Skip to content

Commit 1b4752f

Browse files
committed
fix: preserve provider model bindings
1 parent 956382d commit 1b4752f

28 files changed

Lines changed: 549 additions & 58 deletions

crates/core/src/conversation/records.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ pub struct SessionRecord {
2929
pub model_provider: String,
3030
/// The latest resolved model slug for the session.
3131
pub model: Option<String>,
32+
/// The latest selected provider model binding id for the session.
33+
#[serde(default, skip_serializing_if = "Option::is_none")]
34+
pub model_binding_id: Option<String>,
3235
/// The logical thinking selection used as the default for the next turn.
3336
pub thinking: Option<String>,
3437
/// The working directory associated with the session.
@@ -87,6 +90,9 @@ pub struct TurnRecord {
8790
pub kind: TurnKind,
8891
/// The logical model selection used for the turn.
8992
pub model: String,
93+
/// The selected provider model binding id used for the turn, when available.
94+
#[serde(default, skip_serializing_if = "Option::is_none")]
95+
pub model_binding_id: Option<String>,
9096
/// The logical thinking selection used for the turn.
9197
pub thinking: Option<String>,
9298
/// The concrete request model used to execute the turn.
@@ -413,6 +419,7 @@ mod tests {
413419
agent_path: None,
414420
model_provider: "test".into(),
415421
model: None,
422+
model_binding_id: None,
416423
thinking: None,
417424
cwd: ".".into(),
418425
cli_version: "0.1.0".into(),
@@ -868,6 +875,7 @@ mod tests {
868875
agent_path: None,
869876
model_provider: "test-provider".into(),
870877
model: Some("test-model".into()),
878+
model_binding_id: Some("test-binding".into()),
871879
thinking: None,
872880
cwd: "/tmp/test".into(),
873881
cli_version: "0.1.0".into(),
@@ -898,6 +906,7 @@ mod tests {
898906
status,
899907
kind: crate::TurnKind::Regular,
900908
model: "test-model".into(),
909+
model_binding_id: Some("test-binding".into()),
901910
thinking: None,
902911
request_model: "test-model".into(),
903912
request_thinking: None,

crates/core/src/session.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ pub struct TurnConfig {
7272
/// Provider wire model name from the selected binding's `model_name`.
7373
/// This is the string sent as `ModelRequest.model` for the base model.
7474
pub request_model: String,
75+
/// Provider model binding id selected for this turn, when the request was
76+
/// resolved through configured provider bindings.
77+
pub model_binding_id: Option<String>,
7578
/// Provider-scoped variant lookup used when thinking resolves to another
7679
/// catalog slug before the request is built.
7780
pub provider_request_models: ProviderRequestModelMap,
@@ -117,6 +120,7 @@ impl TurnConfig {
117120
Self {
118121
model,
119122
request_model,
123+
model_binding_id: None,
120124
provider_request_models: ProviderRequestModelMap::default(),
121125
provider_route: ProviderRoute::Default,
122126
web_search: ResolvedWebSearchConfig::Disabled,
@@ -189,6 +193,7 @@ impl TurnConfig {
189193
Self {
190194
model,
191195
request_model,
196+
model_binding_id: None,
192197
provider_request_models,
193198
provider_route,
194199
web_search,

crates/protocol/src/session.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ pub struct SessionMetadata {
4343
pub agent_role: Option<String>,
4444
pub ephemeral: bool,
4545
pub model: Option<String>,
46+
#[serde(default, skip_serializing_if = "Option::is_none")]
47+
pub model_binding_id: Option<String>,
4648
pub thinking: Option<String>,
4749
pub reasoning_effort: Option<ReasoningEffort>,
4850
pub total_input_tokens: usize,
@@ -67,6 +69,8 @@ pub struct SessionStartParams {
6769
pub ephemeral: bool,
6870
pub title: Option<String>,
6971
pub model: Option<String>,
72+
#[serde(default, skip_serializing_if = "Option::is_none")]
73+
pub model_binding_id: Option<String>,
7074
}
7175

7276
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -208,6 +212,8 @@ pub struct SessionTitleUpdateResult {
208212
pub struct SessionMetadataUpdateParams {
209213
pub session_id: SessionId,
210214
pub model: Option<String>,
215+
#[serde(default, skip_serializing_if = "Option::is_none")]
216+
pub model_binding_id: Option<String>,
211217
pub thinking: Option<String>,
212218
}
213219

@@ -366,6 +372,7 @@ mod tests {
366372
agent_role: None,
367373
ephemeral: false,
368374
model: Some("test-model".to_string()),
375+
model_binding_id: Some("test-binding".to_string()),
369376
thinking: Some("medium".to_string()),
370377
reasoning_effort: Some(crate::ReasoningEffort::Medium),
371378
total_input_tokens: 12,

crates/protocol/src/turn.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ pub struct TurnMetadata {
1313
pub status: TurnStatus,
1414
pub kind: TurnKind,
1515
pub model: String,
16+
#[serde(default, skip_serializing_if = "Option::is_none")]
17+
pub model_binding_id: Option<String>,
1618
pub thinking: Option<String>,
1719
pub reasoning_effort: Option<ReasoningEffort>,
1820
pub request_model: String,
@@ -86,6 +88,8 @@ pub struct TurnStartParams {
8688
pub session_id: SessionId,
8789
pub input: Vec<InputItem>,
8890
pub model: Option<String>,
91+
#[serde(default, skip_serializing_if = "Option::is_none")]
92+
pub model_binding_id: Option<String>,
8993
pub thinking: Option<String>,
9094
pub sandbox: Option<String>,
9195
pub approval_policy: Option<String>,
@@ -200,6 +204,7 @@ mod tests {
200204
status: TurnStatus::Completed,
201205
kind: TurnKind::Regular,
202206
model: "logical-model".to_string(),
207+
model_binding_id: Some("provider-binding".to_string()),
203208
thinking: Some("high".to_string()),
204209
reasoning_effort: Some(ReasoningEffort::High),
205210
request_model: "provider-model".to_string(),

crates/server/src/db.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ impl Database {
190190
agent_role: None,
191191
ephemeral: ephemeral != 0,
192192
model,
193+
model_binding_id: None,
193194
thinking,
194195
reasoning_effort: None,
195196
total_input_tokens: 0,
@@ -257,6 +258,7 @@ impl Database {
257258
agent_role: None,
258259
ephemeral: ephemeral != 0,
259260
model,
261+
model_binding_id: None,
260262
thinking,
261263
reasoning_effort: None,
262264
total_input_tokens: 0,
@@ -547,6 +549,7 @@ mod tests {
547549
agent_role: None,
548550
ephemeral: false,
549551
model: Some("claude-sonnet-4-20250514".into()),
552+
model_binding_id: None,
550553
thinking: None,
551554
reasoning_effort: None,
552555
total_input_tokens: 0,

crates/server/src/execution.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,8 @@ impl ServerRuntimeDependencies {
288288
let provider_request_models = ProviderRequestModelMap::new(
289289
provider_request_model_map_for_binding(&provider_config, &binding),
290290
);
291-
return TurnConfig::with_provider_route_and_web_tools(
291+
let binding_id = binding.binding_id.clone();
292+
let mut turn_config = TurnConfig::with_provider_route_and_web_tools(
292293
self.catalog_model_or_fallback(&binding.model_slug),
293294
binding.model_name,
294295
provider_request_models,
@@ -297,6 +298,8 @@ impl ServerRuntimeDependencies {
297298
web_fetch,
298299
thinking_selection,
299300
);
301+
turn_config.model_binding_id = Some(binding_id);
302+
return turn_config;
300303
}
301304

302305
let model = self.resolve_turn_model(requested_model);

crates/server/src/persistence.rs

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ impl RolloutStore {
9292
cwd: PathBuf,
9393
title: Option<String>,
9494
model: Option<String>,
95+
model_binding_id: Option<String>,
9596
thinking: Option<String>,
9697
model_provider: String,
9798
parent_session_id: Option<SessionId>,
@@ -112,6 +113,7 @@ impl RolloutStore {
112113
agent_path: None,
113114
model_provider,
114115
model,
116+
model_binding_id,
115117
thinking,
116118
cwd,
117119
cli_version: env!("CARGO_PKG_VERSION").into(),
@@ -391,6 +393,7 @@ impl ReplayState {
391393
status: line.turn.status.clone(),
392394
kind: line.turn.kind.clone(),
393395
model: line.turn.model.clone(),
396+
model_binding_id: line.turn.model_binding_id.clone(),
394397
thinking: line.turn.thinking.clone(),
395398
reasoning_effort: line
396399
.turn
@@ -527,13 +530,19 @@ impl ReplayState {
527530
.div_ceil(4);
528531
let pending_turn_queue = std::sync::Arc::clone(&core_session.pending_turn_queue);
529532
let btw_input_queue = std::sync::Arc::clone(&core_session.btw_input_queue);
530-
let summary_model = self
533+
let summary_model_selection = self
531534
.latest_turn_metadata
532535
.as_ref()
533-
.map(|turn| turn.model.clone())
536+
.and_then(|turn| turn.model_binding_id.clone())
537+
.or_else(|| {
538+
self.latest_turn_metadata
539+
.as_ref()
540+
.map(|turn| turn.model.clone())
541+
})
542+
.or_else(|| record.model_binding_id.clone())
534543
.or_else(|| record.model.clone())
535544
.unwrap_or_else(|| deps.default_model.clone());
536-
let turn_config = deps.resolve_turn_config(Some(&summary_model), None);
545+
let turn_config = deps.resolve_turn_config(Some(&summary_model_selection), None);
537546
let concrete_selection = |selection: Option<&str>| {
538547
selection
539548
.map(str::trim)
@@ -565,6 +574,7 @@ impl ReplayState {
565574
.resolve_thinking_selection(summary_thinking.as_deref())
566575
.effective_reasoning_effort;
567576
record.model = Some(turn_config.model.slug.clone());
577+
record.model_binding_id = turn_config.model_binding_id.clone();
568578
record.thinking = summary_thinking.clone();
569579

570580
let summary = SessionMetadata {
@@ -580,6 +590,7 @@ impl ReplayState {
580590
agent_role: record.agent_role.clone(),
581591
ephemeral: false,
582592
model: Some(turn_config.model.slug),
593+
model_binding_id: turn_config.model_binding_id.clone(),
583594
thinking: summary_thinking,
584595
reasoning_effort: summary_reasoning_effort,
585596
total_input_tokens: self.total_input_tokens,
@@ -1147,6 +1158,7 @@ pub(crate) fn build_turn_record(
11471158
status: turn.status.clone(),
11481159
kind: turn.kind.clone(),
11491160
model: turn.model.clone(),
1161+
model_binding_id: turn.model_binding_id.clone(),
11501162
thinking: turn.thinking.clone(),
11511163
request_model: turn.request_model.clone(),
11521164
request_thinking: turn.request_thinking.clone(),
@@ -1491,6 +1503,7 @@ mod tests {
14911503
agent_path: None,
14921504
model_provider: "test".into(),
14931505
model: Some("model-a".into()),
1506+
model_binding_id: None,
14941507
thinking: None,
14951508
cwd: PathBuf::from("/tmp/root"),
14961509
cli_version: "0.1.0".into(),
@@ -1523,6 +1536,7 @@ mod tests {
15231536
status: TurnStatus::Completed,
15241537
kind: devo_core::TurnKind::Regular,
15251538
model: "model-b".into(),
1539+
model_binding_id: None,
15261540
thinking: Some("enabled".into()),
15271541
request_model: "model-b".into(),
15281542
request_thinking: Some("enabled".into()),

crates/server/src/projection.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,7 @@ impl SessionProjector for DefaultProjection {
407407
agent_role: session.agent_role.clone(),
408408
ephemeral,
409409
model: session.model.clone(),
410+
model_binding_id: session.model_binding_id.clone(),
410411
thinking: session.thinking.clone(),
411412
reasoning_effort: session
412413
.latest_turn_context
@@ -438,6 +439,7 @@ impl TurnProjector for DefaultProjection {
438439
status: turn.status.clone(),
439440
kind: turn.kind.clone(),
440441
model: turn.model.clone(),
442+
model_binding_id: turn.model_binding_id.clone(),
441443
thinking: turn.thinking.clone(),
442444
reasoning_effort: turn
443445
.turn_context

crates/server/src/runtime.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,46 @@ impl TurnInputMode {
211211
}
212212
}
213213

214+
fn session_model_selection(session: &SessionMetadata) -> Option<&str> {
215+
session
216+
.model_binding_id
217+
.as_deref()
218+
.or(session.model.as_deref())
219+
}
220+
221+
fn requested_model_selection<'a>(
222+
model_binding_id: Option<&'a str>,
223+
model: Option<&'a str>,
224+
session: &'a SessionMetadata,
225+
) -> Option<&'a str> {
226+
model_binding_id
227+
.or(model)
228+
.or_else(|| session_model_selection(session))
229+
}
230+
231+
fn apply_turn_config_to_session_summary(summary: &mut SessionMetadata, turn_config: &TurnConfig) {
232+
summary.model = Some(turn_config.model.slug.clone());
233+
summary.model_binding_id = turn_config.model_binding_id.clone();
234+
summary.thinking = turn_config.thinking_selection.clone();
235+
}
236+
237+
fn string_field_from_pending_metadata(
238+
metadata: Option<&serde_json::Value>,
239+
key: &str,
240+
) -> Option<String> {
241+
metadata?
242+
.get(key)?
243+
.as_str()
244+
.map(str::trim)
245+
.filter(|value| !value.is_empty())
246+
.map(str::to_string)
247+
}
248+
249+
fn model_selection_from_pending_metadata(metadata: Option<&serde_json::Value>) -> Option<String> {
250+
string_field_from_pending_metadata(metadata, "model_binding_id")
251+
.or_else(|| string_field_from_pending_metadata(metadata, "model"))
252+
}
253+
214254
impl ServerRuntime {
215255
pub fn new(server_home: PathBuf, deps: ServerRuntimeDependencies) -> Arc<Self> {
216256
let rollout_store = RolloutStore::new(server_home.clone());

crates/server/src/runtime/agents.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ impl ServerRuntime {
7171
.unwrap_or_else(|| "root".to_string());
7272
let agent_path = AgentPath::new(parent_path).join(&nickname).0;
7373
let model = parent_summary.model.clone();
74+
let model_binding_id = parent_summary.model_binding_id.clone();
7475
let thinking = parent_summary.thinking.clone();
7576

7677
let mut record = self.rollout_store.create_session_record(
@@ -79,6 +80,7 @@ impl ServerRuntime {
7980
parent_summary.cwd.clone(),
8081
Some(nickname.clone()),
8182
model.clone(),
83+
model_binding_id.clone(),
8284
thinking.clone(),
8385
self.deps.provider.name().to_string(),
8486
Some(parent_session_id),
@@ -139,6 +141,7 @@ impl ServerRuntime {
139141
agent_role: Some(role.clone()),
140142
ephemeral: params.ephemeral,
141143
model: model.clone(),
144+
model_binding_id: model_binding_id.clone(),
142145
thinking,
143146
reasoning_effort: None,
144147
total_input_tokens: 0,
@@ -416,7 +419,7 @@ impl ServerRuntime {
416419
let (turn_config, resolved_request) = {
417420
let session = session_arc.lock().await;
418421
let turn_config = self.deps.resolve_turn_config(
419-
session.summary.model.as_deref(),
422+
session_model_selection(&session.summary),
420423
session.summary.thinking.clone(),
421424
);
422425
let resolved_request = turn_config
@@ -438,6 +441,7 @@ impl ServerRuntime {
438441
status: TurnStatus::Running,
439442
kind: devo_core::TurnKind::Regular,
440443
model: turn_config.model.slug.clone(),
444+
model_binding_id: turn_config.model_binding_id.clone(),
441445
thinking: turn_config.thinking_selection.clone(),
442446
reasoning_effort: resolved_request.effective_reasoning_effort,
443447
request_model,
@@ -448,8 +452,7 @@ impl ServerRuntime {
448452
};
449453
session.summary.status = SessionRuntimeStatus::ActiveTurn;
450454
session.summary.updated_at = now;
451-
session.summary.model = Some(turn_config.model.slug.clone());
452-
session.summary.thinking = turn_config.thinking_selection.clone();
455+
apply_turn_config_to_session_summary(&mut session.summary, &turn_config);
453456
session.active_turn = Some(turn.clone());
454457
turn
455458
};

0 commit comments

Comments
 (0)