Make provider policy caller-owned
This commit is contained in:
parent
032d25d5f3
commit
19ef672aed
4 changed files with 284 additions and 7 deletions
32
Cargo.lock
generated
32
Cargo.lock
generated
|
|
@ -76,6 +76,7 @@ dependencies = [
|
|||
"rand_core",
|
||||
"rmp-serde",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror",
|
||||
]
|
||||
|
|
@ -159,12 +160,24 @@ dependencies = [
|
|||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
|
|
@ -268,6 +281,19 @@ dependencies = [
|
|||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
|
|
@ -349,3 +375,9 @@ name = "wasi"
|
|||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
|
|
|
|||
|
|
@ -16,5 +16,6 @@ aes-gcm = "0.10"
|
|||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
rmp-serde = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
thiserror = "2"
|
||||
|
|
|
|||
|
|
@ -25,6 +25,11 @@ receipt binding their SHA-256. A consumer-native request digest is carried for
|
|||
audit correlation; the connector does not interpret or rederive consumer-native
|
||||
cognition.
|
||||
|
||||
Tool choice, parallel-tool policy, provider-safe call IDs, strict tool schemas,
|
||||
structured-output naming, and the output-token bound are therefore caller-owned
|
||||
fields. The shared contract renders one deterministic Responses body and
|
||||
refuses inputs that would require the daemon to rename, project, or repair them.
|
||||
|
||||
Tool calls are typed output. The connector never executes them.
|
||||
|
||||
The encrypted envelope contains only MessagePack contracts and an AES-256-GCM
|
||||
|
|
|
|||
253
src/lib.rs
253
src/lib.rs
|
|
@ -36,9 +36,11 @@ pub struct CodexProviderRequest {
|
|||
pub reasoning_effort: Option<String>,
|
||||
pub reasoning_summary: Option<String>,
|
||||
pub service_tier: Option<String>,
|
||||
pub output_contract_id: Option<String>,
|
||||
pub output_format_name: Option<String>,
|
||||
pub previous_response_id: Option<String>,
|
||||
pub tools: Vec<CodexToolDefinition>,
|
||||
pub tool_choice: CodexToolChoice,
|
||||
pub parallel_tool_calls: bool,
|
||||
pub output_schema_json: Option<String>,
|
||||
pub max_output_tokens: Option<u32>,
|
||||
pub prompt_cache_key: Option<String>,
|
||||
|
|
@ -61,9 +63,11 @@ impl CodexProviderRequest {
|
|||
reasoning_effort: None,
|
||||
reasoning_summary: None,
|
||||
service_tier: None,
|
||||
output_contract_id: None,
|
||||
output_format_name: None,
|
||||
previous_response_id: None,
|
||||
tools: Vec::new(),
|
||||
tool_choice: CodexToolChoice::Auto,
|
||||
parallel_tool_calls: false,
|
||||
output_schema_json: None,
|
||||
max_output_tokens: None,
|
||||
prompt_cache_key: None,
|
||||
|
|
@ -92,7 +96,7 @@ impl CodexProviderRequest {
|
|||
(self.reasoning_effort.as_deref(), "reasoning_effort"),
|
||||
(self.reasoning_summary.as_deref(), "reasoning_summary"),
|
||||
(self.service_tier.as_deref(), "service_tier"),
|
||||
(self.output_contract_id.as_deref(), "output_contract_id"),
|
||||
(self.output_format_name.as_deref(), "output_format_name"),
|
||||
(self.previous_response_id.as_deref(), "previous_response_id"),
|
||||
] {
|
||||
if let Some(value) = value {
|
||||
|
|
@ -101,13 +105,27 @@ impl CodexProviderRequest {
|
|||
}
|
||||
if let Some(schema) = &self.output_schema_json {
|
||||
require_content(schema, "output_schema_json")?;
|
||||
require_json_object(schema, "output_schema_json")?;
|
||||
require_provider_name(
|
||||
self.output_format_name
|
||||
.as_deref()
|
||||
.ok_or(ContractError::Invalid("output_format_name"))?,
|
||||
"output_format_name",
|
||||
)?;
|
||||
} else if self.output_format_name.is_some() {
|
||||
return Err(ContractError::Invalid("output_schema_json"));
|
||||
}
|
||||
|
||||
if self.tools.is_empty() && self.tool_choice == CodexToolChoice::Required {
|
||||
return Err(ContractError::Invalid("tool_choice"));
|
||||
}
|
||||
|
||||
let mut tool_names = HashSet::new();
|
||||
for tool in &self.tools {
|
||||
require_id(&tool.name, "tool.name")?;
|
||||
require_provider_name(&tool.name, "tool.name")?;
|
||||
require_content(&tool.description, "tool.description")?;
|
||||
require_content(&tool.parameters_json, "tool.parameters_json")?;
|
||||
require_json_object(&tool.parameters_json, "tool.parameters_json")?;
|
||||
if !tool_names.insert(tool.name.as_str()) {
|
||||
return Err(ContractError::DuplicateTool(tool.name.clone()));
|
||||
}
|
||||
|
|
@ -122,12 +140,12 @@ impl CodexProviderRequest {
|
|||
name,
|
||||
arguments,
|
||||
} => {
|
||||
require_id(call_id, "input.call_id")?;
|
||||
require_id(name, "input.tool_name")?;
|
||||
require_provider_call_id(call_id, "input.call_id")?;
|
||||
require_provider_name(name, "input.tool_name")?;
|
||||
require_content(arguments, "input.arguments")?;
|
||||
}
|
||||
CodexInputItem::ToolResult { call_id, output } => {
|
||||
require_id(call_id, "input.call_id")?;
|
||||
require_provider_call_id(call_id, "input.call_id")?;
|
||||
require_content(output, "input.output")?;
|
||||
}
|
||||
}
|
||||
|
|
@ -136,6 +154,13 @@ impl CodexProviderRequest {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CodexToolChoice {
|
||||
Auto,
|
||||
Required,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum CodexInputItem {
|
||||
UserText {
|
||||
|
|
@ -411,6 +436,141 @@ pub fn provider_request_sha256(request: &CodexProviderRequest) -> Result<[u8; 32
|
|||
Ok(Sha256::digest(canonical_provider_request_bytes(request)?).into())
|
||||
}
|
||||
|
||||
pub fn canonical_responses_body(
|
||||
request: &CodexProviderRequest,
|
||||
) -> Result<serde_json::Value, ContractError> {
|
||||
request.validate()?;
|
||||
let input = request
|
||||
.input
|
||||
.iter()
|
||||
.map(|item| match item {
|
||||
CodexInputItem::UserText { text } => serde_json::json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": text}],
|
||||
}),
|
||||
CodexInputItem::AssistantText { text } => serde_json::json!({
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": text}],
|
||||
}),
|
||||
CodexInputItem::ToolCall {
|
||||
call_id,
|
||||
name,
|
||||
arguments,
|
||||
} => serde_json::json!({
|
||||
"type": "function_call",
|
||||
"call_id": call_id,
|
||||
"name": name,
|
||||
"arguments": arguments,
|
||||
}),
|
||||
CodexInputItem::ToolResult { call_id, output } => serde_json::json!({
|
||||
"type": "function_call_output",
|
||||
"call_id": call_id,
|
||||
"output": output,
|
||||
}),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let tools = request
|
||||
.tools
|
||||
.iter()
|
||||
.map(|tool| {
|
||||
Ok(serde_json::json!({
|
||||
"type": "function",
|
||||
"name": tool.name,
|
||||
"description": tool.description,
|
||||
"parameters": serde_json::from_str::<serde_json::Value>(&tool.parameters_json)
|
||||
.map_err(|_| ContractError::Invalid("tool.parameters_json"))?,
|
||||
"strict": true,
|
||||
}))
|
||||
})
|
||||
.collect::<Result<Vec<_>, ContractError>>()?;
|
||||
let mut body = serde_json::json!({
|
||||
"model": request.model,
|
||||
"instructions": request.instructions,
|
||||
"input": input,
|
||||
"tools": tools,
|
||||
"tool_choice": match request.tool_choice {
|
||||
CodexToolChoice::Auto => "auto",
|
||||
CodexToolChoice::Required => "required",
|
||||
},
|
||||
"parallel_tool_calls": request.parallel_tool_calls,
|
||||
"store": false,
|
||||
"stream": true,
|
||||
"include": [],
|
||||
});
|
||||
let object = body
|
||||
.as_object_mut()
|
||||
.expect("the statically constructed Responses body is an object");
|
||||
if request.reasoning_effort.is_some() || request.reasoning_summary.is_some() {
|
||||
let mut reasoning = serde_json::Map::new();
|
||||
if let Some(effort) = &request.reasoning_effort {
|
||||
reasoning.insert("effort".to_string(), serde_json::json!(effort));
|
||||
}
|
||||
if let Some(summary) = &request.reasoning_summary {
|
||||
reasoning.insert("summary".to_string(), serde_json::json!(summary));
|
||||
}
|
||||
object.insert("reasoning".to_string(), reasoning.into());
|
||||
}
|
||||
for (name, value) in [
|
||||
(
|
||||
"service_tier",
|
||||
request
|
||||
.service_tier
|
||||
.as_ref()
|
||||
.map(|value| serde_json::json!(value)),
|
||||
),
|
||||
(
|
||||
"previous_response_id",
|
||||
request
|
||||
.previous_response_id
|
||||
.as_ref()
|
||||
.map(|value| serde_json::json!(value)),
|
||||
),
|
||||
(
|
||||
"prompt_cache_key",
|
||||
request
|
||||
.prompt_cache_key
|
||||
.as_ref()
|
||||
.map(|value| serde_json::json!(value)),
|
||||
),
|
||||
(
|
||||
"max_output_tokens",
|
||||
request
|
||||
.max_output_tokens
|
||||
.map(|value| serde_json::json!(value)),
|
||||
),
|
||||
] {
|
||||
if let Some(value) = value {
|
||||
object.insert(name.to_string(), value);
|
||||
}
|
||||
}
|
||||
if let (Some(name), Some(schema)) = (
|
||||
request.output_format_name.as_ref(),
|
||||
request.output_schema_json.as_ref(),
|
||||
) {
|
||||
object.insert(
|
||||
"text".to_string(),
|
||||
serde_json::json!({
|
||||
"format": {
|
||||
"type": "json_schema",
|
||||
"name": name,
|
||||
"strict": true,
|
||||
"schema": serde_json::from_str::<serde_json::Value>(schema)
|
||||
.map_err(|_| ContractError::Invalid("output_schema_json"))?,
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
pub fn canonical_responses_body_bytes(
|
||||
request: &CodexProviderRequest,
|
||||
) -> Result<Vec<u8>, ContractError> {
|
||||
serde_json::to_vec(&canonical_responses_body(request)?).map_err(|_| ContractError::Encoding)
|
||||
}
|
||||
|
||||
pub fn canonical_invocation_bytes(
|
||||
invocation: &CodexTransportInvocation,
|
||||
) -> Result<Vec<u8>, ContractError> {
|
||||
|
|
@ -900,6 +1060,35 @@ fn require_content(value: &str, field: &'static str) -> Result<(), ContractError
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn require_json_object(value: &str, field: &'static str) -> Result<(), ContractError> {
|
||||
if !matches!(
|
||||
serde_json::from_str::<serde_json::Value>(value),
|
||||
Ok(serde_json::Value::Object(_))
|
||||
) {
|
||||
return Err(ContractError::Invalid(field));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_provider_name(value: &str, field: &'static str) -> Result<(), ContractError> {
|
||||
if value.is_empty()
|
||||
|| value.len() > 64
|
||||
|| !value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
|
||||
{
|
||||
return Err(ContractError::Invalid(field));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_provider_call_id(value: &str, field: &'static str) -> Result<(), ContractError> {
|
||||
if value.is_empty() || value.len() > 64 || !value.is_ascii() {
|
||||
return Err(ContractError::Invalid(field));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Error, PartialEq, Eq)]
|
||||
pub enum ContractError {
|
||||
#[error("unexpected schema")]
|
||||
|
|
@ -983,6 +1172,56 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_responses_body_preserves_caller_owned_provider_policy() {
|
||||
let mut request = request();
|
||||
request.tool_choice = CodexToolChoice::Required;
|
||||
request.parallel_tool_calls = false;
|
||||
request.reasoning_effort = Some("high".to_string());
|
||||
request.previous_response_id = Some("response-previous".to_string());
|
||||
request.max_output_tokens = Some(512);
|
||||
request.output_format_name = Some("epiphany_role_result_v3".to_string());
|
||||
request.output_schema_json = Some(
|
||||
r#"{"type":"object","properties":{"summary":{"type":"string"}},"required":["summary"],"additionalProperties":false}"#
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
let body = canonical_responses_body(&request).unwrap();
|
||||
assert_eq!(body["tool_choice"], "required");
|
||||
assert_eq!(body["parallel_tool_calls"], false);
|
||||
assert_eq!(body["reasoning"], serde_json::json!({"effort": "high"}));
|
||||
assert_eq!(body["previous_response_id"], "response-previous");
|
||||
assert_eq!(body["max_output_tokens"], 512);
|
||||
assert_eq!(body["tools"][0]["name"], "read_source");
|
||||
assert_eq!(body["text"]["format"]["name"], "epiphany_role_result_v3");
|
||||
assert_eq!(
|
||||
canonical_responses_body_bytes(&request).unwrap(),
|
||||
serde_json::to_vec(&body).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_contract_refuses_transport_side_fixup_pressure() {
|
||||
let mut request = request();
|
||||
request.output_format_name = Some("epiphany.role.result".to_string());
|
||||
request.output_schema_json = Some(r#"{"type":"object"}"#.to_string());
|
||||
assert_eq!(
|
||||
request.validate(),
|
||||
Err(ContractError::Invalid("output_format_name"))
|
||||
);
|
||||
|
||||
request.output_format_name = None;
|
||||
request.output_schema_json = None;
|
||||
request.input.push(CodexInputItem::ToolResult {
|
||||
call_id: "x".repeat(65),
|
||||
output: "evidence".to_string(),
|
||||
});
|
||||
assert_eq!(
|
||||
request.validate(),
|
||||
Err(ContractError::Invalid("input.call_id"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invocation_refuses_provider_request_substitution() {
|
||||
let mut invocation = invocation();
|
||||
|
|
|
|||
Loading…
Reference in a new issue