diff --git a/crates/ironclaw_conversations/src/inbound.rs b/crates/ironclaw_conversations/src/inbound.rs index fc6038a6f15..77b64ee0ca2 100644 --- a/crates/ironclaw_conversations/src/inbound.rs +++ b/crates/ironclaw_conversations/src/inbound.rs @@ -243,6 +243,7 @@ where let turn_submission_result = self .turn_coordinator .submit_turn(SubmitTurnRequest { + requested_model: None, scope: resolution.turn_scope.clone(), actor: accepted_message.actor.clone(), accepted_message_ref: accepted_message.message_ref.clone(), diff --git a/crates/ironclaw_host_runtime/tests/support/host_runtime_harness.rs b/crates/ironclaw_host_runtime/tests/support/host_runtime_harness.rs index fa3579c7a27..e4d7e6212c5 100644 --- a/crates/ironclaw_host_runtime/tests/support/host_runtime_harness.rs +++ b/crates/ironclaw_host_runtime/tests/support/host_runtime_harness.rs @@ -2264,6 +2264,7 @@ pub(crate) fn http_without_body_then_operation_failed_wat() -> String { #[cfg(feature = "libsql")] pub(crate) fn submit_turn_request(thread: &str, idempotency_key: &str) -> SubmitTurnRequest { SubmitTurnRequest { + requested_model: None, scope: TurnScope::new( TenantId::new("tenant1").unwrap(), Some(AgentId::new("agent1").unwrap()), diff --git a/crates/ironclaw_loop_host/tests/turn_event_publisher_contract.rs b/crates/ironclaw_loop_host/tests/turn_event_publisher_contract.rs index 78acc233ee6..613de295a98 100644 --- a/crates/ironclaw_loop_host/tests/turn_event_publisher_contract.rs +++ b/crates/ironclaw_loop_host/tests/turn_event_publisher_contract.rs @@ -30,6 +30,7 @@ fn actor() -> TurnActor { fn submit_request(thread: &str, idempotency_key: &str) -> SubmitTurnRequest { SubmitTurnRequest { + requested_model: None, scope: scope(thread), actor: actor(), accepted_message_ref: AcceptedMessageRef::new(format!("message-{idempotency_key}")) diff --git a/crates/ironclaw_product_adapters/src/inbound.rs b/crates/ironclaw_product_adapters/src/inbound.rs index 107615a2a9f..83ff7e69af5 100644 --- a/crates/ironclaw_product_adapters/src/inbound.rs +++ b/crates/ironclaw_product_adapters/src/inbound.rs @@ -15,6 +15,7 @@ use crate::outbound::ProjectionCursor; use crate::redaction::RedactedString; const USER_MESSAGE_TEXT_MAX_BYTES: usize = 64 * 1024; +const REQUESTED_MODEL_MAX_BYTES: usize = 256; const COMMAND_MAX_BYTES: usize = 256; const COMMAND_ARGUMENTS_MAX_BYTES: usize = 64 * 1024; const THREAD_HINT_MAX_BYTES: usize = 512; @@ -99,6 +100,13 @@ pub struct UserMessagePayload { pub text: String, pub attachments: Vec, pub trigger: ProductTriggerReason, + /// Caller-requested model for this turn (e.g. an OpenAI-compatible client's + /// `model` field). A model *hint*, not authority: the coordinator routes to + /// it only when the operator has it configured, otherwise it falls back to + /// the deployment's active model. `None` for surfaces that don't select a + /// model (chat UI, channels). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub requested_model: Option, } impl UserMessagePayload { @@ -111,13 +119,25 @@ impl UserMessagePayload { text: text.into(), attachments, trigger, + requested_model: None, }; payload.validate()?; Ok(payload) } + /// Attach a caller-requested model to this payload. See + /// [`UserMessagePayload::requested_model`]. + pub fn with_requested_model(mut self, requested_model: Option) -> Self { + self.requested_model = requested_model.filter(|model| !model.is_empty()); + self + } + pub fn validate(&self) -> Result<(), ProductAdapterError> { - validate_payload_string("user message text", &self.text, USER_MESSAGE_TEXT_MAX_BYTES) + validate_payload_string("user message text", &self.text, USER_MESSAGE_TEXT_MAX_BYTES)?; + if let Some(model) = &self.requested_model { + validate_payload_string("requested model", model, REQUESTED_MODEL_MAX_BYTES)?; + } + Ok(()) } } @@ -126,6 +146,8 @@ struct UserMessagePayloadWire { text: String, attachments: Vec, trigger: ProductTriggerReason, + #[serde(default)] + requested_model: Option, } impl<'de> Deserialize<'de> for UserMessagePayload { @@ -134,7 +156,14 @@ impl<'de> Deserialize<'de> for UserMessagePayload { D: Deserializer<'de>, { let wire = UserMessagePayloadWire::deserialize(deserializer)?; - Self::new(wire.text, wire.attachments, wire.trigger).map_err(serde::de::Error::custom) + let payload = Self::new(wire.text, wire.attachments, wire.trigger) + .map(|payload| payload.with_requested_model(wire.requested_model)) + .map_err(serde::de::Error::custom)?; + // `new` validated the payload while `requested_model` was still `None`; + // re-validate the assembled value so the wire-supplied model hint is + // bounded like every other ingress field (bypass flagged in PR review). + payload.validate().map_err(serde::de::Error::custom)?; + Ok(payload) } } @@ -882,6 +911,73 @@ mod tests { use crate::auth::AuthRequirement; use crate::external::{ExternalActorRef, ExternalConversationRef, ExternalEventId}; + #[test] + fn user_message_payload_round_trips_and_filters_requested_model() { + let with_model = UserMessagePayload::new("hi", vec![], ProductTriggerReason::DirectChat) + .unwrap() + .with_requested_model(Some("gpt-4o".to_string())); + assert_eq!(with_model.requested_model.as_deref(), Some("gpt-4o")); + // Round-trips over the wire (custom Deserialize via the wire struct). + let decoded: UserMessagePayload = + serde_json::from_str(&serde_json::to_string(&with_model).unwrap()).unwrap(); + assert_eq!(decoded.requested_model.as_deref(), Some("gpt-4o")); + + // Omitted → None, and not serialized when absent. + let without = + UserMessagePayload::new("hi", vec![], ProductTriggerReason::DirectChat).unwrap(); + assert!(without.requested_model.is_none()); + assert!( + !serde_json::to_string(&without) + .unwrap() + .contains("requested_model") + ); + + // An empty requested model is filtered to None. + assert!( + UserMessagePayload::new("hi", vec![], ProductTriggerReason::DirectChat) + .unwrap() + .with_requested_model(Some(String::new())) + .requested_model + .is_none() + ); + } + + #[test] + fn user_message_payload_bounds_requested_model_on_every_path() { + let over_limit = "m".repeat(REQUESTED_MODEL_MAX_BYTES + 1); + + // Explicit validation after the builder rejects an over-long hint. + let built = UserMessagePayload::new("hi", vec![], ProductTriggerReason::DirectChat) + .unwrap() + .with_requested_model(Some(over_limit.clone())); + assert!(built.validate().is_err()); + + // Deserialization must not smuggle an unbounded hint past validation: + // the wire path attaches `requested_model` after `new`, so it re-validates. + let wire = serde_json::json!({ + "text": "hi", + "attachments": [], + "trigger": "direct_chat", + "requested_model": over_limit, + }) + .to_string(); + let decoded: Result = serde_json::from_str(&wire); + assert!( + decoded.is_err(), + "an over-long requested_model must be rejected during deserialization" + ); + + // A hint at the cap is accepted on both paths. + let at_cap = "m".repeat(REQUESTED_MODEL_MAX_BYTES); + assert!( + UserMessagePayload::new("hi", vec![], ProductTriggerReason::DirectChat) + .unwrap() + .with_requested_model(Some(at_cap)) + .validate() + .is_ok() + ); + } + fn sample_context() -> TrustedInboundContext { let evidence = ProtocolAuthEvidence::test_verified( AuthRequirement::SharedSecretHeader { diff --git a/crates/ironclaw_product_workflow/src/auth_continuation.rs b/crates/ironclaw_product_workflow/src/auth_continuation.rs index 05d3a94cb31..55adad71bb9 100644 --- a/crates/ironclaw_product_workflow/src/auth_continuation.rs +++ b/crates/ironclaw_product_workflow/src/auth_continuation.rs @@ -795,6 +795,7 @@ mod tests { let actor = TurnActor::new(UserId::new("alice").unwrap()); let submit = coordinator .submit_turn(SubmitTurnRequest { + requested_model: None, scope: scope.clone(), actor: actor.clone(), accepted_message_ref: AcceptedMessageRef::new("message-auth-real").unwrap(), diff --git a/crates/ironclaw_product_workflow/src/inbound_turn.rs b/crates/ironclaw_product_workflow/src/inbound_turn.rs index 23055748768..5be923743df 100644 --- a/crates/ironclaw_product_workflow/src/inbound_turn.rs +++ b/crates/ironclaw_product_workflow/src/inbound_turn.rs @@ -491,6 +491,7 @@ where received_at: envelope.received_at(), adapter_id: prepared.adapter_id, surface_type: prepared.surface_type, + requested_model: payload.requested_model.clone(), })) .submit_or_replay(&self.thread_service, &self.turn_coordinator) .await @@ -654,6 +655,10 @@ impl ProductInboundTurnHandoff { received_at, adapter_id, surface_type, + // The requested model is not persisted in the message store, so an + // idempotent resubmission of an accepted message falls back to the + // deployment's active model rather than recovering the original hint. + requested_model: None, }, ))) } @@ -703,6 +708,7 @@ struct AcceptedProductInboundTurn { received_at: DateTime, adapter_id: ProductAdapterId, surface_type: TurnSurfaceType, + requested_model: Option, } impl AcceptedProductInboundTurn { @@ -725,6 +731,7 @@ impl AcceptedProductInboundTurn { received_at, adapter_id, surface_type, + requested_model, } = self; let turn_scope = TurnScope::new_with_owner( binding.tenant_id.clone(), @@ -779,6 +786,7 @@ impl AcceptedProductInboundTurn { source_binding_ref, reply_target_binding_ref, requested_run_profile: None, + requested_model, idempotency_key, received_at, requested_run_id: None, diff --git a/crates/ironclaw_product_workflow/src/reborn_services.rs b/crates/ironclaw_product_workflow/src/reborn_services.rs index d249afe3f4d..2c06db5a730 100644 --- a/crates/ironclaw_product_workflow/src/reborn_services.rs +++ b/crates/ironclaw_product_workflow/src/reborn_services.rs @@ -3755,6 +3755,7 @@ impl RebornServicesApi for RebornServices { )?; let product_context = ironclaw_product_context::resolve_web_ui(scope.product_owner(&actor)); let submit = SubmitTurnRequest { + requested_model: None, scope: scope.clone(), actor, accepted_message_ref: accepted_message_ref.clone(), diff --git a/crates/ironclaw_product_workflow/tests/product_workflow_contract.rs b/crates/ironclaw_product_workflow/tests/product_workflow_contract.rs index 2d643fc6151..4b645060132 100644 --- a/crates/ironclaw_product_workflow/tests/product_workflow_contract.rs +++ b/crates/ironclaw_product_workflow/tests/product_workflow_contract.rs @@ -2995,6 +2995,7 @@ async fn before_inbound_policy_path_probes_replay_once() { async fn before_inbound_policy_rewrite_revalidates_payload_before_turn_path() { let (workflow, inbound, ledger, policy) = build_workflow_with_policy(); policy.rewrite_user_message(UserMessagePayload { + requested_model: None, text: "a".repeat(64 * 1024 + 1), attachments: vec![], trigger: ProductTriggerReason::DirectChat, diff --git a/crates/ironclaw_reborn_composition/src/factory.rs b/crates/ironclaw_reborn_composition/src/factory.rs index fba4f664bf4..37c4231866f 100644 --- a/crates/ironclaw_reborn_composition/src/factory.rs +++ b/crates/ironclaw_reborn_composition/src/factory.rs @@ -6709,6 +6709,7 @@ mod tests { Some(owner.clone()), ); let submit = ironclaw_turns::SubmitTurnRequest { + requested_model: None, scope, actor: ironclaw_turns::TurnActor::new(owner), accepted_message_ref: ironclaw_turns::AcceptedMessageRef::new("configured-message-ref") @@ -6832,6 +6833,7 @@ mod tests { Some(owner.clone()), ); let submit = ironclaw_turns::SubmitTurnRequest { + requested_model: None, scope, actor: ironclaw_turns::TurnActor::new(owner), accepted_message_ref: ironclaw_turns::AcceptedMessageRef::new("default-message-ref") diff --git a/crates/ironclaw_reborn_composition/src/factory/auth_tests.rs b/crates/ironclaw_reborn_composition/src/factory/auth_tests.rs index 41386d91944..6a2149925dd 100644 --- a/crates/ironclaw_reborn_composition/src/factory/auth_tests.rs +++ b/crates/ironclaw_reborn_composition/src/factory/auth_tests.rs @@ -193,6 +193,7 @@ async fn local_dev_oauth_turn_gate_callback_resumes_default_turn_coordinator() { let actor = TurnActor::new(UserId::new("alice").unwrap()); let submit = turn_coordinator .submit_turn(SubmitTurnRequest { + requested_model: None, scope: scope.clone(), actor: actor.clone(), accepted_message_ref: AcceptedMessageRef::new("message-auth-callback").unwrap(), @@ -956,6 +957,7 @@ async fn submit_and_block_provider_auth_run( ) -> TurnRunId { let submit = turn_coordinator .submit_turn(SubmitTurnRequest { + requested_model: None, scope: scope.clone(), actor, accepted_message_ref: AcceptedMessageRef::new(format!("message-fanout-{suffix}")) @@ -1075,6 +1077,7 @@ async fn submit_and_block_auth_run( ) -> ironclaw_turns::TurnRunId { let submit = turn_coordinator .submit_turn(SubmitTurnRequest { + requested_model: None, scope: scope.clone(), actor, accepted_message_ref: AcceptedMessageRef::new("message-auth-callback-2").unwrap(), diff --git a/crates/ironclaw_reborn_composition/src/runtime.rs b/crates/ironclaw_reborn_composition/src/runtime.rs index 1d045dcb3d4..2f732628735 100644 --- a/crates/ironclaw_reborn_composition/src/runtime.rs +++ b/crates/ironclaw_reborn_composition/src/runtime.rs @@ -2346,6 +2346,7 @@ impl RebornRuntime { let response = match self .turn_coordinator .submit_turn(SubmitTurnRequest { + requested_model: None, scope: scope.clone(), actor: TurnActor::new(self.actor_user_id.clone()), accepted_message_ref: accepted_message_ref.clone(), @@ -8177,6 +8178,7 @@ output_schema_ref = "schemas/write.output.json" let parent = runtime .turn_coordinator .submit_turn(SubmitTurnRequest { + requested_model: None, scope: parent_scope.clone(), actor: actor.clone(), accepted_message_ref: AcceptedMessageRef::new("msg:cancel-parent").unwrap(), @@ -10521,6 +10523,7 @@ output_schema_ref = "schemas/write.output.json" let submitted = runtime .turn_coordinator .submit_turn(SubmitTurnRequest { + requested_model: None, scope: scope.clone(), actor: actor.clone(), accepted_message_ref: AcceptedMessageRef::new("msg:audit").unwrap(), @@ -11102,6 +11105,7 @@ output_schema_ref = "schemas/write.output.json" let submitted_a = runtime .turn_coordinator .submit_turn(SubmitTurnRequest { + requested_model: None, scope: scope.clone(), actor: actor.clone(), accepted_message_ref: AcceptedMessageRef::new("msg:rejected-busy-a").unwrap(), diff --git a/crates/ironclaw_reborn_composition/src/runtime/tests/auth_interaction.rs b/crates/ironclaw_reborn_composition/src/runtime/tests/auth_interaction.rs index f579a902f5e..e3a9c5a299c 100644 --- a/crates/ironclaw_reborn_composition/src/runtime/tests/auth_interaction.rs +++ b/crates/ironclaw_reborn_composition/src/runtime/tests/auth_interaction.rs @@ -199,6 +199,7 @@ async fn submit_and_block_auth_run( .turn_state .submit_turn( SubmitTurnRequest { + requested_model: None, scope: scope.clone(), actor, accepted_message_ref: AcceptedMessageRef::new("message-runtime-auth-read-model") diff --git a/crates/ironclaw_reborn_openai_compat/src/chat_workflow.rs b/crates/ironclaw_reborn_openai_compat/src/chat_workflow.rs index 7b206cc1400..1c667327d41 100644 --- a/crates/ironclaw_reborn_openai_compat/src/chat_workflow.rs +++ b/crates/ironclaw_reborn_openai_compat/src/chat_workflow.rs @@ -795,7 +795,13 @@ fn chat_user_message_and_attachments( bytes: image.bytes, }) .collect(); - let payload = UserMessagePayload::new(text, vec![], ProductTriggerReason::DirectChat)?; + let payload = UserMessagePayload::new(text, vec![], ProductTriggerReason::DirectChat)? + .with_requested_model(crate::model_validation::requested_model_hint( + &request.model, + )); + // The builder attaches the model hint after `new`'s validation, so bound the + // assembled payload before it is submitted. + payload.validate()?; Ok((payload, attachments)) } diff --git a/crates/ironclaw_reborn_openai_compat/src/model_validation.rs b/crates/ironclaw_reborn_openai_compat/src/model_validation.rs index cc002bec3ca..ae6781fd67c 100644 --- a/crates/ironclaw_reborn_openai_compat/src/model_validation.rs +++ b/crates/ironclaw_reborn_openai_compat/src/model_validation.rs @@ -12,6 +12,27 @@ use crate::OpenAiCompatHttpError; /// Maximum accepted `model` string length, in bytes. pub(crate) const MAX_MODEL_NAME_BYTES: usize = 256; +/// The OpenAI-compatible alias every client may send to mean "use the server's +/// active/default model" rather than naming a concrete one. The models listing +/// advertises it, so it is not a routable model id. +const DEFAULT_MODEL_ALIAS: &str = "default"; + +/// Map a validated client `model` string to an optional caller-requested model +/// *hint* for turn routing. +/// +/// Returns `None` for the [`DEFAULT_MODEL_ALIAS`] sentinel (and defensively for +/// empty), so a client asking for the server default does not pin an advisory +/// route to the non-routable `"default"` id — which the model gateway rejects as +/// non-concrete and which would fail route resolution on routed hosts. A +/// concrete model name is forwarded as `Some`. +pub(crate) fn requested_model_hint(model: &str) -> Option { + let trimmed = model.trim(); + if trimmed.is_empty() || trimmed.eq_ignore_ascii_case(DEFAULT_MODEL_ALIAS) { + return None; + } + Some(trimmed.to_string()) +} + /// Validate the client-supplied `model` string before it is carried as a /// projection/policy hint. /// @@ -82,4 +103,22 @@ mod tests { let at_cap = "m".repeat(MAX_MODEL_NAME_BYTES); assert!(validate_model_name(&at_cap).is_ok()); } + + #[test] + fn requested_model_hint_drops_default_sentinel() { + assert_eq!(requested_model_hint("default"), None); + assert_eq!(requested_model_hint("DEFAULT"), None); + assert_eq!(requested_model_hint("Default"), None); + assert_eq!(requested_model_hint(""), None); + assert_eq!(requested_model_hint(" "), None); + } + + #[test] + fn requested_model_hint_forwards_concrete_model() { + assert_eq!(requested_model_hint("gpt-4o"), Some("gpt-4o".to_string())); + assert_eq!( + requested_model_hint("anthropic/claude-opus-4"), + Some("anthropic/claude-opus-4".to_string()) + ); + } } diff --git a/crates/ironclaw_reborn_openai_compat/src/responses_workflow.rs b/crates/ironclaw_reborn_openai_compat/src/responses_workflow.rs index 56a03fbf559..afb08b6f6b3 100644 --- a/crates/ironclaw_reborn_openai_compat/src/responses_workflow.rs +++ b/crates/ironclaw_reborn_openai_compat/src/responses_workflow.rs @@ -1382,11 +1382,18 @@ fn validate_temperature(temperature: Option) -> Result<(), OpenAiCompatHttp fn responses_user_message_payload( request: &OpenAiResponsesCreateRequest, ) -> Result { - Ok(UserMessagePayload::new( + let payload = UserMessagePayload::new( responses_input_to_product_text(request)?, vec![], ProductTriggerReason::DirectChat, - )?) + )? + .with_requested_model(crate::model_validation::requested_model_hint( + &request.model, + )); + // The builder attaches the model hint after `new`'s validation, so bound the + // assembled payload before it is submitted. + payload.validate()?; + Ok(payload) } fn responses_input_to_product_text( diff --git a/crates/ironclaw_runner/src/loop_driver_host.rs b/crates/ironclaw_runner/src/loop_driver_host.rs index f1110acac1b..e17cdf965b2 100644 --- a/crates/ironclaw_runner/src/loop_driver_host.rs +++ b/crates/ironclaw_runner/src/loop_driver_host.rs @@ -1968,6 +1968,18 @@ where .validate() .map_err(|reason| RebornLoopDriverHostError::InvalidRequest { reason })?; let Some(resolver) = &self.model_route_resolver else { + // No route resolver is wired (the default product runtime). An + // *advisory* snapshot is a caller-requested model hint, not an + // operator-approved route: pass it through unvalidated so the + // non-routed gateway can honor the model id when its provider + // supports per-request overrides and otherwise fall back to the + // active model. A non-advisory (operator) route persisted on a + // resolver-less host is a misconfiguration we cannot validate, + // so fail closed. Routed hosts (resolver present) validate every + // route below. + if snapshot.is_advisory() { + return Ok(run_context); + } return Err(RebornLoopDriverHostError::InvalidRequest { reason: "model route resolver is required for this host".to_string(), }); diff --git a/crates/ironclaw_runner/src/model_gateway.rs b/crates/ironclaw_runner/src/model_gateway.rs index 3dae237e355..8f7bf2d6d12 100644 --- a/crates/ironclaw_runner/src/model_gateway.rs +++ b/crates/ironclaw_runner/src/model_gateway.rs @@ -388,7 +388,14 @@ where "model profile is not permitted", ) })?; - let model_override = request_model_override(route, self.provider.as_ref())?; + let model_override = request_model_override( + route, + self.provider.as_ref(), + request + .resolved_model_route + .as_ref() + .map(|snapshot| snapshot.model_id.as_str()), + )?; let model_profile_id = request.model_profile_id.clone(); let run_id = request.run_id; let turn_id = request.turn_id; @@ -423,7 +430,14 @@ where "model profile is not permitted", ) })?; - let model_override = request_model_override(route, self.provider.as_ref())?; + let model_override = request_model_override( + route, + self.provider.as_ref(), + request + .resolved_model_route + .as_ref() + .map(|snapshot| snapshot.model_id.as_str()), + )?; let model_profile_id = request.model_profile_id.clone(); let run_id = request.run_id; let turn_id = request.turn_id; @@ -458,7 +472,14 @@ where "model profile is not permitted", ) })?; - let model_override = request_model_override(route, self.provider.as_ref())?; + let model_override = request_model_override( + route, + self.provider.as_ref(), + request + .resolved_model_route + .as_ref() + .map(|snapshot| snapshot.model_id.as_str()), + )?; let model_profile_id = request.model_profile_id.clone(); let run_id = request.run_id; let turn_id = request.turn_id; @@ -498,7 +519,14 @@ where "model profile is not permitted", ) })?; - let model_override = request_model_override(route, self.provider.as_ref())?; + let model_override = request_model_override( + route, + self.provider.as_ref(), + request + .resolved_model_route + .as_ref() + .map(|snapshot| snapshot.model_id.as_str()), + )?; let model_profile_id = request.model_profile_id.clone(); let run_id = request.run_id; let turn_id = request.turn_id; @@ -976,14 +1004,22 @@ fn host_error_to_model_gateway_error(error: AgentLoopHostError) -> LoopModelGate fn request_model_override

( route: &LlmModelProfileRoute, provider: &P, + requested_model: Option<&str>, ) -> Result where P: LlmProvider + ?Sized, { - let model_override = route - .model_override - .as_deref() + // A per-run caller-requested model (an advisory route hint set at submit) + // takes precedence over the profile default. Providers that honor + // per-request overrides (e.g. NEAR AI) serve the requested model; providers + // that bake the model at construction ignore it and fall back to their + // active model — the "route if the provider can serve it, else fall back" + // behavior, decided at the provider boundary rather than a route allowlist. + let model_override = requested_model + .map(str::trim) + .filter(|model| !model.is_empty()) .map(str::to_string) + .or_else(|| route.model_override.as_deref().map(str::to_string)) .unwrap_or_else(|| provider.active_model_name()); let trimmed = model_override.trim(); if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("default") { diff --git a/crates/ironclaw_runner/src/subagent/await_edge/boot_recovery.rs b/crates/ironclaw_runner/src/subagent/await_edge/boot_recovery.rs index 438193af8c4..16f84c886de 100644 --- a/crates/ironclaw_runner/src/subagent/await_edge/boot_recovery.rs +++ b/crates/ironclaw_runner/src/subagent/await_edge/boot_recovery.rs @@ -731,6 +731,7 @@ mod tests { .. } = coordinator .submit_turn(SubmitTurnRequest { + requested_model: None, scope: parent_scope.clone(), actor: actor.clone(), accepted_message_ref: ironclaw_turns::AcceptedMessageRef::new( diff --git a/crates/ironclaw_runner/src/subagent/await_edge/resolver.rs b/crates/ironclaw_runner/src/subagent/await_edge/resolver.rs index c0ddc100b90..77175127bab 100644 --- a/crates/ironclaw_runner/src/subagent/await_edge/resolver.rs +++ b/crates/ironclaw_runner/src/subagent/await_edge/resolver.rs @@ -1370,6 +1370,7 @@ mod tests { ); let root_run_id = match coordinator .submit_turn(SubmitTurnRequest { + requested_model: None, scope: root_scope.clone(), actor: actor.clone(), accepted_message_ref: ironclaw_turns::AcceptedMessageRef::new("msg:tr-root") diff --git a/crates/ironclaw_runner/tests/concurrent_workers.rs b/crates/ironclaw_runner/tests/concurrent_workers.rs index 3457e84df1f..3eaa3c8531c 100644 --- a/crates/ironclaw_runner/tests/concurrent_workers.rs +++ b/crates/ironclaw_runner/tests/concurrent_workers.rs @@ -194,6 +194,7 @@ async fn submit_run_on_thread( let submit = turn_store .submit_turn( SubmitTurnRequest { + requested_model: None, scope: turn_scope, actor: TurnActor::new(user_id.clone()), accepted_message_ref: AcceptedMessageRef::new(format!( @@ -311,6 +312,7 @@ async fn submit_owned_run_on_thread( let submit = turn_store .submit_turn( SubmitTurnRequest { + requested_model: None, scope: turn_scope, actor: TurnActor::new(user_id.clone()), accepted_message_ref: AcceptedMessageRef::new(format!( diff --git a/crates/ironclaw_runner/tests/llm_gateway.rs b/crates/ironclaw_runner/tests/llm_gateway.rs index 879f4897538..93bd60c2778 100644 --- a/crates/ironclaw_runner/tests/llm_gateway.rs +++ b/crates/ironclaw_runner/tests/llm_gateway.rs @@ -100,6 +100,56 @@ async fn gateway_calls_llm_provider_for_allowed_model_profile() { assert_eq!(requests[0].messages[1].content, "hello model"); } +#[tokio::test] +async fn gateway_honors_caller_requested_model_route_over_profile_default() { + let provider = Arc::new(RecordingLlmProvider::reply("assistant response")); + // Profile default resolves to "profile-default-model"; the caller's per-run + // requested-model route must take precedence. + let policy = LlmModelProfilePolicy::new().allow_model_profile( + interactive_model(), + Some("profile-default-model".to_string()), + ); + let gateway = LlmProviderModelGateway::with_provider_identity( + STATIC_PROVIDER_ID, + provider.clone(), + policy, + ); + + let request = model_request_with_route(interactive_model(), "requested", "caller-picked-model"); + gateway.stream_model(request).await.unwrap(); + + let requests = provider.requests.lock().unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!( + requests[0].model.as_deref(), + Some("caller-picked-model"), + "the per-run requested model must override the profile default" + ); +} + +#[tokio::test] +async fn gateway_falls_back_to_profile_default_when_no_requested_route() { + let provider = Arc::new(RecordingLlmProvider::reply("assistant response")); + let policy = LlmModelProfilePolicy::new().allow_model_profile( + interactive_model(), + Some("profile-default-model".to_string()), + ); + let gateway = LlmProviderModelGateway::with_provider_identity( + STATIC_PROVIDER_ID, + provider.clone(), + policy, + ); + + // No resolved_model_route on the request → the profile default is used. + gateway + .stream_model(model_request(interactive_model())) + .await + .unwrap(); + + let requests = provider.requests.lock().unwrap(); + assert_eq!(requests[0].model.as_deref(), Some("profile-default-model")); +} + #[tokio::test] async fn gateway_stream_model_with_progress_uses_provider_streaming_and_sanitizes_updates() { let provider = Arc::new(StreamingRecordingLlmProvider::new( diff --git a/crates/ironclaw_runner/tests/loop_driver_host.rs b/crates/ironclaw_runner/tests/loop_driver_host.rs index d6d15cbb217..4d8da234105 100644 --- a/crates/ironclaw_runner/tests/loop_driver_host.rs +++ b/crates/ironclaw_runner/tests/loop_driver_host.rs @@ -1985,6 +1985,7 @@ async fn turn_runner_worker_completes_after_libsql_turn_and_thread_services_reop let submit = turn_store .submit_turn( SubmitTurnRequest { + requested_model: None, scope: turn_scope.clone(), actor: TurnActor::new(user_id), accepted_message_ref: AcceptedMessageRef::new("accepted-libsql-restart") @@ -3589,6 +3590,7 @@ async fn default_planned_runtime_composes_no_profile_coordinator_and_profiled_ho let SubmitTurnResponse::Accepted { run_id, status, .. } = composition .coordinator .submit_turn(SubmitTurnRequest { + requested_model: None, scope: fixture.context.scope.clone(), actor: TurnActor::new(UserId::new("user-text-host").unwrap()), accepted_message_ref: AcceptedMessageRef::new("accepted-runtime-planned").unwrap(), @@ -3761,6 +3763,7 @@ async fn pre_minted_scheduler_wake_wiring_drives_scheduler_on_coordinator_submit let SubmitTurnResponse::Accepted { run_id, .. } = composition .coordinator .submit_turn(SubmitTurnRequest { + requested_model: None, scope: fixture.context.scope.clone(), actor: TurnActor::new(UserId::new("user-preminted-wake").unwrap()), accepted_message_ref: AcceptedMessageRef::new("accepted-preminted").unwrap(), @@ -4579,6 +4582,39 @@ async fn text_only_host_factory_rejects_persisted_model_route_snapshot_without_r ); } +#[tokio::test] +async fn text_only_host_factory_passes_advisory_model_route_snapshot_without_resolver() { + // A caller-requested model reaches the default (resolver-less) product + // runtime as an *advisory* snapshot. Unlike an operator route, it must pass + // through unvalidated so the non-routed gateway can honor the requested + // model id, rather than failing closed the way an operator route does. + let fixture = HostFixture::new("thread-host-advisory-no-resolver", "hello routed host").await; + let advisory_snapshot = + LoopModelRouteSnapshot::advisory("gpt-4o").expect("valid advisory model"); + let context = fixture + .context + .clone() + .with_resolved_model_route(advisory_snapshot.clone()); + let mut claimed = fixture.claimed.clone(); + claimed.state.resolved_model_route = Some(advisory_snapshot.clone()); + + let host = fixture + .factory() + .build_text_only_host(RebornLoopDriverHostRequest { + claimed_run: claimed, + loop_run_context: context, + }) + .await + .expect("advisory snapshot passes through a resolver-less host"); + + let host_dyn: &(dyn AgentLoopDriverHost + Send + Sync) = &host; + assert_eq!( + host_dyn.run_context().resolved_model_route, + Some(advisory_snapshot), + "the advisory model id must survive on the run context for the gateway to honor it" + ); +} + #[tokio::test] async fn text_only_host_factory_rejects_persisted_model_route_snapshot_denied_by_policy() { let fixture = HostFixture::new("thread-host-model-route-denied", "hello routed host").await; @@ -8699,6 +8735,7 @@ async fn queue_fixture_turn( let submit = turn_store .submit_turn( SubmitTurnRequest { + requested_model: None, scope: fixture.context.scope.clone(), actor: TurnActor::new(UserId::new("user-text-host").unwrap()), accepted_message_ref: AcceptedMessageRef::new(format!( diff --git a/crates/ironclaw_runner/tests/turn_scheduler_contract.rs b/crates/ironclaw_runner/tests/turn_scheduler_contract.rs index 73acd3d2c24..f6707359343 100644 --- a/crates/ironclaw_runner/tests/turn_scheduler_contract.rs +++ b/crates/ironclaw_runner/tests/turn_scheduler_contract.rs @@ -2297,6 +2297,7 @@ where fn submit_turn_request(thread: &str, idempotency_key: &str) -> SubmitTurnRequest { SubmitTurnRequest { + requested_model: None, scope: scope(thread), actor: TurnActor::new(UserId::new("user1").unwrap()), accepted_message_ref: AcceptedMessageRef::new(format!("message-{thread}")).unwrap(), diff --git a/crates/ironclaw_turns/src/memory/mod.rs b/crates/ironclaw_turns/src/memory/mod.rs index dbd86a6ac2a..38dcbc976d1 100644 --- a/crates/ironclaw_turns/src/memory/mod.rs +++ b/crates/ironclaw_turns/src/memory/mod.rs @@ -1184,7 +1184,10 @@ impl TurnStateStore for InMemoryTurnStateStore { run_id, status: RunStatusCell::new(TurnStatus::Queued), profile: profile.clone(), - resolved_model_route: None, + resolved_model_route: request + .requested_model + .as_deref() + .and_then(crate::run_profile::LoopModelRouteSnapshot::advisory), model_usage: None, accepted_message_ref: request.accepted_message_ref.clone(), source_binding_ref: request.source_binding_ref.clone(), @@ -1386,6 +1389,7 @@ impl TurnSpawnTreeStateStore for InMemoryTurnStateStore { return response; } SubmitTurnRequest { + requested_model: None, scope: request.child_scope.clone(), actor: request.actor.clone(), accepted_message_ref: request.accepted_message_ref.clone(), @@ -4114,6 +4118,7 @@ mod tests { let response = store .submit_turn( SubmitTurnRequest { + requested_model: None, scope: scope.clone(), actor: TurnActor::new(UserId::new(format!("user-{index}")).unwrap()), accepted_message_ref: AcceptedMessageRef::new(format!("accepted-{index}")) @@ -4192,6 +4197,7 @@ mod tests { let response = store .submit_turn( SubmitTurnRequest { + requested_model: None, scope: scope.clone(), actor: TurnActor::new(UserId::new("user-lease-overlay").unwrap()), accepted_message_ref: AcceptedMessageRef::new("accepted-lease-overlay") diff --git a/crates/ironclaw_turns/src/request.rs b/crates/ironclaw_turns/src/request.rs index b4a7a4e7577..11142a08525 100644 --- a/crates/ironclaw_turns/src/request.rs +++ b/crates/ironclaw_turns/src/request.rs @@ -68,6 +68,11 @@ pub struct SubmitTurnRequest { pub source_binding_ref: SourceBindingRef, pub reply_target_binding_ref: ReplyTargetBindingRef, pub requested_run_profile: Option, + /// Caller-requested model for this turn. A hint the coordinator resolves to a + /// concrete per-run model route when the operator has it configured; when it + /// can't be resolved the run falls back to the deployment's active model. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub requested_model: Option, pub idempotency_key: IdempotencyKey, pub received_at: TurnTimestamp, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/crates/ironclaw_turns/src/run_profile/host.rs b/crates/ironclaw_turns/src/run_profile/host.rs index ce731f90797..72bb4547c97 100644 --- a/crates/ironclaw_turns/src/run_profile/host.rs +++ b/crates/ironclaw_turns/src/run_profile/host.rs @@ -518,6 +518,10 @@ fn origin_input_cursor_token() -> LoopInputCursorToken { LoopInputCursorToken("input-cursor:origin".to_string()) } +/// Placeholder component value marking a [`LoopModelRouteSnapshot`] as a +/// caller-requested advisory hint rather than an operator-resolved route. +const ADVISORY_MODEL_ROUTE_COMPONENT: &str = "requested"; + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct LoopModelRouteSnapshot { pub provider_id: String, @@ -552,6 +556,40 @@ impl LoopModelRouteSnapshot { Ok(snapshot) } + /// Build an *advisory* route from a caller-requested model string. The + /// provider/config/auth components are placeholders (`"requested"`) — only + /// `model_id` carries meaning. Advisory routes exist so a caller (e.g. an + /// OpenAI-compatible client) can request a model without an operator-approved + /// route binding: the non-routed gateway honors the model id when its + /// provider supports per-request overrides and otherwise falls back to the + /// active model, while routed hosts still validate the route and fail closed. + /// Returns `None` when the model is empty or not a valid route component, so + /// the run falls back to the deployment's active model. + pub fn advisory(requested_model: &str) -> Option { + let model = requested_model.trim(); + if model.is_empty() { + return None; + } + Self::try_new( + ADVISORY_MODEL_ROUTE_COMPONENT, + model, + ADVISORY_MODEL_ROUTE_COMPONENT, + ADVISORY_MODEL_ROUTE_COMPONENT, + ) + .ok() + } + + /// Whether this route is a caller-requested advisory hint (see + /// [`LoopModelRouteSnapshot::advisory`]) rather than an operator-resolved + /// route. A non-routed host passes an advisory snapshot through unvalidated + /// but fails closed on an operator route it cannot validate without a + /// resolver. + pub fn is_advisory(&self) -> bool { + self.provider_id == ADVISORY_MODEL_ROUTE_COMPONENT + && self.config_version == ADVISORY_MODEL_ROUTE_COMPONENT + && self.auth_version == ADVISORY_MODEL_ROUTE_COMPONENT + } + pub fn validate(&self) -> Result<(), String> { validate_model_route_component_value("provider_id", &self.provider_id, 128, |character| { character.is_ascii_alphanumeric() || matches!(character, '_' | '-' | '.') @@ -2591,6 +2629,33 @@ fn unsupported_host_method(method: &'static str) -> AgentLoopHostError { mod tests { use super::*; + #[test] + fn advisory_model_route_carries_model_and_marks_itself_advisory() { + let route = LoopModelRouteSnapshot::advisory("gpt-4o").expect("valid model"); + assert_eq!(route.model_id, "gpt-4o"); + assert!(route.is_advisory()); + assert!(route.validate().is_ok()); + } + + #[test] + fn operator_resolved_route_is_not_advisory() { + let route = LoopModelRouteSnapshot::new("openai", "gpt-4o", "config:v1", "auth:v1"); + assert!(!route.is_advisory()); + } + + #[test] + fn advisory_model_route_trims_and_rejects_empty_or_invalid_models() { + assert_eq!(LoopModelRouteSnapshot::advisory(" "), None); + assert_eq!(LoopModelRouteSnapshot::advisory(""), None); + // A model id with a space is not a valid route component → falls back. + assert_eq!(LoopModelRouteSnapshot::advisory("gpt 4o"), None); + // Surrounding whitespace is trimmed before validation. + assert_eq!( + LoopModelRouteSnapshot::advisory(" claude-opus-4-6 ").map(|route| route.model_id), + Some("claude-opus-4-6".to_string()) + ); + } + struct DefinitionPort { definitions: Vec, } diff --git a/crates/ironclaw_turns/tests/active_run_ref_state_contract.rs b/crates/ironclaw_turns/tests/active_run_ref_state_contract.rs index 941b8092b59..fd102035be8 100644 --- a/crates/ironclaw_turns/tests/active_run_ref_state_contract.rs +++ b/crates/ironclaw_turns/tests/active_run_ref_state_contract.rs @@ -71,6 +71,7 @@ fn submit_request_for( idempotency_key: &str, ) -> ironclaw_turns::SubmitTurnRequest { ironclaw_turns::SubmitTurnRequest { + requested_model: None, scope, actor: turn_actor(), accepted_message_ref: AcceptedMessageRef::new(format!("message-{idempotency_key}")) diff --git a/crates/ironclaw_turns/tests/agent_loop_host_contract.rs b/crates/ironclaw_turns/tests/agent_loop_host_contract.rs index 56f50c2c676..75924caa589 100644 --- a/crates/ironclaw_turns/tests/agent_loop_host_contract.rs +++ b/crates/ironclaw_turns/tests/agent_loop_host_contract.rs @@ -3306,6 +3306,7 @@ async fn claimed_run_context() -> LoopRunContext { let coordinator = DefaultTurnCoordinator::new(store.clone()); let response = coordinator .submit_turn(SubmitTurnRequest { + requested_model: None, scope: scope.clone(), actor: TurnActor::new(UserId::new("user-loop").unwrap()), accepted_message_ref: AcceptedMessageRef::new("message-loop-host").unwrap(), diff --git a/crates/ironclaw_turns/tests/filesystem_turn_state_contract.rs b/crates/ironclaw_turns/tests/filesystem_turn_state_contract.rs index 26d76f98b40..ae80536e4e1 100644 --- a/crates/ironclaw_turns/tests/filesystem_turn_state_contract.rs +++ b/crates/ironclaw_turns/tests/filesystem_turn_state_contract.rs @@ -1181,6 +1181,7 @@ fn turn_actor() -> TurnActor { fn submit_request_for(scope: TurnScope, idempotency_key: &str) -> SubmitTurnRequest { SubmitTurnRequest { + requested_model: None, scope, actor: turn_actor(), accepted_message_ref: AcceptedMessageRef::new(format!("message-{idempotency_key}")) diff --git a/crates/ironclaw_turns/tests/per_inbound_type_concurrency_cap.rs b/crates/ironclaw_turns/tests/per_inbound_type_concurrency_cap.rs index 5dcc45c9054..32421684481 100644 --- a/crates/ironclaw_turns/tests/per_inbound_type_concurrency_cap.rs +++ b/crates/ironclaw_turns/tests/per_inbound_type_concurrency_cap.rs @@ -96,6 +96,7 @@ fn submit_request( ) -> SubmitTurnRequest { let owner = scope.explicit_owner_user_id().unwrap().clone(); SubmitTurnRequest { + requested_model: None, actor: actor_for(&owner), accepted_message_ref: AcceptedMessageRef::new(format!("message-{key}")).unwrap(), source_binding_ref: SourceBindingRef::new("source-web").unwrap(), diff --git a/crates/ironclaw_turns/tests/per_user_concurrency_cap.rs b/crates/ironclaw_turns/tests/per_user_concurrency_cap.rs index 013387dae2c..3fe2e9568bc 100644 --- a/crates/ironclaw_turns/tests/per_user_concurrency_cap.rs +++ b/crates/ironclaw_turns/tests/per_user_concurrency_cap.rs @@ -57,6 +57,7 @@ fn actor_for(user: &UserId) -> TurnActor { fn submit_request_for(scope: TurnScope, key: &str) -> SubmitTurnRequest { let actor = actor_for(scope.explicit_owner_user_id().unwrap()); SubmitTurnRequest { + requested_model: None, actor, accepted_message_ref: AcceptedMessageRef::new(format!("message-{key}")).unwrap(), source_binding_ref: SourceBindingRef::new("source-web").unwrap(), @@ -583,6 +584,7 @@ async fn ownerless_runs_are_not_counted_against_cap() { ); let make_req = |scope: TurnScope, key: &'static str| SubmitTurnRequest { + requested_model: None, scope, actor: actor.clone(), accepted_message_ref: AcceptedMessageRef::new(format!("msg-{key}")).unwrap(), @@ -683,6 +685,7 @@ async fn actor_fallback_runs_are_capped_under_actor_user_id() { ); let make_req = |scope: TurnScope, actor: TurnActor, key: &'static str| SubmitTurnRequest { + requested_model: None, scope, actor, accepted_message_ref: AcceptedMessageRef::new(format!("msg-{key}")).unwrap(), diff --git a/crates/ironclaw_turns/tests/retry_failed_turn_store_contract.rs b/crates/ironclaw_turns/tests/retry_failed_turn_store_contract.rs index 8135b78f140..fec839272ae 100644 --- a/crates/ironclaw_turns/tests/retry_failed_turn_store_contract.rs +++ b/crates/ironclaw_turns/tests/retry_failed_turn_store_contract.rs @@ -85,6 +85,7 @@ fn actor() -> TurnActor { fn submit_request(thread: &str, idempotency_key: &str) -> SubmitTurnRequest { SubmitTurnRequest { + requested_model: None, scope: scope(thread), actor: actor(), accepted_message_ref: AcceptedMessageRef::new(format!("message-{thread}")).unwrap(), diff --git a/crates/ironclaw_turns/tests/turn_coordinator_contract.rs b/crates/ironclaw_turns/tests/turn_coordinator_contract.rs index aedc993feff..2900c266e9f 100644 --- a/crates/ironclaw_turns/tests/turn_coordinator_contract.rs +++ b/crates/ironclaw_turns/tests/turn_coordinator_contract.rs @@ -470,6 +470,51 @@ async fn prepare_turn_mints_ids_without_side_effects_and_submit_binds_requested_ assert!(matches!(err, TurnError::Conflict { .. })); } +#[tokio::test] +async fn submit_turn_records_advisory_model_route_from_requested_model() { + let (coordinator, _store) = coordinator(); + let mut request = submit_request("thread-model-select", "idem-model-select"); + request.requested_model = Some("gpt-4o".to_string()); + + let run_id = accepted_run_id(&coordinator.submit_turn(request).await.unwrap()); + + let state = coordinator + .get_run_state(GetRunStateRequest { + scope: scope("thread-model-select"), + run_id, + }) + .await + .unwrap(); + let route = state + .resolved_model_route + .expect("advisory model route recorded from requested_model"); + assert_eq!(route.model_id, "gpt-4o"); + assert!( + route.is_advisory(), + "a caller-requested model is an advisory route, not an operator-resolved one" + ); +} + +#[tokio::test] +async fn submit_turn_without_requested_model_records_no_route() { + let (coordinator, _store) = coordinator(); + let run_id = accepted_run_id( + &coordinator + .submit_turn(submit_request("thread-no-model", "idem-no-model")) + .await + .unwrap(), + ); + + let state = coordinator + .get_run_state(GetRunStateRequest { + scope: scope("thread-no-model"), + run_id, + }) + .await + .unwrap(); + assert!(state.resolved_model_route.is_none()); +} + #[tokio::test] async fn children_of_get_run_record_and_tree_reservation_are_scope_checked() { let (coordinator, store) = coordinator(); @@ -7083,6 +7128,7 @@ async fn complete_queued_run(store: &InMemoryTurnStateStore, run_id: TurnRunId, fn submit_request(thread: &str, idempotency_key: &str) -> SubmitTurnRequest { SubmitTurnRequest { + requested_model: None, scope: scope(thread), actor: actor(), accepted_message_ref: AcceptedMessageRef::new(format!("message-{thread}")).unwrap(), diff --git a/tests/integration/subagent_await_edge.rs b/tests/integration/subagent_await_edge.rs index eb91858fa9b..a37db9d1528 100644 --- a/tests/integration/subagent_await_edge.rs +++ b/tests/integration/subagent_await_edge.rs @@ -410,6 +410,7 @@ async fn rollback_deleted_edge_is_reconstructed_so_the_parent_still_gets_the_res // state a real parent is in while its blocking-mode child runs. let submitted = coordinator .submit_turn(ironclaw_turns::SubmitTurnRequest { + requested_model: None, scope: parent_scope.clone(), actor: actor.clone(), accepted_message_ref: ironclaw_turns::AcceptedMessageRef::new("msg:parent-rollback") @@ -705,6 +706,7 @@ async fn mixed_status_batch_group_reports_each_members_own_status_and_reason() { // 1. Submit and block the parent on a shared dependent-run gate. let submitted = coordinator .submit_turn(ironclaw_turns::SubmitTurnRequest { + requested_model: None, scope: parent_scope.clone(), actor: actor.clone(), accepted_message_ref: ironclaw_turns::AcceptedMessageRef::new("msg:parent-mixed-batch") diff --git a/tools/ironclaw_stress/src/user_turn.rs b/tools/ironclaw_stress/src/user_turn.rs index 7ea17c4717d..932581424dc 100644 --- a/tools/ironclaw_stress/src/user_turn.rs +++ b/tools/ironclaw_stress/src/user_turn.rs @@ -767,6 +767,7 @@ where reply_target_binding_ref: ReplyTargetBindingRef::new(reply_target) .map_err(|error| OperationFailure::invalid_request("prefill_submit", error))?, requested_run_profile: None, + requested_model: None, idempotency_key: IdempotencyKey::new(format!( "ironclaw-stress-prefill:{operation_ref}" )) @@ -946,6 +947,7 @@ where reply_target_binding_ref: ReplyTargetBindingRef::new(reply_target) .map_err(|error| OperationFailure::invalid_request("submit_turn", error))?, requested_run_profile: None, + requested_model: None, idempotency_key: IdempotencyKey::new(format!( "ironclaw-stress:{operation_ref}" )) @@ -1062,6 +1064,7 @@ where reply_target_binding_ref: ReplyTargetBindingRef::new(reply_target) .map_err(|error| OperationFailure::invalid_request("submit_turn", error))?, requested_run_profile: None, + requested_model: None, idempotency_key: IdempotencyKey::new(format!( "ironclaw-stress:{operation_ref}" ))