Cut 2 fix batch: decode tolerance, open-time refusals, and dedup (PA.f18-f20, PA.f22)
Soul's Cut 2 verdicts left five findings after PA.f16/f17: two decode gaps, two missing open-time refusals, one duplication/placement cleanup, and four surviving mutations with no test naming them. All fix here. PA.f18: LocalChatResponse.id and LocalChatMessage.tool_calls both integrity- violated on shapes a nonstandard local server can plausibly send. An id is used only for receipt bookkeeping, so #[serde(default)] tolerates an absent one. tool_calls already tolerated an absent field through #[serde(default)] but not an explicit "tool_calls": null, since default only fires when the key is missing; deserialize_with = "null_as_default" covers both. Tool-call ids still integrity-violate on absence, since the tool loop must correlate them. PA.f19: open_inference now refuses an empty local or SDK model prefix (ControllerOpenError::EmptyModelPrefix) instead of letting it match every model and silently take a transport's lanes. assemble_output now refuses a reply carrying the same tool-call id twice, as integrity_violation, since the port owns reply integrity and duplicate ids would otherwise reach the evaluator unchecked. PA.f20: call_id_is_valid and tool_name_is_valid are hoisted to one pub(super) copy in controllers.rs; local_inference.rs and sdk_inference.rs both import it instead of keeping their own. The loopback check moves out of open_inference and into LocalInferencePort::new itself, so it holds by construction for every caller, not only the one open_inference exercises; open_local_port and open_inference propagate the constructor's Result. A request offering no tools now omits `tools` and `parallel_tool_calls` from the lowered body instead of sending an empty array. Test helper two_tool_call_reply is folded into tool_call_reply, which now takes a slice of calls. PA.f22: one test per mutation that survived Soul's Cut 2 pass, each run by hand against this commit and reverted after killing its test: offered_tools_are_declared_in_the_request_body (S2, empty tools array), the_model_prefix_is_stripped_in_the_request_body (S3, unstripped model), infer_refuses_a_foreign_caller and infer_refuses_an_expired_invocation (S4, both infer gates), the_receipt_digest_changes_with_the_response_id (S6, response_id dropped from the receipt). Local lib census: 9 (after the prior commit) -> 21 in local_inference::; 488 -> 502 in the full `cargo test -p ghostlight --lib` run (501 passed, 1 ignored). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
a062b56e1f
commit
4ac7b828e0
3 changed files with 458 additions and 93 deletions
|
|
@ -242,6 +242,22 @@ impl InferenceFault {
|
|||
}
|
||||
}
|
||||
|
||||
/// A call id the connector's own validators would refuse cannot be persisted:
|
||||
/// the evaluator rebuilds these into `CodexInputItem`s that `validate()` will
|
||||
/// see again. Both the local and SDK ports police a provider reply against
|
||||
/// this one rule, so there is one copy rather than two that can drift.
|
||||
pub(super) fn call_id_is_valid(call_id: &str) -> bool {
|
||||
!call_id.is_empty() && call_id.len() <= 64 && call_id.is_ascii()
|
||||
}
|
||||
|
||||
pub(super) fn tool_name_is_valid(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& name.len() <= 64
|
||||
&& name
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
|
||||
}
|
||||
|
||||
/// One lane's own answer to "what does this tool call return", lent to the
|
||||
/// inference port for exactly one query. A port that runs the tool loop needs a
|
||||
/// real result before the model will take another turn, and the only correct
|
||||
|
|
@ -2328,6 +2344,8 @@ pub enum ControllerOpenError {
|
|||
LocalEndpointNotLoopback { endpoint: SocketAddr },
|
||||
#[error("the local and SDK inference transports both claim model prefix `{prefix}`")]
|
||||
SharedModelPrefix { prefix: String },
|
||||
#[error("the {transport} inference transport's model prefix must not be empty")]
|
||||
EmptyModelPrefix { transport: &'static str },
|
||||
}
|
||||
|
||||
/// Everything the CodexConnector transport needs to open, gathered so
|
||||
|
|
@ -2365,6 +2383,9 @@ pub fn open_inference(
|
|||
path: binding.sidecar_entry.display().to_string(),
|
||||
});
|
||||
}
|
||||
if binding.model_prefix.is_empty() {
|
||||
return Err(ControllerOpenError::EmptyModelPrefix { transport: "SDK" });
|
||||
}
|
||||
sdk_model_prefix = binding.model_prefix;
|
||||
Some(Arc::new(SdkInferencePort::new(
|
||||
Arc::new(ChildProcessLink::new(binding.sidecar_entry)),
|
||||
|
|
@ -2374,17 +2395,16 @@ pub fn open_inference(
|
|||
None => None,
|
||||
};
|
||||
// Ghostlight never holds a credential for this transport: a loopback
|
||||
// local model server needs none, and refusing anything else here is what
|
||||
// keeps that true by construction rather than by convention.
|
||||
// local model server needs none, and `open_local_port` refuses anything
|
||||
// else by construction, inside the port's own constructor, rather than by
|
||||
// convention in this caller.
|
||||
let local: Option<(String, Arc<dyn InferencePort>)> = match local {
|
||||
Some(binding) => {
|
||||
if !binding.endpoint.ip().is_loopback() {
|
||||
return Err(ControllerOpenError::LocalEndpointNotLoopback {
|
||||
endpoint: binding.endpoint,
|
||||
});
|
||||
if binding.model_prefix.is_empty() {
|
||||
return Err(ControllerOpenError::EmptyModelPrefix { transport: "local" });
|
||||
}
|
||||
let prefix = binding.model_prefix.clone();
|
||||
Some((prefix, open_local_port(binding)))
|
||||
Some((prefix, open_local_port(binding)?))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,8 +9,9 @@
|
|||
//! flight through the same port at once.
|
||||
|
||||
use super::controllers::{
|
||||
InferenceEvent, InferenceFault, InferenceOutput, InferencePort, InferenceRequest,
|
||||
PreparedInference, REQUEST_EXPIRY, RESPONSE_TIMEOUT, unix_ms,
|
||||
ControllerOpenError, InferenceEvent, InferenceFault, InferenceOutput, InferencePort,
|
||||
InferenceRequest, PreparedInference, REQUEST_EXPIRY, RESPONSE_TIMEOUT, call_id_is_valid,
|
||||
tool_name_is_valid, unix_ms,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use codex_connector::CodexInputItem;
|
||||
|
|
@ -57,6 +58,10 @@ struct LocalInferenceReceipt {
|
|||
/// integrity violation, not a best-effort read.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LocalChatResponse {
|
||||
/// Used only for receipt bookkeeping (`response_id`), so an absent id is
|
||||
/// tolerated rather than an integrity violation; a missing or duplicate
|
||||
/// tool call id, which the tool loop must correlate against, still is.
|
||||
#[serde(default)]
|
||||
id: String,
|
||||
choices: Vec<LocalChatChoice>,
|
||||
#[serde(default)]
|
||||
|
|
@ -74,10 +79,25 @@ struct LocalChatChoice {
|
|||
struct LocalChatMessage {
|
||||
#[serde(default)]
|
||||
content: Option<String>,
|
||||
#[serde(default)]
|
||||
/// An absent `tool_calls` field already decodes to the empty vec through
|
||||
/// `#[serde(default)]`; `null_as_default` additionally covers an explicit
|
||||
/// `"tool_calls": null`, which `#[serde(default)]` alone does not, since
|
||||
/// `default` only fires when the key is missing, not when it is present
|
||||
/// and null.
|
||||
#[serde(default, deserialize_with = "null_as_default")]
|
||||
tool_calls: Vec<LocalChatToolCall>,
|
||||
}
|
||||
|
||||
/// Treats a present-but-null field the same as an absent one. Paired with
|
||||
/// `#[serde(default)]`, which covers only the absent case on its own.
|
||||
fn null_as_default<'de, D, T>(deserializer: D) -> Result<T, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
T: Default + Deserialize<'de>,
|
||||
{
|
||||
Ok(Option::deserialize(deserializer)?.unwrap_or_default())
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LocalChatToolCall {
|
||||
id: String,
|
||||
|
|
@ -98,20 +118,6 @@ struct LocalChatUsage {
|
|||
completion_tokens: u64,
|
||||
}
|
||||
|
||||
/// A call id the connector's own validators would refuse cannot be persisted,
|
||||
/// same rule the SDK port applies to its sidecar's reports.
|
||||
fn call_id_is_valid(call_id: &str) -> bool {
|
||||
!call_id.is_empty() && call_id.len() <= 64 && call_id.is_ascii()
|
||||
}
|
||||
|
||||
fn tool_name_is_valid(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& name.len() <= 64
|
||||
&& name
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
|
||||
}
|
||||
|
||||
/// The local OpenAI-compatible backend behind Ghostlight's inference seam.
|
||||
/// Unlike the SDK port, it runs no query loop: one prepared request lowers to
|
||||
/// one HTTP call, and any tool call in the reply comes back inert for Rust's
|
||||
|
|
@ -125,14 +131,23 @@ pub(super) struct LocalInferencePort {
|
|||
}
|
||||
|
||||
impl LocalInferencePort {
|
||||
/// The client is built with `.no_proxy()` so an environment proxy
|
||||
/// variable can never carry this request, or a `proxy-authorization`
|
||||
/// header derived from a proxy URL's userinfo, off this loopback
|
||||
/// endpoint (PA.f16); and with redirects disabled, so a 3xx reply cannot
|
||||
/// re-POST the request's contents to a second endpoint this port never
|
||||
/// opened (PA.f17).
|
||||
fn new(endpoint: SocketAddr, prefix: impl Into<String>, caller_runtime_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
/// The loopback check happens here, in the constructor, so a non-loopback
|
||||
/// endpoint cannot reach this port through any path that builds one —
|
||||
/// not only the one `open_inference` exercises today. The client is
|
||||
/// built with `.no_proxy()` so an environment proxy variable can never
|
||||
/// carry this request, or a `proxy-authorization` header derived from a
|
||||
/// proxy URL's userinfo, off this loopback endpoint; and with redirects
|
||||
/// disabled, so a 3xx reply cannot re-POST the request's contents to a
|
||||
/// second endpoint this port never opened.
|
||||
fn new(
|
||||
endpoint: SocketAddr,
|
||||
prefix: impl Into<String>,
|
||||
caller_runtime_id: impl Into<String>,
|
||||
) -> Result<Self, ControllerOpenError> {
|
||||
if !endpoint.ip().is_loopback() {
|
||||
return Err(ControllerOpenError::LocalEndpointNotLoopback { endpoint });
|
||||
}
|
||||
Ok(Self {
|
||||
client: reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
|
|
@ -142,7 +157,7 @@ impl LocalInferencePort {
|
|||
endpoint,
|
||||
prefix: prefix.into(),
|
||||
caller_runtime_id: caller_runtime_id.into(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn send(&self, body: Value) -> Result<LocalChatResponse, InferenceFault> {
|
||||
|
|
@ -231,9 +246,11 @@ fn lower_request(prepared: &PreparedInference, prefix: &str) -> Result<Value, In
|
|||
let mut body = json!({
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"tools": tools,
|
||||
"parallel_tool_calls": request.parallel_tool_calls,
|
||||
});
|
||||
if !tools.is_empty() {
|
||||
body["tools"] = json!(tools);
|
||||
body["parallel_tool_calls"] = json!(request.parallel_tool_calls);
|
||||
}
|
||||
if let Some(max_tokens) = request.max_output_tokens {
|
||||
body["max_tokens"] = json!(max_tokens);
|
||||
}
|
||||
|
|
@ -263,6 +280,7 @@ fn assemble_output(
|
|||
{
|
||||
events.push(InferenceEvent::Text(content));
|
||||
}
|
||||
let mut seen_call_ids = std::collections::HashSet::new();
|
||||
for call in choice.message.tool_calls {
|
||||
if !call_id_is_valid(&call.id) || !tool_name_is_valid(&call.function.name) {
|
||||
return Err(InferenceFault::integrity_violation(
|
||||
|
|
@ -274,6 +292,11 @@ fn assemble_output(
|
|||
"the local inference endpoint called a tool this request did not offer",
|
||||
));
|
||||
}
|
||||
if !seen_call_ids.insert(call.id.clone()) {
|
||||
return Err(InferenceFault::integrity_violation(
|
||||
"the local inference endpoint reported the same call id twice",
|
||||
));
|
||||
}
|
||||
events.push(InferenceEvent::ToolCall {
|
||||
call_id: call.id,
|
||||
name: call.function.name,
|
||||
|
|
@ -336,24 +359,23 @@ impl InferencePort for LocalInferencePort {
|
|||
}
|
||||
}
|
||||
|
||||
/// Builds the local port from its binding. The caller validates the endpoint
|
||||
/// is loopback before this is reached: this constructor does no validation of
|
||||
/// its own, so it cannot silently open a non-loopback port through a path
|
||||
/// that skips the check `open_inference` runs.
|
||||
pub(super) fn open_local_port(binding: LocalBinding) -> Arc<dyn InferencePort> {
|
||||
Arc::new(LocalInferencePort::new(
|
||||
/// Builds the local port from its binding. The loopback check lives in
|
||||
/// `LocalInferencePort::new` itself, so it holds by construction for every
|
||||
/// caller of this function, not only `open_inference`.
|
||||
pub(super) fn open_local_port(
|
||||
binding: LocalBinding,
|
||||
) -> Result<Arc<dyn InferencePort>, ControllerOpenError> {
|
||||
Ok(Arc::new(LocalInferencePort::new(
|
||||
binding.endpoint,
|
||||
binding.model_prefix,
|
||||
binding.caller_runtime_id,
|
||||
))
|
||||
)?))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::controllers::{
|
||||
ControllerOpenError, InferencePurpose, RequestShape, open_inference, tool_request,
|
||||
};
|
||||
use crate::controllers::{InferencePurpose, RequestShape, open_inference, tool_request};
|
||||
use crate::elaboration::{ElaborationLoopEvaluation, SEED_ROUND_BUDGET, evaluate_elaboration_loop};
|
||||
use crate::patch::{RECORD_GAP_PATCH_TOOL, SUBMIT_PATCH_TOOL, patch_tools};
|
||||
use crate::sdk_inference::{DEFAULT_SDK_MODEL_PREFIX, RoutedInferencePort, SdkBinding};
|
||||
|
|
@ -506,37 +528,32 @@ mod tests {
|
|||
addr
|
||||
}
|
||||
|
||||
fn tool_call_reply(id: &str, name: &str, arguments: &str) -> String {
|
||||
/// One or more tool calls in one reply. Folded from the former
|
||||
/// single-call and two-call variants so there is one shape to keep
|
||||
/// consistent (PA.f20).
|
||||
fn tool_call_reply(calls: &[(&str, &str, &str)]) -> String {
|
||||
let id = if calls.len() == 1 {
|
||||
format!("resp-{}", calls[0].0)
|
||||
} else {
|
||||
"resp-double".to_owned()
|
||||
};
|
||||
let tool_calls: Vec<Value> = calls
|
||||
.iter()
|
||||
.map(|(call_id, name, arguments)| {
|
||||
json!({
|
||||
"id": call_id,
|
||||
"type": "function",
|
||||
"function": {"name": name, "arguments": arguments},
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
json!({
|
||||
"id": format!("resp-{id}"),
|
||||
"id": id,
|
||||
"choices": [{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": Value::Null,
|
||||
"tool_calls": [{
|
||||
"id": id,
|
||||
"type": "function",
|
||||
"function": {"name": name, "arguments": arguments},
|
||||
}],
|
||||
},
|
||||
"finish_reason": "tool_calls",
|
||||
}],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 5},
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn two_tool_call_reply(first: (&str, &str, &str), second: (&str, &str, &str)) -> String {
|
||||
json!({
|
||||
"id": "resp-double",
|
||||
"choices": [{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": Value::Null,
|
||||
"tool_calls": [
|
||||
{"id": first.0, "type": "function", "function": {"name": first.1, "arguments": first.2}},
|
||||
{"id": second.0, "type": "function", "function": {"name": second.1, "arguments": second.2}},
|
||||
],
|
||||
"tool_calls": tool_calls,
|
||||
},
|
||||
"finish_reason": "tool_calls",
|
||||
}],
|
||||
|
|
@ -546,8 +563,12 @@ mod tests {
|
|||
}
|
||||
|
||||
fn text_reply(text: &str) -> String {
|
||||
text_reply_with_id("resp-text", text)
|
||||
}
|
||||
|
||||
fn text_reply_with_id(id: &str, text: &str) -> String {
|
||||
json!({
|
||||
"id": "resp-text",
|
||||
"id": id,
|
||||
"choices": [{
|
||||
"message": {"role": "assistant", "content": text, "tool_calls": []},
|
||||
"finish_reason": "stop",
|
||||
|
|
@ -581,6 +602,7 @@ mod tests {
|
|||
.lock()
|
||||
.expect("the client-build lock is never poisoned");
|
||||
LocalInferencePort::new(endpoint, DEFAULT_LOCAL_MODEL_PREFIX, TEST_RUNTIME)
|
||||
.expect("a loopback endpoint opens")
|
||||
}
|
||||
|
||||
/// Spec test: pins invariant 7. Two rounds through the real seed
|
||||
|
|
@ -593,12 +615,12 @@ mod tests {
|
|||
let responder = ScriptedResponder::start(vec![
|
||||
(
|
||||
200,
|
||||
two_tool_call_reply(
|
||||
tool_call_reply(&[
|
||||
("call-0", RECORD_GAP_PATCH_TOOL, r#"{"detail":"no route"}"#),
|
||||
("call-1", RECORD_GAP_PATCH_TOOL, r#"{"detail":"still missing"}"#),
|
||||
),
|
||||
]),
|
||||
),
|
||||
(200, tool_call_reply("call-2", SUBMIT_PATCH_TOOL, "{}")),
|
||||
(200, tool_call_reply(&[("call-2", SUBMIT_PATCH_TOOL, "{}")])),
|
||||
])
|
||||
.await;
|
||||
let port = port(responder.endpoint());
|
||||
|
|
@ -754,7 +776,7 @@ mod tests {
|
|||
#[tokio::test]
|
||||
async fn a_call_to_an_unoffered_tool_is_an_integrity_violation() {
|
||||
let responder =
|
||||
ScriptedResponder::start(vec![(200, tool_call_reply("call-0", "speek", "{}"))]).await;
|
||||
ScriptedResponder::start(vec![(200, tool_call_reply(&[("call-0", "speek", "{}")]))]).await;
|
||||
let port = port(responder.endpoint());
|
||||
let request = tool_request(
|
||||
CommandId::new(),
|
||||
|
|
@ -941,7 +963,7 @@ mod tests {
|
|||
std::env::remove_var("HTTP_PROXY");
|
||||
std::env::remove_var("http_proxy");
|
||||
}
|
||||
built
|
||||
built.expect("a loopback endpoint opens")
|
||||
};
|
||||
|
||||
let request = tool_request(
|
||||
|
|
@ -1014,4 +1036,342 @@ mod tests {
|
|||
"the redirect target received a request"
|
||||
);
|
||||
}
|
||||
|
||||
/// Spec test (PA.f18). An explicit `"tool_calls": null` decodes as no
|
||||
/// calls, the same as an absent field, instead of failing to decode into
|
||||
/// an integrity violation.
|
||||
#[test]
|
||||
fn a_null_tool_calls_field_decodes_as_no_calls() {
|
||||
let raw = json!({
|
||||
"id": "resp-null",
|
||||
"choices": [{
|
||||
"message": {"role": "assistant", "content": "ok", "tool_calls": Value::Null},
|
||||
"finish_reason": "stop",
|
||||
}],
|
||||
})
|
||||
.to_string();
|
||||
let response: LocalChatResponse =
|
||||
serde_json::from_str(&raw).expect("a null tool_calls field decodes");
|
||||
assert!(response.choices[0].message.tool_calls.is_empty());
|
||||
}
|
||||
|
||||
/// Spec test (PA.f18). An absent `tool_calls` field decodes the same way
|
||||
/// as an explicit null.
|
||||
#[test]
|
||||
fn an_absent_tool_calls_field_decodes_as_no_calls() {
|
||||
let raw = json!({
|
||||
"id": "resp-absent",
|
||||
"choices": [{
|
||||
"message": {"role": "assistant", "content": "ok"},
|
||||
"finish_reason": "stop",
|
||||
}],
|
||||
})
|
||||
.to_string();
|
||||
let response: LocalChatResponse =
|
||||
serde_json::from_str(&raw).expect("an absent tool_calls field decodes");
|
||||
assert!(response.choices[0].message.tool_calls.is_empty());
|
||||
}
|
||||
|
||||
/// Spec test (PA.f18). An absent top-level `id` decodes to the empty
|
||||
/// string. It is used only for receipt bookkeeping, so a local server
|
||||
/// that omits it should not quarantine the lane.
|
||||
#[test]
|
||||
fn an_absent_response_id_decodes_as_empty() {
|
||||
let raw = json!({
|
||||
"choices": [{
|
||||
"message": {"role": "assistant", "content": "ok", "tool_calls": []},
|
||||
"finish_reason": "stop",
|
||||
}],
|
||||
})
|
||||
.to_string();
|
||||
let response: LocalChatResponse =
|
||||
serde_json::from_str(&raw).expect("an absent id decodes");
|
||||
assert_eq!(response.id, "");
|
||||
}
|
||||
|
||||
/// Spec test (PA.f19). Open refuses an empty local model prefix: an empty
|
||||
/// prefix matches every model and would silently take the local port's
|
||||
/// lanes.
|
||||
#[test]
|
||||
fn an_empty_local_model_prefix_is_refused_at_open() {
|
||||
let error = open_inference(
|
||||
None,
|
||||
None,
|
||||
Some(LocalBinding {
|
||||
endpoint: "127.0.0.1:1".parse().unwrap(),
|
||||
model_prefix: String::new(),
|
||||
caller_runtime_id: TEST_RUNTIME.into(),
|
||||
}),
|
||||
&[TEST_MODEL],
|
||||
)
|
||||
.err()
|
||||
.expect("an empty local model prefix opened");
|
||||
assert!(matches!(
|
||||
error,
|
||||
ControllerOpenError::EmptyModelPrefix { transport: "local" }
|
||||
));
|
||||
}
|
||||
|
||||
/// Spec test (PA.f19). Open refuses an empty SDK model prefix for the
|
||||
/// same reason.
|
||||
#[test]
|
||||
fn an_empty_sdk_model_prefix_is_refused_at_open() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let sdk_entry = directory.path().join("main.js");
|
||||
std::fs::write(&sdk_entry, "// sidecar").unwrap();
|
||||
let error = open_inference(
|
||||
None,
|
||||
Some(SdkBinding {
|
||||
sidecar_entry: sdk_entry,
|
||||
caller_runtime_id: TEST_RUNTIME.into(),
|
||||
model_prefix: String::new(),
|
||||
}),
|
||||
None,
|
||||
&[TEST_MODEL],
|
||||
)
|
||||
.err()
|
||||
.expect("an empty SDK model prefix opened");
|
||||
assert!(matches!(
|
||||
error,
|
||||
ControllerOpenError::EmptyModelPrefix { transport: "SDK" }
|
||||
));
|
||||
}
|
||||
|
||||
/// Spec test (PA.f19). Duplicate call ids in one reply pass neither the
|
||||
/// port's own validators nor the evaluator's; the port refuses them
|
||||
/// itself, as an integrity violation, rather than letting the evaluator
|
||||
/// see two calls it cannot tell apart.
|
||||
#[tokio::test]
|
||||
async fn duplicate_call_ids_in_one_reply_are_an_integrity_violation() {
|
||||
let responder = ScriptedResponder::start(vec![(
|
||||
200,
|
||||
tool_call_reply(&[
|
||||
("call-0", RECORD_GAP_PATCH_TOOL, r#"{"detail":"first"}"#),
|
||||
("call-0", RECORD_GAP_PATCH_TOOL, r#"{"detail":"second"}"#),
|
||||
]),
|
||||
)])
|
||||
.await;
|
||||
let port = port(responder.endpoint());
|
||||
let request = tool_request(
|
||||
CommandId::new(),
|
||||
0,
|
||||
InferencePurpose::Elaboration,
|
||||
TEST_MODEL,
|
||||
"Author the shortfall.",
|
||||
vec![CodexInputItem::UserText {
|
||||
text: "Author the shortfall.".into(),
|
||||
}],
|
||||
patch_tools(),
|
||||
seed_shape(),
|
||||
)
|
||||
.expect("the request builds");
|
||||
let prepared = port.prepare(request).expect("the port prepares");
|
||||
let fault = port
|
||||
.infer(prepared)
|
||||
.await
|
||||
.expect_err("a duplicate call id produced an output");
|
||||
assert!(fault.integrity_was_violated(), "{fault:?}");
|
||||
}
|
||||
|
||||
/// Spec test (PA.f20). A request offering no tools omits `tools` and
|
||||
/// `parallel_tool_calls` from the lowered body instead of sending an
|
||||
/// empty array.
|
||||
#[tokio::test]
|
||||
async fn a_request_with_no_tools_omits_tools_and_parallel_tool_calls() {
|
||||
let responder = ScriptedResponder::start(vec![(200, text_reply("ok"))]).await;
|
||||
let port = port(responder.endpoint());
|
||||
let request = tool_request(
|
||||
CommandId::new(),
|
||||
0,
|
||||
InferencePurpose::Persona,
|
||||
TEST_MODEL,
|
||||
"Respond only in natural prose.",
|
||||
vec![CodexInputItem::UserText {
|
||||
text: "Say something true.".into(),
|
||||
}],
|
||||
Vec::<CodexToolDefinition>::new(),
|
||||
RequestShape {
|
||||
max_output_tokens: 1_200,
|
||||
parallel_tool_calls: false,
|
||||
},
|
||||
)
|
||||
.expect("the request builds");
|
||||
let prepared = port.prepare(request).expect("the port prepares");
|
||||
let _ = port.infer(prepared).await.expect("the port infers");
|
||||
|
||||
let sent: Value = serde_json::from_str(&responder.bodies()[0]).expect("the body is JSON");
|
||||
let object = sent.as_object().expect("the body is a JSON object");
|
||||
assert!(!object.contains_key("tools"), "{sent}");
|
||||
assert!(!object.contains_key("parallel_tool_calls"), "{sent}");
|
||||
}
|
||||
|
||||
/// Spec test (PA.f22, Soul's S2). The tool declarations offered actually
|
||||
/// reach the request body; a lowering that always sent an empty `tools`
|
||||
/// array would pass every other test in this module.
|
||||
#[tokio::test]
|
||||
async fn offered_tools_are_declared_in_the_request_body() {
|
||||
let responder = ScriptedResponder::start(vec![(200, text_reply("ok"))]).await;
|
||||
let port = port(responder.endpoint());
|
||||
let request = tool_request(
|
||||
CommandId::new(),
|
||||
0,
|
||||
InferencePurpose::Elaboration,
|
||||
TEST_MODEL,
|
||||
"Author the shortfall.",
|
||||
vec![CodexInputItem::UserText {
|
||||
text: "Author the shortfall.".into(),
|
||||
}],
|
||||
patch_tools(),
|
||||
seed_shape(),
|
||||
)
|
||||
.expect("the request builds");
|
||||
let prepared = port.prepare(request).expect("the port prepares");
|
||||
let _ = port.infer(prepared).await.expect("the port infers");
|
||||
|
||||
let sent: Value = serde_json::from_str(&responder.bodies()[0]).expect("the body is JSON");
|
||||
let tools = sent["tools"].as_array().expect("tools is an array");
|
||||
assert_eq!(tools.len(), patch_tools().len(), "{sent}");
|
||||
let names: Vec<&str> = tools
|
||||
.iter()
|
||||
.map(|tool| tool["function"]["name"].as_str().expect("a tool name"))
|
||||
.collect();
|
||||
assert!(names.contains(&RECORD_GAP_PATCH_TOOL), "{names:?}");
|
||||
assert!(names.contains(&SUBMIT_PATCH_TOOL), "{names:?}");
|
||||
}
|
||||
|
||||
/// Spec test (PA.f22, Soul's S3). The model prefix is stripped before the
|
||||
/// request is sent.
|
||||
#[tokio::test]
|
||||
async fn the_model_prefix_is_stripped_in_the_request_body() {
|
||||
let responder = ScriptedResponder::start(vec![(200, text_reply("ok"))]).await;
|
||||
let port = port(responder.endpoint());
|
||||
let request = tool_request(
|
||||
CommandId::new(),
|
||||
0,
|
||||
InferencePurpose::Persona,
|
||||
TEST_MODEL,
|
||||
"Respond only in natural prose.",
|
||||
vec![CodexInputItem::UserText {
|
||||
text: "Say something true.".into(),
|
||||
}],
|
||||
Vec::<CodexToolDefinition>::new(),
|
||||
RequestShape {
|
||||
max_output_tokens: 1_200,
|
||||
parallel_tool_calls: false,
|
||||
},
|
||||
)
|
||||
.expect("the request builds");
|
||||
let prepared = port.prepare(request).expect("the port prepares");
|
||||
let _ = port.infer(prepared).await.expect("the port infers");
|
||||
|
||||
let sent: Value = serde_json::from_str(&responder.bodies()[0]).expect("the body is JSON");
|
||||
assert_eq!(
|
||||
sent["model"],
|
||||
TEST_MODEL.strip_prefix(DEFAULT_LOCAL_MODEL_PREFIX).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
/// Spec test (PA.f22, Soul's S4). The foreign-caller gate in `infer`
|
||||
/// refuses a persisted invocation stamped with a different runtime
|
||||
/// identity than the one this port was configured with.
|
||||
#[tokio::test]
|
||||
async fn infer_refuses_a_foreign_caller() {
|
||||
let responder = ScriptedResponder::start(vec![(200, text_reply("ok"))]).await;
|
||||
let port = port(responder.endpoint());
|
||||
let request = tool_request(
|
||||
CommandId::new(),
|
||||
0,
|
||||
InferencePurpose::Persona,
|
||||
TEST_MODEL,
|
||||
"Respond only in natural prose.",
|
||||
vec![CodexInputItem::UserText {
|
||||
text: "Say something true.".into(),
|
||||
}],
|
||||
Vec::<CodexToolDefinition>::new(),
|
||||
RequestShape {
|
||||
max_output_tokens: 1_200,
|
||||
parallel_tool_calls: false,
|
||||
},
|
||||
)
|
||||
.expect("the request builds");
|
||||
let mut prepared = port.prepare(request).expect("the port prepares");
|
||||
prepared.invocation.caller_runtime_id = "someone-elses-runtime".into();
|
||||
let fault = port
|
||||
.infer(prepared)
|
||||
.await
|
||||
.expect_err("a foreign caller's invocation produced an output");
|
||||
assert!(fault.integrity_was_violated(), "{fault:?}");
|
||||
assert!(responder.bodies().is_empty(), "a foreign invocation reached the endpoint");
|
||||
}
|
||||
|
||||
/// Spec test (PA.f22, Soul's S4). The expiry gate in `infer` refuses a
|
||||
/// persisted invocation whose expiry has already passed.
|
||||
#[tokio::test]
|
||||
async fn infer_refuses_an_expired_invocation() {
|
||||
let responder = ScriptedResponder::start(vec![(200, text_reply("ok"))]).await;
|
||||
let port = port(responder.endpoint());
|
||||
let request = tool_request(
|
||||
CommandId::new(),
|
||||
0,
|
||||
InferencePurpose::Persona,
|
||||
TEST_MODEL,
|
||||
"Respond only in natural prose.",
|
||||
vec![CodexInputItem::UserText {
|
||||
text: "Say something true.".into(),
|
||||
}],
|
||||
Vec::<CodexToolDefinition>::new(),
|
||||
RequestShape {
|
||||
max_output_tokens: 1_200,
|
||||
parallel_tool_calls: false,
|
||||
},
|
||||
)
|
||||
.expect("the request builds");
|
||||
let mut prepared = port.prepare(request).expect("the port prepares");
|
||||
prepared.invocation.expires_at_unix_ms = 1;
|
||||
let fault = port
|
||||
.infer(prepared)
|
||||
.await
|
||||
.expect_err("an expired invocation produced an output");
|
||||
assert!(!fault.integrity_was_violated(), "{fault:?}");
|
||||
assert!(responder.bodies().is_empty(), "an expired invocation reached the endpoint");
|
||||
}
|
||||
|
||||
/// Spec test (PA.f22, Soul's S6). The receipt digest changes when the
|
||||
/// reply's `response_id` changes and nothing else does.
|
||||
#[tokio::test]
|
||||
async fn the_receipt_digest_changes_with_the_response_id() {
|
||||
let request = tool_request(
|
||||
CommandId::new(),
|
||||
0,
|
||||
InferencePurpose::Persona,
|
||||
TEST_MODEL,
|
||||
"Respond only in natural prose.",
|
||||
vec![CodexInputItem::UserText {
|
||||
text: "Say something true.".into(),
|
||||
}],
|
||||
Vec::<CodexToolDefinition>::new(),
|
||||
RequestShape {
|
||||
max_output_tokens: 1_200,
|
||||
parallel_tool_calls: false,
|
||||
},
|
||||
)
|
||||
.expect("the request builds");
|
||||
|
||||
let first_responder =
|
||||
ScriptedResponder::start(vec![(200, text_reply_with_id("resp-aaa", "ok"))]).await;
|
||||
let first_port = port(first_responder.endpoint());
|
||||
let first_prepared = first_port.prepare(request.clone()).expect("the port prepares");
|
||||
let first_output = first_port.infer(first_prepared).await.expect("the port infers");
|
||||
|
||||
let second_responder =
|
||||
ScriptedResponder::start(vec![(200, text_reply_with_id("resp-bbb", "ok"))]).await;
|
||||
let second_port = port(second_responder.endpoint());
|
||||
let second_prepared = second_port.prepare(request).expect("the port prepares");
|
||||
let second_output = second_port.infer(second_prepared).await.expect("the port infers");
|
||||
|
||||
assert_ne!(
|
||||
first_output.receipt_digest, second_output.receipt_digest,
|
||||
"a changed response_id did not change the receipt digest"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@
|
|||
|
||||
use super::controllers::{
|
||||
InferenceEvent, InferenceFault, InferenceOutput, InferencePort, InferenceRequest,
|
||||
PreparedInference, REQUEST_EXPIRY, RESPONSE_TIMEOUT, ToolResultOracle,
|
||||
unix_ms,
|
||||
PreparedInference, REQUEST_EXPIRY, RESPONSE_TIMEOUT, ToolResultOracle, call_id_is_valid,
|
||||
tool_name_is_valid, unix_ms,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use codex_connector::CodexInputItem;
|
||||
|
|
@ -573,21 +573,6 @@ pub(super) fn lower_query(
|
|||
})
|
||||
}
|
||||
|
||||
/// A call id the connector's own validators would refuse cannot be persisted:
|
||||
/// the evaluator rebuilds these into `CodexInputItem`s that `validate()` will
|
||||
/// see again.
|
||||
fn call_id_is_valid(call_id: &str) -> bool {
|
||||
!call_id.is_empty() && call_id.len() <= 64 && call_id.is_ascii()
|
||||
}
|
||||
|
||||
fn tool_name_is_valid(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& name.len() <= 64
|
||||
&& name
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
|
||||
}
|
||||
|
||||
fn assemble_output(
|
||||
prepared: &PreparedInference,
|
||||
events: Vec<SidecarEvent>,
|
||||
|
|
|
|||
Loading…
Reference in a new issue