Add the local inference port (play agent Cut 2, PA-Q1 A)

A third `InferencePort` beside the connector and the SDK sidecar: one
loopback-only OpenAI-compatible `POST /v1/chat/completions` call per
prepared request, tool calls returned inert for Rust's own evaluator to
drive the next round, exactly as the connector lane already does. No
credential is read or sent; `open_inference` refuses a non-loopback
`LocalBinding` and a model prefix two transports both claim.

`RoutedInferencePort` gains the local slot and routes by longest claimed
prefix, refusing rather than falling back to the connector when a claimed
prefix has no configured port behind it (the same rule the SDK prefix
already had, now made explicit for both). `ControllerModels::each` is
`pub` and `open_inference` takes `models: &[&str]` instead of the whole
struct, so a caller can extend the list with a model the struct does not
carry.

Dungeon's runtime reads `GHOSTLIGHT_LOCAL_ENDPOINT` and
`GHOSTLIGHT_LOCAL_MODEL_PREFIX` and wires the binding through; no other
Dungeon behavior changes in this cut.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-22 02:12:10 +02:00
commit 182f8034f7
7 changed files with 985 additions and 34 deletions

1
Cargo.lock generated
View file

@ -666,6 +666,7 @@ dependencies = [
"codex-connector",
"cultcache-rs",
"ghostlight-persona-projection",
"reqwest",
"rmp-serde",
"serde",
"serde_bytes",

View file

@ -13,11 +13,12 @@ use crate::{
use ghostlight::{
AffordanceId, CONSUMER_BODY_LIMIT, CommandBody, CommandId, ConnectorBinding, ConsumerPort,
ConsumerRegistry, ControllerModels, ControllerRunner, ControllerWorkCustody,
CreateJurisdictionIntent, CreateWorldIntent, DEFAULT_SDK_MODEL_PREFIX, DecisionInvocation,
DecisionOpportunity, KernelError, Lens, LensWeights, MailboxError, PrincipalCommandIntent,
PrincipalId, SdkBinding, SeedOutcome, SeedPort, Statement, SubjectKind, SubmitReceipt,
TickMinutes, VaultEvidenceSource, VerifiedPrincipalEvidence, WorldMailbox, WorldPhase,
WorldSnapshot, open_controller_work, open_inference,
CreateJurisdictionIntent, CreateWorldIntent, DEFAULT_LOCAL_MODEL_PREFIX,
DEFAULT_SDK_MODEL_PREFIX, DecisionInvocation, DecisionOpportunity, KernelError, Lens,
LensWeights, LocalBinding, MailboxError, PrincipalCommandIntent, PrincipalId, SdkBinding,
SeedOutcome, SeedPort, Statement, SubjectKind, SubmitReceipt, TickMinutes, VaultEvidenceSource,
VerifiedPrincipalEvidence, WorldMailbox, WorldPhase, WorldSnapshot, open_controller_work,
open_inference,
};
use anyhow::{Context, bail, ensure};
use axum::{
@ -390,7 +391,21 @@ fn open_controller(
model_prefix: std::env::var("GHOSTLIGHT_SDK_MODEL_PREFIX")
.unwrap_or_else(|_| DEFAULT_SDK_MODEL_PREFIX.into()),
});
let inference = open_inference(connector, sdk, &models)?;
// Ghostlight reads no credential for this transport either: a loopback
// local model server needs none, and `open_inference` refuses anything
// else at open.
let local = match std::env::var("GHOSTLIGHT_LOCAL_ENDPOINT") {
Ok(value) => Some(LocalBinding {
endpoint: value.parse().with_context(|| {
format!("GHOSTLIGHT_LOCAL_ENDPOINT `{value}` is not a socket address")
})?,
model_prefix: std::env::var("GHOSTLIGHT_LOCAL_MODEL_PREFIX")
.unwrap_or_else(|_| DEFAULT_LOCAL_MODEL_PREFIX.into()),
caller_runtime_id: runtime_id.to_owned(),
}),
Err(_) => None,
};
let inference = open_inference(connector, sdk, local, &models.each())?;
let work = open_controller_work(service_root.join("controller-work.cc"))?;
ControllerRunner::open(world.clone(), inference, work, models).map_err(Into::into)
}
@ -1781,7 +1796,7 @@ mod tests {
key_path: controller_key,
caller_runtime_id: "ghostlight-runtime-test".into(),
};
let inference = open_inference(Some(connector), None, &models).unwrap();
let inference = open_inference(Some(connector), None, None, &models.each()).unwrap();
let work = open_controller_work(directory.path().join("controller-work.cc")).unwrap();
let controllers = ControllerRunner::open(world.clone(), inference, work, models).unwrap();
let mesh = MeshPublisher::open(

View file

@ -10,6 +10,7 @@ chrono.workspace = true
codex-connector.workspace = true
cultcache-rs.workspace = true
ghostlight-persona-projection = { path = "../ghostlight-persona-projection" }
reqwest.workspace = true
rmp-serde.workspace = true
serde.workspace = true
serde_bytes = "0.11"

View file

@ -11,6 +11,7 @@ use super::elaboration::{
NullEvidenceSource, SeedCheckpoint, SeedRunner, valid_elaboration_progression,
valid_seed_progression,
};
use super::local_inference::{LocalBinding, open_local_port};
use super::sdk_inference::{
ChildProcessLink, DEFAULT_SDK_MODEL_PREFIX, RoutedInferencePort, SdkBinding, SdkInferencePort,
};
@ -2293,14 +2294,16 @@ pub struct ControllerModels {
impl ControllerModels {
/// Every model a configured backend must claim, in one place, so the
/// open-time routing check and the canonical-identifier gate read the same
/// list.
pub(crate) fn each(&self) -> [&String; 5] {
/// list. `pub` so a caller can extend it with a model this struct does not
/// carry (the play lane's model, Cut 8) before passing the combined slice
/// to `open_inference`.
pub fn each(&self) -> [&str; 5] {
[
&self.projector,
&self.persona,
&self.interpreter,
&self.operational_agent,
&self.elaborator,
self.projector.as_str(),
self.persona.as_str(),
self.interpreter.as_str(),
self.operational_agent.as_str(),
self.elaborator.as_str(),
]
}
@ -2321,6 +2324,10 @@ pub enum ControllerOpenError {
UnroutableModel { model: String },
#[error("the SDK sidecar entry `{path}` is not a file")]
SdkSidecarMissing { path: String },
#[error("the local inference endpoint `{endpoint}` is not loopback")]
LocalEndpointNotLoopback { endpoint: SocketAddr },
#[error("the local and SDK inference transports both claim model prefix `{prefix}`")]
SharedModelPrefix { prefix: String },
}
/// Everything the CodexConnector transport needs to open, gathered so
@ -2332,11 +2339,15 @@ pub struct ConnectorBinding {
}
/// Builds the one port every lane shares. A lane whose model no configured
/// backend claims fails here, at open, rather than at its first tick.
/// backend claims fails here, at open, rather than at its first tick. `models`
/// is every model that must route, not just `ControllerModels`'s own fields,
/// so a caller can pass `&models.each()` extended with a model that struct
/// does not carry (the play lane's, Cut 8).
pub fn open_inference(
connector: Option<ConnectorBinding>,
sdk: Option<SdkBinding>,
models: &ControllerModels,
local: Option<LocalBinding>,
models: &[&str],
) -> Result<Arc<dyn InferencePort>, ControllerOpenError> {
let connector: Option<Arc<dyn InferencePort>> = match connector {
Some(binding) => Some(Arc::new(CodexConnectorInferencePort::from_secret_file(
@ -2362,11 +2373,34 @@ pub fn open_inference(
}
None => None,
};
let routed = RoutedInferencePort::new(connector, sdk, sdk_model_prefix);
for model in models.each() {
// 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.
let local: Option<(String, Arc<dyn InferencePort>)> = match local {
Some(binding) => {
if !binding.endpoint.ip().is_loopback() {
return Err(ControllerOpenError::LocalEndpointNotLoopback {
endpoint: binding.endpoint,
});
}
let prefix = binding.model_prefix.clone();
Some((prefix, open_local_port(binding)))
}
None => None,
};
if let Some((prefix, _)) = &local
&& sdk.is_some()
&& *prefix == sdk_model_prefix
{
return Err(ControllerOpenError::SharedModelPrefix {
prefix: prefix.clone(),
});
}
let routed = RoutedInferencePort::new(connector, sdk, sdk_model_prefix, local);
for &model in models {
if routed.route(model).is_none() {
return Err(ControllerOpenError::UnroutableModel {
model: model.clone(),
model: model.to_owned(),
});
}
}
@ -9224,7 +9258,8 @@ mod tests {
caller_runtime_id: runtime_id,
}),
None,
&models,
None,
&models.each(),
)
.unwrap();
let work = open_controller_work(directory.path().join("controller-work.cc")).unwrap();

View file

@ -63,6 +63,7 @@ mod cover;
mod elaboration;
mod journal;
mod lens;
mod local_inference;
mod mailbox;
mod patch;
mod sdk_inference;
@ -92,6 +93,7 @@ pub use elaboration::{
EvidenceReceipt, EvidenceSource, SeedCheckpoint, SeedOutcome, SeedRunner, select_row,
};
pub use lens::{Lens, LensWeights};
pub use local_inference::{DEFAULT_LOCAL_MODEL_PREFIX, LocalBinding};
pub(crate) use mailbox::ElaborationPort;
pub use mailbox::{ConsumerPort, ControllerPort, MailboxError, SeedPort, WorldMailbox};
pub use patch::{

View file

@ -0,0 +1,860 @@
//! A local OpenAI-compatible inference port, for a loopback model server this
//! process does not authenticate to and does not need a credential for.
//!
//! This module owns one authority: lowering a prepared request to one
//! `POST /v1/chat/completions` call and lifting the reply back into the same
//! `InferenceOutput` every other port produces. It runs no tool loop of its
//! own — a returned tool call is inert, exactly as the connector lane's is —
//! and it holds no mutex across a request, so several requests may be in
//! flight through the same port at once.
use super::controllers::{
InferenceEvent, InferenceFault, InferenceOutput, InferencePort, InferenceRequest,
PreparedInference, REQUEST_EXPIRY, RESPONSE_TIMEOUT, unix_ms,
};
use async_trait::async_trait;
use codex_connector::CodexInputItem;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::net::SocketAddr;
use std::sync::Arc;
/// The model-name prefix that names the local transport when nothing
/// configures one.
pub const DEFAULT_LOCAL_MODEL_PREFIX: &str = "local/";
/// Everything the local port needs to open, gathered so `open_inference`
/// takes a binding rather than reading the environment itself.
pub struct LocalBinding {
pub endpoint: SocketAddr,
pub model_prefix: String,
pub caller_runtime_id: String,
}
const LOCAL_RECEIPT_SCHEMA: &str = "ghostlight.local_inference_receipt.v1";
/// The identity half is computed here from the invocation, exactly as the SDK
/// receipt's is; the provenance half is the endpoint's own reply, carried
/// verbatim.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
struct LocalInferenceReceipt {
schema_id: String,
request_id: String,
conversation_id: String,
caller_runtime_id: String,
native_request_sha256: [u8; 32],
provider_request_sha256: [u8; 32],
model: String,
response_id: String,
finish_reason: Option<String>,
prompt_tokens: u64,
completion_tokens: u64,
}
/// One OpenAI-compatible chat-completions reply, only as much of the shape as
/// this port needs. A reply that does not decode into this shape is an
/// integrity violation, not a best-effort read.
#[derive(Debug, Deserialize)]
struct LocalChatResponse {
id: String,
choices: Vec<LocalChatChoice>,
#[serde(default)]
usage: Option<LocalChatUsage>,
}
#[derive(Debug, Deserialize)]
struct LocalChatChoice {
message: LocalChatMessage,
#[serde(default)]
finish_reason: Option<String>,
}
#[derive(Debug, Deserialize)]
struct LocalChatMessage {
#[serde(default)]
content: Option<String>,
#[serde(default)]
tool_calls: Vec<LocalChatToolCall>,
}
#[derive(Debug, Deserialize)]
struct LocalChatToolCall {
id: String,
function: LocalChatFunctionCall,
}
#[derive(Debug, Deserialize)]
struct LocalChatFunctionCall {
name: String,
arguments: String,
}
#[derive(Debug, Deserialize)]
struct LocalChatUsage {
#[serde(default)]
prompt_tokens: u64,
#[serde(default)]
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
/// own evaluator to drive the next round, exactly as the connector lane
/// already does.
pub(super) struct LocalInferencePort {
client: reqwest::Client,
endpoint: SocketAddr,
prefix: String,
caller_runtime_id: String,
}
impl LocalInferencePort {
fn new(endpoint: SocketAddr, prefix: impl Into<String>, caller_runtime_id: impl Into<String>) -> Self {
Self {
client: reqwest::Client::builder()
.timeout(RESPONSE_TIMEOUT)
.build()
.expect("the local inference HTTP client builds with no custom TLS material"),
endpoint,
prefix: prefix.into(),
caller_runtime_id: caller_runtime_id.into(),
}
}
async fn send(&self, body: Value) -> Result<LocalChatResponse, InferenceFault> {
let response = self
.client
.post(format!("http://{}/v1/chat/completions", self.endpoint))
.json(&body)
.send()
.await
.map_err(|error| {
if error.is_connect() {
InferenceFault::retryable(format!(
"the local inference endpoint refused the connection: {error}"
))
} else {
InferenceFault::new(format!("the local inference request failed: {error}"))
}
})?;
let status = response.status();
if !status.is_success() {
let detail = format!("the local inference endpoint returned {status}");
return Err(if status.as_u16() == 429 || status.as_u16() == 503 {
InferenceFault::retryable(detail)
} else {
InferenceFault::new(detail)
});
}
response.json::<LocalChatResponse>().await.map_err(|error| {
InferenceFault::integrity_violation(format!(
"the local inference endpoint's reply was not the declared shape: {error}"
))
})
}
}
/// Lowers one prepared request to the OpenAI chat-completions body: the
/// system message is the request's instructions, every input item maps to
/// one message in order, and every offered tool is declared `strict`. No
/// header but content type is ever set on the request this builds.
fn lower_request(prepared: &PreparedInference, prefix: &str) -> Result<Value, InferenceFault> {
let request = &prepared.invocation.request;
let model = request.model.strip_prefix(prefix).unwrap_or(&request.model);
let mut messages = vec![json!({"role": "system", "content": request.instructions})];
for item in &request.input {
messages.push(match item {
CodexInputItem::UserText { text } => json!({"role": "user", "content": text}),
CodexInputItem::AssistantText { text } => json!({"role": "assistant", "content": text}),
CodexInputItem::ToolCall {
call_id,
name,
arguments,
} => json!({
"role": "assistant",
"content": Value::Null,
"tool_calls": [{
"id": call_id,
"type": "function",
"function": {"name": name, "arguments": arguments},
}],
}),
CodexInputItem::ToolResult { call_id, output } => json!({
"role": "tool",
"tool_call_id": call_id,
"content": output,
}),
});
}
let mut tools = Vec::with_capacity(request.tools.len());
for tool in &request.tools {
let parameters: Value = serde_json::from_str(&tool.parameters_json).map_err(|error| {
InferenceFault::integrity_violation(format!(
"tool `{}` parameters are not valid JSON: {error}",
tool.name
))
})?;
tools.push(json!({
"type": "function",
"function": {
"name": tool.name,
"description": tool.description,
"parameters": parameters,
"strict": true,
},
}));
}
let mut body = json!({
"model": model,
"messages": messages,
"tools": tools,
"parallel_tool_calls": request.parallel_tool_calls,
});
if let Some(max_tokens) = request.max_output_tokens {
body["max_tokens"] = json!(max_tokens);
}
Ok(body)
}
/// Lifts one chat-completions reply into the port's output. A tool call
/// naming a tool this request never offered, or carrying a call id or tool
/// name the provider contract refuses, is the endpoint lying about the
/// request it was given, not a gap — so it is an integrity violation, not a
/// dropped call.
fn assemble_output(
prepared: &PreparedInference,
response: LocalChatResponse,
) -> Result<InferenceOutput, InferenceFault> {
let request = &prepared.invocation.request;
let mut choices = response.choices;
if choices.is_empty() {
return Err(InferenceFault::integrity_violation(
"the local inference endpoint's reply carried no choices",
));
}
let choice = choices.remove(0);
let mut events = Vec::new();
if let Some(content) = choice.message.content
&& !content.is_empty()
{
events.push(InferenceEvent::Text(content));
}
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(
"the local inference endpoint reported a call id or tool name the provider contract refuses",
));
}
if !request.tools.iter().any(|tool| tool.name == call.function.name) {
return Err(InferenceFault::integrity_violation(
"the local inference endpoint called a tool this request did not offer",
));
}
events.push(InferenceEvent::ToolCall {
call_id: call.id,
name: call.function.name,
arguments: call.function.arguments,
});
}
let receipt = LocalInferenceReceipt {
schema_id: LOCAL_RECEIPT_SCHEMA.to_owned(),
request_id: request.request_id.clone(),
conversation_id: request.conversation_id.clone(),
caller_runtime_id: prepared.invocation.caller_runtime_id.clone(),
native_request_sha256: prepared.invocation.native_request_sha256,
provider_request_sha256: prepared.invocation.provider_request_sha256,
model: request.model.clone(),
response_id: response.id,
finish_reason: choice.finish_reason,
prompt_tokens: response
.usage
.as_ref()
.map(|usage| usage.prompt_tokens)
.unwrap_or_default(),
completion_tokens: response
.usage
.as_ref()
.map(|usage| usage.completion_tokens)
.unwrap_or_default(),
};
let receipt_bytes = rmp_serde::to_vec_named(&receipt)
.map_err(|error| InferenceFault::new(error.to_string()))?;
Ok(InferenceOutput::new(
events,
format!("sha256:{:x}", Sha256::digest(&receipt_bytes)),
))
}
#[async_trait]
impl InferencePort for LocalInferencePort {
fn prepare(&self, request: InferenceRequest) -> Result<PreparedInference, InferenceFault> {
PreparedInference::prepare(
&self.caller_runtime_id,
unix_ms()?.saturating_add(REQUEST_EXPIRY.as_millis() as u64),
request,
)
}
async fn infer(&self, request: PreparedInference) -> Result<InferenceOutput, InferenceFault> {
if request.invocation.caller_runtime_id != self.caller_runtime_id {
return Err(InferenceFault::integrity_violation(
"persisted inference caller does not match the configured runtime identity",
));
}
if request.invocation.expires_at_unix_ms <= unix_ms()? {
return Err(InferenceFault::new(
"persisted inference invocation expired before it reached the local endpoint",
));
}
let body = lower_request(&request, &self.prefix)?;
let response = self.send(body).await?;
assemble_output(&request, response)
}
}
/// 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(
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::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};
use crate::CommandId;
use codex_connector::CodexToolDefinition;
use std::collections::VecDeque;
use std::sync::Mutex;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
const TEST_RUNTIME: &str = "ghostlight-local-test";
const TEST_MODEL: &str = "local/llama-8b";
/// A raw HTTP/1.1 responder over a loopback `TcpListener`, so the driver
/// is exercised against real sockets without a mock HTTP crate. It plays
/// back one scripted `(status, body)` reply per accepted connection and
/// records each request's header block and body for inspection.
struct ScriptedResponder {
addr: SocketAddr,
state: Arc<Mutex<ScriptedResponderState>>,
}
#[derive(Default)]
struct ScriptedResponderState {
replies: VecDeque<(u16, String)>,
headers: Vec<String>,
bodies: Vec<String>,
}
impl ScriptedResponder {
async fn start(replies: Vec<(u16, String)>) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("loopback binds");
let addr = listener.local_addr().expect("a bound listener has a local address");
let state = Arc::new(Mutex::new(ScriptedResponderState {
replies: replies.into(),
..Default::default()
}));
let worker_state = Arc::clone(&state);
tokio::spawn(async move {
loop {
let Ok((stream, _)) = listener.accept().await else {
return;
};
serve_one(stream, &worker_state).await;
}
});
Self { addr, state }
}
fn endpoint(&self) -> SocketAddr {
self.addr
}
fn headers(&self) -> Vec<String> {
self.state.lock().expect("the responder mutex is never poisoned").headers.clone()
}
fn bodies(&self) -> Vec<String> {
self.state.lock().expect("the responder mutex is never poisoned").bodies.clone()
}
}
async fn serve_one(mut stream: TcpStream, state: &Arc<Mutex<ScriptedResponderState>>) {
let mut buffer = Vec::new();
let mut chunk = [0_u8; 4096];
let header_end = loop {
let read = stream.read(&mut chunk).await.expect("the test socket reads");
if read == 0 {
return;
}
buffer.extend_from_slice(&chunk[..read]);
if let Some(pos) = find_double_crlf(&buffer) {
break pos;
}
};
let header_block = String::from_utf8_lossy(&buffer[..header_end]).into_owned();
let content_length: usize = header_block
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("content-length")
.then(|| value.trim().parse().ok())
.flatten()
})
.unwrap_or(0);
let body_start = header_end + 4;
while buffer.len() < body_start + content_length {
let read = stream.read(&mut chunk).await.expect("the test socket reads");
if read == 0 {
break;
}
buffer.extend_from_slice(&chunk[..read]);
}
let body = String::from_utf8_lossy(&buffer[body_start..body_start + content_length]).into_owned();
let (status, reply_body) = {
let mut guard = state.lock().expect("the responder mutex is never poisoned");
guard.headers.push(header_block);
guard.bodies.push(body);
guard.replies.pop_front().unwrap_or((500, "{}".to_owned()))
};
let reason = match status {
200 => "OK",
400 => "Bad Request",
429 => "Too Many Requests",
503 => "Service Unavailable",
_ => "Error",
};
let response = format!(
"HTTP/1.1 {status} {reason}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{reply_body}",
reply_body.len()
);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.shutdown().await;
}
fn find_double_crlf(buffer: &[u8]) -> Option<usize> {
buffer.windows(4).position(|window| window == b"\r\n\r\n")
}
fn tool_call_reply(id: &str, name: &str, arguments: &str) -> String {
json!({
"id": format!("resp-{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}},
],
},
"finish_reason": "tool_calls",
}],
"usage": {"prompt_tokens": 10, "completion_tokens": 5},
})
.to_string()
}
fn text_reply(text: &str) -> String {
json!({
"id": "resp-text",
"choices": [{
"message": {"role": "assistant", "content": text, "tool_calls": []},
"finish_reason": "stop",
}],
"usage": {"prompt_tokens": 3, "completion_tokens": 2},
})
.to_string()
}
fn seed_shape() -> RequestShape {
RequestShape {
max_output_tokens: 4_000,
parallel_tool_calls: false,
}
}
fn port(endpoint: SocketAddr) -> LocalInferencePort {
LocalInferencePort::new(endpoint, DEFAULT_LOCAL_MODEL_PREFIX, TEST_RUNTIME)
}
/// Spec test: pins invariant 7. Two rounds through the real seed
/// evaluator: round 0 carries two non-terminal tool calls, round 1
/// submits, and the second HTTP request literally carries the first
/// round's tool calls and their derived results.
#[tokio::test]
async fn a_scripted_backend_drives_an_elaboration_round_through_the_existing_evaluator() {
let prompt = "Author the shortfall.";
let responder = ScriptedResponder::start(vec![
(
200,
two_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, "{}")),
])
.await;
let port = port(responder.endpoint());
let command_id = CommandId::new();
let round0 = tool_request(
command_id,
0,
InferencePurpose::Elaboration,
TEST_MODEL,
"Author the shortfall.",
vec![CodexInputItem::UserText {
text: prompt.into(),
}],
patch_tools(),
seed_shape(),
)
.expect("round 0 builds");
let prepared0 = port.prepare(round0).expect("round 0 prepares");
let output0 = port.infer(prepared0).await.expect("round 0 infers");
assert_eq!(
output0.events.len(),
2,
"both round-0 tool calls came back inert"
);
let ElaborationLoopEvaluation::Continue { conversation } =
evaluate_elaboration_loop(prompt, &[], std::slice::from_ref(&output0), SEED_ROUND_BUDGET)
.expect("round 0 re-derives")
else {
panic!("round 0 completed early");
};
let round1 = tool_request(
command_id,
1,
InferencePurpose::Elaboration,
TEST_MODEL,
"Author the shortfall.",
conversation,
patch_tools(),
seed_shape(),
)
.expect("round 1 builds");
let prepared1 = port.prepare(round1).expect("round 1 prepares");
let output1 = port.infer(prepared1).await.expect("round 1 infers");
let evaluation =
evaluate_elaboration_loop(prompt, &[], &[output0, output1], SEED_ROUND_BUDGET)
.expect("the seed evaluator re-derives");
let ElaborationLoopEvaluation::Complete { capture } = evaluation else {
panic!("two local inference rounds did not close the seed round")
};
assert!(capture.submitted, "the re-derived capture did not submit");
let bodies = responder.bodies();
assert_eq!(bodies.len(), 2, "the port made anything but two requests");
// The second request is Rust's own loop replaying the first round's
// tool calls and their fold-derived results, not the port's.
assert!(bodies[1].contains("no route"));
assert!(bodies[1].contains("still missing"));
assert!(bodies[1].contains("call-0"));
assert!(bodies[1].contains("call-1"));
assert!(bodies[1].contains("gap recorded"));
}
/// Spec test: pins the message mapping item by item.
#[tokio::test]
async fn the_request_lowers_every_input_item() {
let responder = ScriptedResponder::start(vec![(200, text_reply("acknowledged"))]).await;
let port = port(responder.endpoint());
let request = tool_request(
CommandId::new(),
0,
InferencePurpose::Elaboration,
TEST_MODEL,
"Only reply once every item is accounted for.",
vec![
CodexInputItem::UserText {
text: "Open the gate.".into(),
},
CodexInputItem::AssistantText {
text: "Checking the ledger.".into(),
},
CodexInputItem::ToolCall {
call_id: "call-9".into(),
name: RECORD_GAP_PATCH_TOOL.into(),
arguments: r#"{"detail":"no ledger entry"}"#.into(),
},
CodexInputItem::ToolResult {
call_id: "call-9".into(),
output: "gap recorded".into(),
},
],
patch_tools(),
seed_shape(),
)
.expect("the request builds");
let prepared = port.prepare(request).expect("the port prepares");
let output = port.infer(prepared).await.expect("the port infers");
assert_eq!(
output.events,
vec![InferenceEvent::Text("acknowledged".into())]
);
let sent: Value = serde_json::from_str(&responder.bodies()[0]).expect("the body is JSON");
let messages = sent["messages"].as_array().expect("messages is an array");
assert_eq!(messages.len(), 5, "instructions plus four input items");
assert_eq!(messages[0]["role"], "system");
assert_eq!(messages[1]["role"], "user");
assert_eq!(messages[1]["content"], "Open the gate.");
assert_eq!(messages[2]["role"], "assistant");
assert_eq!(messages[2]["content"], "Checking the ledger.");
assert_eq!(messages[3]["role"], "assistant");
assert_eq!(
messages[3]["tool_calls"][0]["function"]["name"],
RECORD_GAP_PATCH_TOOL
);
assert_eq!(messages[4]["role"], "tool");
assert_eq!(messages[4]["tool_call_id"], "call-9");
assert_eq!(messages[4]["content"], "gap recorded");
}
/// Spec test.
#[test]
fn a_non_loopback_endpoint_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: DEFAULT_SDK_MODEL_PREFIX.into(),
}),
Some(LocalBinding {
endpoint: "93.184.216.34:443".parse().unwrap(),
model_prefix: DEFAULT_LOCAL_MODEL_PREFIX.into(),
caller_runtime_id: TEST_RUNTIME.into(),
}),
&[TEST_MODEL],
)
.err()
.expect("a non-loopback local endpoint opened");
assert!(matches!(
error,
ControllerOpenError::LocalEndpointNotLoopback { .. }
));
}
/// Spec test.
#[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;
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("an unoffered tool call produced an output");
assert!(fault.integrity_was_violated(), "{fault:?}");
}
/// Spec test. `RoutedInferencePort` prefers the longest matching claim
/// among overlapping prefixes, and `open_inference` refuses two slots
/// that claim the identical prefix outright.
#[test]
fn routing_prefers_the_longest_claim_and_refuses_a_shared_prefix() {
let local_port = Arc::new(port("127.0.0.1:1".parse().unwrap())) as Arc<dyn InferencePort>;
let sdk_port = Arc::new(port("127.0.0.1:1".parse().unwrap())) as Arc<dyn InferencePort>;
let connector_port = Arc::new(port("127.0.0.1:1".parse().unwrap())) as Arc<dyn InferencePort>;
let routed = RoutedInferencePort::new(
Some(Arc::clone(&connector_port)),
Some(Arc::clone(&sdk_port)),
"claude",
Some(("claude-local/".to_owned(), Arc::clone(&local_port))),
);
assert!(Arc::ptr_eq(
routed.route("claude-local/llama-8b").unwrap(),
&local_port
));
assert!(Arc::ptr_eq(routed.route("claude-opus-5").unwrap(), &sdk_port));
assert!(Arc::ptr_eq(
routed.route("gpt-5.6-terra").unwrap(),
&connector_port
));
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: "claude".into(),
}),
Some(LocalBinding {
endpoint: "127.0.0.1:1".parse().unwrap(),
model_prefix: "claude".into(),
caller_runtime_id: TEST_RUNTIME.into(),
}),
&["claude-opus-5"],
)
.err()
.expect("two ports sharing one prefix opened");
assert!(matches!(
error,
ControllerOpenError::SharedModelPrefix { .. }
));
}
/// Spec test. The responder asserts on the raw header block, not the
/// decoded request, so a header this port never intended to send cannot
/// hide behind a friendly wrapper's own parsing.
#[tokio::test]
async fn no_request_carries_an_authorization_header() {
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 output = port.infer(prepared).await.expect("the port infers");
assert_eq!(output.events, vec![InferenceEvent::Text("ok".into())]);
let header_block = responder.headers()[0].to_ascii_lowercase();
assert!(!header_block.contains("authorization"));
assert!(!header_block.contains("bearer"));
assert!(!header_block.contains("api-key"));
assert!(!header_block.contains("api_key"));
}
/// Spec test (mutation M2.3). Connection refusal and the two status codes
/// the map names are retryable; any other unsuccessful status is
/// recovery-required, not a special case.
#[tokio::test]
async fn connection_refusal_and_429_503_are_retryable_other_statuses_are_recovery_required() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let refused_endpoint = listener.local_addr().unwrap();
drop(listener);
let refused = port(refused_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 = refused.prepare(request.clone()).expect("the port prepares");
let fault = refused
.infer(prepared)
.await
.expect_err("a closed port accepted a connection");
assert!(
!fault.requires_recovery(),
"a refused connection was not retryable: {fault:?}"
);
assert!(!fault.integrity_was_violated());
for (status, retryable) in [(429_u16, true), (503, true), (400, false)] {
let responder = ScriptedResponder::start(vec![(status, "{}".to_owned())]).await;
let statused = port(responder.endpoint());
let prepared = statused.prepare(request.clone()).expect("the port prepares");
let fault = statused
.infer(prepared)
.await
.expect_err("a non-2xx status produced an output");
assert!(!fault.integrity_was_violated(), "{fault:?}");
assert_eq!(
!fault.requires_recovery(),
retryable,
"status {status} disposition mismatch: {fault:?}"
);
}
}
}

