From 6e7c73bdc4f426ffee1a5537999b11ab0870e7f8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 22 Sep 2026 06:01:07 +0200 Subject: [PATCH] Give the local port's response id the same null tolerance as tool_calls (PA.f23, PA.f24) PA.f23: LocalChatResponse.id already tolerated an absent field through #[serde(default)], but not an explicit "id": null, since #[serde(default)] only fires when the key is missing. Give it the same null_as_default treatment tool_calls already has, with a_null_response_id_decodes_as_empty mirroring the existing tool_calls coverage. PA.f24: add ipv6_loopback_opens_and_a_non_loopback_ipv6_address_is_refused, pinning that the loopback check is IP-address loopback and not "any IPv6 address": [::1] opens and a routable IPv6 address such as [2001:db8::1]:8080 is refused the same way a non-loopback IPv4 address is. Test-only; the loopback check itself was already correct. Co-Authored-By: Claude Sonnet 5 --- crates/ghostlight/src/local_inference.rs | 53 +++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/crates/ghostlight/src/local_inference.rs b/crates/ghostlight/src/local_inference.rs index 1d37423..8ff0111 100644 --- a/crates/ghostlight/src/local_inference.rs +++ b/crates/ghostlight/src/local_inference.rs @@ -61,7 +61,10 @@ 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)] + /// `null_as_default` covers an explicit `"id": null` the same way it + /// does for `tool_calls`: `#[serde(default)]` alone only fires when the + /// key is missing, not when it is present and null. + #[serde(default, deserialize_with = "null_as_default")] id: String, choices: Vec, #[serde(default)] @@ -772,6 +775,36 @@ mod tests { )); } + /// PA.f24: the loopback check is IP-address loopback, not "any IPv6 + /// address" or "any address that parses." `[::1]` — IPv6 loopback — + /// opens, and a routable IPv6 address such as `[2001:db8::1]:8080` (a + /// documentation-range address; never loopback) is refused the same + /// way a non-loopback IPv4 address is. + #[test] + fn ipv6_loopback_opens_and_a_non_loopback_ipv6_address_is_refused() { + let _guard = client_build_lock() + .lock() + .expect("the client-build lock is never poisoned"); + LocalInferencePort::new( + "[::1]:1".parse().unwrap(), + DEFAULT_LOCAL_MODEL_PREFIX, + TEST_RUNTIME, + ) + .expect("the IPv6 loopback address opens"); + + let error = LocalInferencePort::new( + "[2001:db8::1]:8080".parse().unwrap(), + DEFAULT_LOCAL_MODEL_PREFIX, + TEST_RUNTIME, + ) + .err() + .expect("a non-loopback IPv6 endpoint opened"); + assert!(matches!( + error, + ControllerOpenError::LocalEndpointNotLoopback { .. } + )); + } + /// Spec test. #[tokio::test] async fn a_call_to_an_unoffered_tool_is_an_integrity_violation() { @@ -1089,6 +1122,24 @@ mod tests { assert_eq!(response.id, ""); } + /// PA.f23: an explicit `"id": null` decodes to the empty string the + /// same way an absent `id` does, instead of failing to decode. Mirrors + /// `a_null_tool_calls_field_decodes_as_no_calls`. + #[test] + fn a_null_response_id_decodes_as_empty() { + let raw = json!({ + "id": Value::Null, + "choices": [{ + "message": {"role": "assistant", "content": "ok", "tool_calls": []}, + "finish_reason": "stop", + }], + }) + .to_string(); + let response: LocalChatResponse = + serde_json::from_str(&raw).expect("a null 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.