Skip to content

Commit 88bc431

Browse files
committed
feat: annotate conversations with model_provider for filtering
1 parent a4be4d7 commit 88bc431

File tree

14 files changed

+432
-42
lines changed

14 files changed

+432
-42
lines changed

codex-rs/app-server-protocol/src/protocol.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,9 @@ pub struct ListConversationsParams {
313313
/// Opaque pagination cursor returned by a previous call.
314314
#[serde(skip_serializing_if = "Option::is_none")]
315315
pub cursor: Option<String>,
316+
/// Optional model provider filter (matches against session metadata).
317+
#[serde(skip_serializing_if = "Option::is_none")]
318+
pub model_provider: Option<Vec<String>>,
316319
}
317320

318321
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -324,6 +327,9 @@ pub struct ConversationSummary {
324327
/// RFC3339 timestamp string for the session start, if available.
325328
#[serde(skip_serializing_if = "Option::is_none")]
326329
pub timestamp: Option<String>,
330+
/// Optional model provider recorded for the session.
331+
#[serde(skip_serializing_if = "Option::is_none")]
332+
pub model_provider: Option<String>,
327333
}
328334

329335
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]

codex-rs/app-server/src/codex_message_processor.rs

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -818,19 +818,36 @@ impl CodexMessageProcessor {
818818
request_id: RequestId,
819819
params: ListConversationsParams,
820820
) {
821-
let page_size = params.page_size.unwrap_or(25);
821+
let ListConversationsParams {
822+
page_size,
823+
cursor,
824+
model_provider,
825+
} = params;
826+
let page_size = page_size.unwrap_or(25);
822827
// Decode the optional cursor string to a Cursor via serde (Cursor implements Deserialize from string)
823-
let cursor_obj: Option<RolloutCursor> = match params.cursor {
828+
let cursor_obj: Option<RolloutCursor> = match cursor {
824829
Some(s) => serde_json::from_str::<RolloutCursor>(&format!("\"{s}\"")).ok(),
825830
None => None,
826831
};
827832
let cursor_ref = cursor_obj.as_ref();
833+
let model_provider_filter = match model_provider {
834+
Some(providers) => {
835+
if providers.is_empty() {
836+
None
837+
} else {
838+
Some(providers)
839+
}
840+
}
841+
None => Some(vec![self.config.model_provider_id.clone()]),
842+
};
843+
let model_provider_slice = model_provider_filter.as_deref();
828844

829845
let page = match RolloutRecorder::list_conversations(
830846
&self.config.codex_home,
831847
page_size,
832848
cursor_ref,
833849
INTERACTIVE_SESSION_SOURCES,
850+
model_provider_slice,
834851
)
835852
.await
836853
{
@@ -1640,12 +1657,14 @@ fn extract_conversation_summary(
16401657
} else {
16411658
Some(session_meta.timestamp.clone())
16421659
};
1660+
let model_provider = session_meta.model_provider.clone();
16431661

16441662
Some(ConversationSummary {
16451663
conversation_id: session_meta.id,
16461664
timestamp,
16471665
path,
16481666
preview: preview.to_string(),
1667+
model_provider,
16491668
})
16501669
}
16511670

@@ -1669,7 +1688,8 @@ mod tests {
16691688
"cwd": "/",
16701689
"originator": "codex",
16711690
"cli_version": "0.0.0",
1672-
"instructions": null
1691+
"instructions": null,
1692+
"model_provider": "test-provider"
16731693
}),
16741694
json!({
16751695
"type": "message",
@@ -1698,6 +1718,7 @@ mod tests {
16981718
);
16991719
assert_eq!(summary.path, path);
17001720
assert_eq!(summary.preview, "Count to 5");
1721+
assert_eq!(summary.model_provider, Some("test-provider".to_string()));
17011722
Ok(())
17021723
}
17031724
}

codex-rs/app-server/tests/suite/list_resume.rs

Lines changed: 110 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,18 +30,21 @@ async fn test_list_and_resume_conversations() {
3030
"2025-01-02T12-00-00",
3131
"2025-01-02T12:00:00Z",
3232
"Hello A",
33+
Some("openai"),
3334
);
3435
create_fake_rollout(
3536
codex_home.path(),
3637
"2025-01-01T13-00-00",
3738
"2025-01-01T13:00:00Z",
3839
"Hello B",
40+
Some("openai"),
3941
);
4042
create_fake_rollout(
4143
codex_home.path(),
4244
"2025-01-01T12-00-00",
4345
"2025-01-01T12:00:00Z",
4446
"Hello C",
47+
None,
4548
);
4649

4750
let mut mcp = McpProcess::new(codex_home.path())
@@ -57,6 +60,7 @@ async fn test_list_and_resume_conversations() {
5760
.send_list_conversations_request(ListConversationsParams {
5861
page_size: Some(2),
5962
cursor: None,
63+
model_provider: None,
6064
})
6165
.await
6266
.expect("send listConversations");
@@ -74,6 +78,8 @@ async fn test_list_and_resume_conversations() {
7478
// Newest first; preview text should match
7579
assert_eq!(items[0].preview, "Hello A");
7680
assert_eq!(items[1].preview, "Hello B");
81+
assert_eq!(items[0].model_provider.as_deref(), Some("openai"));
82+
assert_eq!(items[1].model_provider.as_deref(), Some("openai"));
7783
assert!(items[0].path.is_absolute());
7884
assert!(next_cursor.is_some());
7985

@@ -82,6 +88,7 @@ async fn test_list_and_resume_conversations() {
8288
.send_list_conversations_request(ListConversationsParams {
8389
page_size: Some(2),
8490
cursor: next_cursor,
91+
model_provider: None,
8592
})
8693
.await
8794
.expect("send listConversations page 2");
@@ -99,8 +106,92 @@ async fn test_list_and_resume_conversations() {
99106
} = to_response::<ListConversationsResponse>(resp2).expect("deserialize response");
100107
assert_eq!(items2.len(), 1);
101108
assert_eq!(items2[0].preview, "Hello C");
109+
assert!(items2[0].model_provider.is_none());
102110
assert!(next2.is_some());
103111

112+
// Add a conversation with an explicit non-OpenAI provider for filter tests.
113+
create_fake_rollout(
114+
codex_home.path(),
115+
"2025-01-01T11-30-00",
116+
"2025-01-01T11:30:00Z",
117+
"Hello TP",
118+
Some("test-provider"),
119+
);
120+
121+
// Filtering by model provider should return only matching sessions.
122+
let filter_req_id = mcp
123+
.send_list_conversations_request(ListConversationsParams {
124+
page_size: Some(10),
125+
cursor: None,
126+
model_provider: Some(vec!["test-provider".to_string()]),
127+
})
128+
.await
129+
.expect("send listConversations filtered");
130+
let filter_resp: JSONRPCResponse = timeout(
131+
DEFAULT_READ_TIMEOUT,
132+
mcp.read_stream_until_response_message(RequestId::Integer(filter_req_id)),
133+
)
134+
.await
135+
.expect("listConversations filtered timeout")
136+
.expect("listConversations filtered resp");
137+
let ListConversationsResponse {
138+
items: filtered_items,
139+
next_cursor: filtered_next,
140+
} = to_response::<ListConversationsResponse>(filter_resp).expect("deserialize filtered");
141+
assert_eq!(filtered_items.len(), 1);
142+
assert!(filtered_next.is_none());
143+
assert_eq!(filtered_items[0].preview, "Hello TP");
144+
assert_eq!(
145+
filtered_items[0].model_provider.as_deref(),
146+
Some("test-provider")
147+
);
148+
149+
// Empty filter should include every session regardless of provider metadata.
150+
let unfiltered_req_id = mcp
151+
.send_list_conversations_request(ListConversationsParams {
152+
page_size: Some(10),
153+
cursor: None,
154+
model_provider: Some(Vec::new()),
155+
})
156+
.await
157+
.expect("send listConversations unfiltered");
158+
let unfiltered_resp: JSONRPCResponse = timeout(
159+
DEFAULT_READ_TIMEOUT,
160+
mcp.read_stream_until_response_message(RequestId::Integer(unfiltered_req_id)),
161+
)
162+
.await
163+
.expect("listConversations unfiltered timeout")
164+
.expect("listConversations unfiltered resp");
165+
let ListConversationsResponse {
166+
items: unfiltered_items,
167+
next_cursor: unfiltered_next,
168+
} = to_response::<ListConversationsResponse>(unfiltered_resp)
169+
.expect("deserialize unfiltered response");
170+
assert_eq!(unfiltered_items.len(), 4);
171+
assert!(unfiltered_next.is_none());
172+
173+
let empty_req_id = mcp
174+
.send_list_conversations_request(ListConversationsParams {
175+
page_size: Some(10),
176+
cursor: None,
177+
model_provider: Some(vec!["other".to_string()]),
178+
})
179+
.await
180+
.expect("send listConversations filtered empty");
181+
let empty_resp: JSONRPCResponse = timeout(
182+
DEFAULT_READ_TIMEOUT,
183+
mcp.read_stream_until_response_message(RequestId::Integer(empty_req_id)),
184+
)
185+
.await
186+
.expect("listConversations filtered empty timeout")
187+
.expect("listConversations filtered empty resp");
188+
let ListConversationsResponse {
189+
items: empty_items,
190+
next_cursor: empty_next,
191+
} = to_response::<ListConversationsResponse>(empty_resp).expect("deserialize filtered empty");
192+
assert!(empty_items.is_empty());
193+
assert!(empty_next.is_none());
194+
104195
// Now resume one of the sessions and expect a SessionConfigured notification and response.
105196
let resume_req_id = mcp
106197
.send_resume_conversation_request(ResumeConversationParams {
@@ -152,7 +243,13 @@ async fn test_list_and_resume_conversations() {
152243
assert!(!conversation_id.to_string().is_empty());
153244
}
154245

155-
fn create_fake_rollout(codex_home: &Path, filename_ts: &str, meta_rfc3339: &str, preview: &str) {
246+
fn create_fake_rollout(
247+
codex_home: &Path,
248+
filename_ts: &str,
249+
meta_rfc3339: &str,
250+
preview: &str,
251+
model_provider: Option<&str>,
252+
) {
156253
let uuid = Uuid::new_v4();
157254
// sessions/YYYY/MM/DD/ derived from filename_ts (YYYY-MM-DDThh-mm-ss)
158255
let year = &filename_ts[0..4];
@@ -164,18 +261,22 @@ fn create_fake_rollout(codex_home: &Path, filename_ts: &str, meta_rfc3339: &str,
164261
let file_path = dir.join(format!("rollout-{filename_ts}-{uuid}.jsonl"));
165262
let mut lines = Vec::new();
166263
// Meta line with timestamp (flattened meta in payload for new schema)
264+
let mut payload = json!({
265+
"id": uuid,
266+
"timestamp": meta_rfc3339,
267+
"cwd": "/",
268+
"originator": "codex",
269+
"cli_version": "0.0.0",
270+
"instructions": null,
271+
});
272+
if let Some(provider) = model_provider {
273+
payload["model_provider"] = json!(provider);
274+
}
167275
lines.push(
168276
json!({
169277
"timestamp": meta_rfc3339,
170278
"type": "session_meta",
171-
"payload": {
172-
"id": uuid,
173-
"timestamp": meta_rfc3339,
174-
"cwd": "/",
175-
"originator": "codex",
176-
"cli_version": "0.0.0",
177-
"instructions": null
178-
}
279+
"payload": payload
179280
})
180281
.to_string(),
181282
);

codex-rs/core/src/rollout/list.rs

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ struct HeadTailSummary {
5454
saw_session_meta: bool,
5555
saw_user_event: bool,
5656
source: Option<SessionSource>,
57+
model_provider: Option<String>,
5758
created_at: Option<String>,
5859
updated_at: Option<String>,
5960
}
@@ -109,6 +110,7 @@ pub(crate) async fn get_conversations(
109110
page_size: usize,
110111
cursor: Option<&Cursor>,
111112
allowed_sources: &[SessionSource],
113+
model_providers: Option<&[String]>,
112114
) -> io::Result<ConversationsPage> {
113115
let mut root = codex_home.to_path_buf();
114116
root.push(SESSIONS_SUBDIR);
@@ -124,8 +126,14 @@ pub(crate) async fn get_conversations(
124126

125127
let anchor = cursor.cloned();
126128

127-
let result =
128-
traverse_directories_for_paths(root.clone(), page_size, anchor, allowed_sources).await?;
129+
let result = traverse_directories_for_paths(
130+
root.clone(),
131+
page_size,
132+
anchor,
133+
allowed_sources,
134+
model_providers,
135+
)
136+
.await?;
129137
Ok(result)
130138
}
131139

@@ -145,6 +153,7 @@ async fn traverse_directories_for_paths(
145153
page_size: usize,
146154
anchor: Option<Cursor>,
147155
allowed_sources: &[SessionSource],
156+
model_providers: Option<&[String]>,
148157
) -> io::Result<ConversationsPage> {
149158
let mut items: Vec<ConversationItem> = Vec::with_capacity(page_size);
150159
let mut scanned_files = 0usize;
@@ -208,6 +217,12 @@ async fn traverse_directories_for_paths(
208217
{
209218
continue;
210219
}
220+
if let Some(filters) = model_providers
221+
&& !filters.is_empty()
222+
&& !provider_matches(filters, summary.model_provider.as_deref())
223+
{
224+
continue;
225+
}
211226
// Apply filters: must have session meta and at least one user message event
212227
if summary.saw_session_meta && summary.saw_user_event {
213228
let HeadTailSummary {
@@ -328,6 +343,17 @@ fn parse_timestamp_uuid_from_filename(name: &str) -> Option<(OffsetDateTime, Uui
328343
Some((ts, uuid))
329344
}
330345

346+
fn provider_matches(filters: &[String], session_provider: Option<&str>) -> bool {
347+
if filters.is_empty() {
348+
return true;
349+
}
350+
let has_openai = filters.iter().any(|provider| provider == "openai");
351+
match session_provider {
352+
Some(provider) => filters.iter().any(|candidate| candidate == provider),
353+
None => has_openai,
354+
}
355+
}
356+
331357
async fn read_head_and_tail(
332358
path: &Path,
333359
head_limit: usize,
@@ -354,6 +380,7 @@ async fn read_head_and_tail(
354380
match rollout_line.item {
355381
RolloutItem::SessionMeta(session_meta_line) => {
356382
summary.source = Some(session_meta_line.meta.source);
383+
summary.model_provider = session_meta_line.meta.model_provider.clone();
357384
summary.created_at = summary
358385
.created_at
359386
.clone()

codex-rs/core/src/rollout/recorder.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,16 @@ impl RolloutRecorder {
9797
page_size: usize,
9898
cursor: Option<&Cursor>,
9999
allowed_sources: &[SessionSource],
100+
model_providers: Option<&[String]>,
100101
) -> std::io::Result<ConversationsPage> {
101-
get_conversations(codex_home, page_size, cursor, allowed_sources).await
102+
get_conversations(
103+
codex_home,
104+
page_size,
105+
cursor,
106+
allowed_sources,
107+
model_providers,
108+
)
109+
.await
102110
}
103111

104112
/// Attempt to create a new [`RolloutRecorder`]. If the sessions directory
@@ -137,6 +145,7 @@ impl RolloutRecorder {
137145
cli_version: env!("CARGO_PKG_VERSION").to_string(),
138146
instructions,
139147
source,
148+
model_provider: Some(config.model_provider_id.clone()),
140149
}),
141150
)
142151
}

0 commit comments

Comments
 (0)