View file

@ -716,6 +716,10 @@ pub(super) struct RoutedInferencePort {
connector: Option<Arc<dyn InferencePort>>,
sdk: Option<Arc<dyn InferencePort>>,
sdk_model_prefix: String,
/// The local port's own claimed prefix, carried beside it rather than in a
/// third bare `String` field, so `route` can compare its length against
/// the SDK's claim without a third parallel field to keep in sync.
local: Option<(String, Arc<dyn InferencePort>)>,
}
impl RoutedInferencePort {
@ -723,22 +727,47 @@ impl RoutedInferencePort {
connector: Option<Arc<dyn InferencePort>>,
sdk: Option<Arc<dyn InferencePort>>,
sdk_model_prefix: impl Into<String>,
local: Option<(String, Arc<dyn InferencePort>)>,
) -> Self {
Self {
connector,
sdk,
sdk_model_prefix: sdk_model_prefix.into(),
local,
}
}
/// The model name carries the transport. A prefixed model that no SDK port
/// claims routes nowhere rather than falling back: a fallback is what would
/// make a typo in a model environment variable silent.
/// The model name carries the transport. A prefixed model that no
/// configured port claims routes nowhere rather than falling back: a
/// fallback is what would make a typo in a model environment variable
/// silent. When more than one prefix matches, the longest claim wins, so a
/// local prefix that extends the SDK's own (`claude-local/` beside
/// `claude`) is unambiguous without either operator having to avoid the
/// other's namespace.
pub(super) fn route(&self, model: &str) -> Option<&Arc<dyn InferencePort>> {
if model.starts_with(&self.sdk_model_prefix) {
self.sdk.as_ref()
} else {
self.connector.as_ref()
// A prefix claims the model whether or not its port is configured: a
// claimed-but-unconfigured prefix refuses rather than falling back to
// the connector, which is what keeps a typo'd model prefix loud
// instead of silently reaching the wrong backend.
let local_claim = self
.local
.as_ref()
.filter(|(prefix, _)| model.starts_with(prefix.as_str()))
.map(|(prefix, port)| (prefix.len(), port));
let sdk_claim = model
.starts_with(&self.sdk_model_prefix)
.then_some(self.sdk_model_prefix.len());
match (local_claim, sdk_claim) {
(Some((local_len, local_port)), Some(sdk_len)) => {
if local_len >= sdk_len {
Some(local_port)
} else {
self.sdk.as_ref()
}
}
(Some((_, local_port)), None) => Some(local_port),
(None, Some(_)) => self.sdk.as_ref(),
(None, None) => self.connector.as_ref(),
}
}
}
@ -1309,14 +1338,19 @@ mod tests {
Some(Arc::clone(&connector)),
Some(Arc::clone(&sdk)),
DEFAULT_SDK_MODEL_PREFIX,
None,
);
assert!(Arc::ptr_eq(routed.route("claude-opus-5").unwrap(), &sdk));
assert!(Arc::ptr_eq(
routed.route("gpt-5.6-terra").unwrap(),
&connector
));
let moved =
RoutedInferencePort::new(Some(Arc::clone(&connector)), Some(Arc::clone(&sdk)), "gpt-");
let moved = RoutedInferencePort::new(
Some(Arc::clone(&connector)),
Some(Arc::clone(&sdk)),
"gpt-",
None,
);
assert!(Arc::ptr_eq(moved.route("gpt-5.6-terra").unwrap(), &sdk));
assert!(Arc::ptr_eq(
moved.route("claude-opus-5").unwrap(),
@ -1354,11 +1388,11 @@ mod tests {
};
assert!(matches!(
open_inference(None, Some(sdk()), &models("gpt-5.6-terra")),
open_inference(None, Some(sdk()), None, &models("gpt-5.6-terra").each()),
Err(ControllerOpenError::UnroutableModel { .. })
));
assert!(matches!(
open_inference(Some(connector()), None, &models("claude-opus-5")),
open_inference(Some(connector()), None, None, &models("claude-opus-5").each()),
Err(ControllerOpenError::UnroutableModel { .. })
));
assert!(matches!(
@ -1369,12 +1403,13 @@ mod tests {
caller_runtime_id: TEST_RUNTIME.into(),
model_prefix: DEFAULT_SDK_MODEL_PREFIX.into(),
}),
&models(TEST_MODEL),
None,
&models(TEST_MODEL).each(),
),
Err(ControllerOpenError::SdkSidecarMissing { .. })
));
assert!(
open_inference(Some(connector()), Some(sdk()), &models(TEST_MODEL)).is_ok(),
open_inference(Some(connector()), Some(sdk()), None, &models(TEST_MODEL).each()).is_ok(),
"a configuration where every model routes did not open"
);
}
@ -1768,6 +1803,7 @@ mod tests {
)) as Arc<dyn InferencePort>),
None,
DEFAULT_SDK_MODEL_PREFIX,
None,
);
assert!(connector_only.route(TEST_MODEL).is_none());
let prepared =
@ -1798,7 +1834,8 @@ mod tests {
caller_runtime_id: TEST_RUNTIME.into(),
model_prefix: DEFAULT_SDK_MODEL_PREFIX.into(),
}),
&models(TEST_MODEL),
None,
&models(TEST_MODEL).each(),
)
.is_ok()
);