mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Feat: CXA-1831 Persist latest model and reasoning effort in sqlite (#14859)
### Summary The goal is for us to get the latest turn model and reasoning effort on thread/resume is no override is provided on the thread/resume func call. This is the part 1 which we write the model and reasoning effort for a thread to the sqlite db and there will be a followup PR to consume the two new fields on thread/resume. [part 2 PR is currently WIP](https://github.com/openai/codex/pull/14888) and this one can be merged independently.
This commit is contained in:
@@ -26,6 +26,8 @@ fn thread_metadata(cwd: &str, title: &str, first_user_message: &str) -> ThreadMe
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
model_provider: "test-provider".to_string(),
|
||||
model: Some("gpt-5".to_string()),
|
||||
reasoning_effort: None,
|
||||
cwd: PathBuf::from(cwd),
|
||||
cli_version: "test".to_string(),
|
||||
title: title.to_string(),
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! are used to preserve compatibility when older payloads omit newly introduced attributes.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::str::FromStr;
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
@@ -48,6 +49,15 @@ pub enum ReasoningEffort {
|
||||
XHigh,
|
||||
}
|
||||
|
||||
impl FromStr for ReasoningEffort {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
serde_json::from_value(serde_json::Value::String(s.to_string()))
|
||||
.map_err(|_| format!("invalid reasoning_effort: {s}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical user-input modality tags advertised by a model.
|
||||
#[derive(
|
||||
Debug,
|
||||
@@ -552,6 +562,20 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_effort_from_str_accepts_known_values() {
|
||||
assert_eq!("high".parse(), Ok(ReasoningEffort::High));
|
||||
assert_eq!("minimal".parse(), Ok(ReasoningEffort::Minimal));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_effort_from_str_rejects_unknown_values() {
|
||||
assert_eq!(
|
||||
"unsupported".parse::<ReasoningEffort>(),
|
||||
Err("invalid reasoning_effort: unsupported".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_model_instructions_uses_template_when_placeholder_present() {
|
||||
let model = test_model(Some(ModelMessages {
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE threads ADD COLUMN model TEXT;
|
||||
ALTER TABLE threads ADD COLUMN reasoning_effort TEXT;
|
||||
@@ -70,6 +70,8 @@ fn apply_turn_context(metadata: &mut ThreadMetadata, turn_ctx: &TurnContextItem)
|
||||
if metadata.cwd.as_os_str().is_empty() {
|
||||
metadata.cwd = turn_ctx.cwd.clone();
|
||||
}
|
||||
metadata.model = Some(turn_ctx.model.clone());
|
||||
metadata.reasoning_effort = turn_ctx.effort;
|
||||
metadata.sandbox_policy = enum_to_string(&turn_ctx.sandbox_policy);
|
||||
metadata.approval_mode = enum_to_string(&turn_ctx.approval_policy);
|
||||
}
|
||||
@@ -141,6 +143,7 @@ mod tests {
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
@@ -312,7 +315,7 @@ mod tests {
|
||||
personality: None,
|
||||
collaboration_mode: None,
|
||||
realtime_active: None,
|
||||
effort: None,
|
||||
effort: Some(ReasoningEffort::High),
|
||||
summary: ReasoningSummary::Auto,
|
||||
user_instructions: None,
|
||||
developer_instructions: None,
|
||||
@@ -325,6 +328,71 @@ mod tests {
|
||||
assert_eq!(metadata.cwd, PathBuf::from("/fallback/workspace"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_context_sets_model_and_reasoning_effort() {
|
||||
let mut metadata = metadata_for_test();
|
||||
|
||||
apply_rollout_item(
|
||||
&mut metadata,
|
||||
&RolloutItem::TurnContext(TurnContextItem {
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
trace_id: None,
|
||||
cwd: PathBuf::from("/fallback/workspace"),
|
||||
current_date: None,
|
||||
timezone: None,
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
network: None,
|
||||
model: "gpt-5".to_string(),
|
||||
personality: None,
|
||||
collaboration_mode: None,
|
||||
realtime_active: None,
|
||||
effort: Some(ReasoningEffort::High),
|
||||
summary: ReasoningSummary::Auto,
|
||||
user_instructions: None,
|
||||
developer_instructions: None,
|
||||
final_output_json_schema: None,
|
||||
truncation_policy: None,
|
||||
}),
|
||||
"test-provider",
|
||||
);
|
||||
|
||||
assert_eq!(metadata.model.as_deref(), Some("gpt-5"));
|
||||
assert_eq!(metadata.reasoning_effort, Some(ReasoningEffort::High));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_meta_does_not_set_model_or_reasoning_effort() {
|
||||
let mut metadata = metadata_for_test();
|
||||
let thread_id = metadata.id;
|
||||
|
||||
apply_rollout_item(
|
||||
&mut metadata,
|
||||
&RolloutItem::SessionMeta(SessionMetaLine {
|
||||
meta: SessionMeta {
|
||||
id: thread_id,
|
||||
forked_from_id: None,
|
||||
timestamp: "2026-02-26T00:00:00.000Z".to_string(),
|
||||
cwd: PathBuf::from("/workspace"),
|
||||
originator: "codex_cli_rs".to_string(),
|
||||
cli_version: "0.0.0".to_string(),
|
||||
source: SessionSource::Cli,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
model_provider: Some("openai".to_string()),
|
||||
base_instructions: None,
|
||||
dynamic_tools: None,
|
||||
memory_mode: None,
|
||||
},
|
||||
git: None,
|
||||
}),
|
||||
"test-provider",
|
||||
);
|
||||
|
||||
assert_eq!(metadata.model, None);
|
||||
assert_eq!(metadata.reasoning_effort, None);
|
||||
}
|
||||
|
||||
fn metadata_for_test() -> ThreadMetadata {
|
||||
let id = ThreadId::from_string(&Uuid::from_u128(42).to_string()).expect("thread id");
|
||||
let created_at = DateTime::<Utc>::from_timestamp(1_735_689_600, 0).expect("timestamp");
|
||||
@@ -337,6 +405,8 @@ mod tests {
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
model_provider: "openai".to_string(),
|
||||
model: None,
|
||||
reasoning_effort: None,
|
||||
cwd: PathBuf::from("/tmp"),
|
||||
cli_version: "0.0.0".to_string(),
|
||||
title: String::new(),
|
||||
|
||||
@@ -3,6 +3,7 @@ use chrono::DateTime;
|
||||
use chrono::Timelike;
|
||||
use chrono::Utc;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
@@ -70,6 +71,10 @@ pub struct ThreadMetadata {
|
||||
pub agent_role: Option<String>,
|
||||
/// The model provider identifier.
|
||||
pub model_provider: String,
|
||||
/// The latest observed model for the thread.
|
||||
pub model: Option<String>,
|
||||
/// The latest observed reasoning effort for the thread.
|
||||
pub reasoning_effort: Option<ReasoningEffort>,
|
||||
/// The working directory for the thread.
|
||||
pub cwd: PathBuf,
|
||||
/// Version of the CLI that created the thread.
|
||||
@@ -181,6 +186,8 @@ impl ThreadMetadataBuilder {
|
||||
.model_provider
|
||||
.clone()
|
||||
.unwrap_or_else(|| default_provider.to_string()),
|
||||
model: None,
|
||||
reasoning_effort: None,
|
||||
cwd: self.cwd.clone(),
|
||||
cli_version: self.cli_version.clone().unwrap_or_default(),
|
||||
title: String::new(),
|
||||
@@ -237,6 +244,12 @@ impl ThreadMetadata {
|
||||
if self.model_provider != other.model_provider {
|
||||
diffs.push("model_provider");
|
||||
}
|
||||
if self.model != other.model {
|
||||
diffs.push("model");
|
||||
}
|
||||
if self.reasoning_effort != other.reasoning_effort {
|
||||
diffs.push("reasoning_effort");
|
||||
}
|
||||
if self.cwd != other.cwd {
|
||||
diffs.push("cwd");
|
||||
}
|
||||
@@ -288,6 +301,8 @@ pub(crate) struct ThreadRow {
|
||||
agent_nickname: Option<String>,
|
||||
agent_role: Option<String>,
|
||||
model_provider: String,
|
||||
model: Option<String>,
|
||||
reasoning_effort: Option<String>,
|
||||
cwd: String,
|
||||
cli_version: String,
|
||||
title: String,
|
||||
@@ -312,6 +327,8 @@ impl ThreadRow {
|
||||
agent_nickname: row.try_get("agent_nickname")?,
|
||||
agent_role: row.try_get("agent_role")?,
|
||||
model_provider: row.try_get("model_provider")?,
|
||||
model: row.try_get("model")?,
|
||||
reasoning_effort: row.try_get("reasoning_effort")?,
|
||||
cwd: row.try_get("cwd")?,
|
||||
cli_version: row.try_get("cli_version")?,
|
||||
title: row.try_get("title")?,
|
||||
@@ -340,6 +357,8 @@ impl TryFrom<ThreadRow> for ThreadMetadata {
|
||||
agent_nickname,
|
||||
agent_role,
|
||||
model_provider,
|
||||
model,
|
||||
reasoning_effort,
|
||||
cwd,
|
||||
cli_version,
|
||||
title,
|
||||
@@ -361,6 +380,9 @@ impl TryFrom<ThreadRow> for ThreadMetadata {
|
||||
agent_nickname,
|
||||
agent_role,
|
||||
model_provider,
|
||||
model,
|
||||
reasoning_effort: reasoning_effort
|
||||
.and_then(|value| value.parse::<ReasoningEffort>().ok()),
|
||||
cwd: PathBuf::from(cwd),
|
||||
cli_version,
|
||||
title,
|
||||
@@ -404,3 +426,87 @@ pub struct BackfillStats {
|
||||
/// The number of rows that failed to upsert.
|
||||
pub failed: usize,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::ThreadMetadata;
|
||||
use super::ThreadRow;
|
||||
use chrono::DateTime;
|
||||
use chrono::Utc;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn thread_row(reasoning_effort: Option<&str>) -> ThreadRow {
|
||||
ThreadRow {
|
||||
id: "00000000-0000-0000-0000-000000000123".to_string(),
|
||||
rollout_path: "/tmp/rollout-123.jsonl".to_string(),
|
||||
created_at: 1_700_000_000,
|
||||
updated_at: 1_700_000_100,
|
||||
source: "cli".to_string(),
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
model_provider: "openai".to_string(),
|
||||
model: Some("gpt-5".to_string()),
|
||||
reasoning_effort: reasoning_effort.map(str::to_string),
|
||||
cwd: "/tmp/workspace".to_string(),
|
||||
cli_version: "0.0.0".to_string(),
|
||||
title: String::new(),
|
||||
sandbox_policy: "read-only".to_string(),
|
||||
approval_mode: "on-request".to_string(),
|
||||
tokens_used: 1,
|
||||
first_user_message: String::new(),
|
||||
archived_at: None,
|
||||
git_sha: None,
|
||||
git_branch: None,
|
||||
git_origin_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn expected_thread_metadata(reasoning_effort: Option<ReasoningEffort>) -> ThreadMetadata {
|
||||
ThreadMetadata {
|
||||
id: ThreadId::from_string("00000000-0000-0000-0000-000000000123")
|
||||
.expect("valid thread id"),
|
||||
rollout_path: PathBuf::from("/tmp/rollout-123.jsonl"),
|
||||
created_at: DateTime::<Utc>::from_timestamp(1_700_000_000, 0).expect("timestamp"),
|
||||
updated_at: DateTime::<Utc>::from_timestamp(1_700_000_100, 0).expect("timestamp"),
|
||||
source: "cli".to_string(),
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
model_provider: "openai".to_string(),
|
||||
model: Some("gpt-5".to_string()),
|
||||
reasoning_effort,
|
||||
cwd: PathBuf::from("/tmp/workspace"),
|
||||
cli_version: "0.0.0".to_string(),
|
||||
title: String::new(),
|
||||
sandbox_policy: "read-only".to_string(),
|
||||
approval_mode: "on-request".to_string(),
|
||||
tokens_used: 1,
|
||||
first_user_message: None,
|
||||
archived_at: None,
|
||||
git_sha: None,
|
||||
git_branch: None,
|
||||
git_origin_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_row_parses_reasoning_effort() {
|
||||
let metadata = ThreadMetadata::try_from(thread_row(Some("high")))
|
||||
.expect("thread metadata should parse");
|
||||
|
||||
assert_eq!(
|
||||
metadata,
|
||||
expected_thread_metadata(Some(ReasoningEffort::High))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_row_ignores_unknown_reasoning_effort_values() {
|
||||
let metadata = ThreadMetadata::try_from(thread_row(Some("future")))
|
||||
.expect("thread metadata should parse");
|
||||
|
||||
assert_eq!(metadata, expected_thread_metadata(None));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,6 +169,8 @@ SELECT
|
||||
agent_nickname,
|
||||
agent_role,
|
||||
model_provider,
|
||||
model,
|
||||
reasoning_effort,
|
||||
cwd,
|
||||
cli_version,
|
||||
title,
|
||||
|
||||
@@ -5,6 +5,8 @@ use chrono::Utc;
|
||||
#[cfg(test)]
|
||||
use codex_protocol::ThreadId;
|
||||
#[cfg(test)]
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
#[cfg(test)]
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
#[cfg(test)]
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
@@ -49,6 +51,8 @@ pub(super) fn test_thread_metadata(
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
model_provider: "test-provider".to_string(),
|
||||
model: Some("gpt-5".to_string()),
|
||||
reasoning_effort: Some(ReasoningEffort::Medium),
|
||||
cwd,
|
||||
cli_version: "0.0.0".to_string(),
|
||||
title: String::new(),
|
||||
|
||||
@@ -13,6 +13,8 @@ SELECT
|
||||
agent_nickname,
|
||||
agent_role,
|
||||
model_provider,
|
||||
model,
|
||||
reasoning_effort,
|
||||
cwd,
|
||||
cli_version,
|
||||
title,
|
||||
@@ -125,6 +127,8 @@ SELECT
|
||||
agent_nickname,
|
||||
agent_role,
|
||||
model_provider,
|
||||
model,
|
||||
reasoning_effort,
|
||||
cwd,
|
||||
cli_version,
|
||||
title,
|
||||
@@ -223,6 +227,8 @@ INSERT INTO threads (
|
||||
agent_nickname,
|
||||
agent_role,
|
||||
model_provider,
|
||||
model,
|
||||
reasoning_effort,
|
||||
cwd,
|
||||
cli_version,
|
||||
title,
|
||||
@@ -236,7 +242,7 @@ INSERT INTO threads (
|
||||
git_branch,
|
||||
git_origin_url,
|
||||
memory_mode
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO NOTHING
|
||||
"#,
|
||||
)
|
||||
@@ -248,6 +254,13 @@ ON CONFLICT(id) DO NOTHING
|
||||
.bind(metadata.agent_nickname.as_deref())
|
||||
.bind(metadata.agent_role.as_deref())
|
||||
.bind(metadata.model_provider.as_str())
|
||||
.bind(metadata.model.as_deref())
|
||||
.bind(
|
||||
metadata
|
||||
.reasoning_effort
|
||||
.as_ref()
|
||||
.map(crate::extract::enum_to_string),
|
||||
)
|
||||
.bind(metadata.cwd.display().to_string())
|
||||
.bind(metadata.cli_version.as_str())
|
||||
.bind(metadata.title.as_str())
|
||||
@@ -337,6 +350,8 @@ INSERT INTO threads (
|
||||
agent_nickname,
|
||||
agent_role,
|
||||
model_provider,
|
||||
model,
|
||||
reasoning_effort,
|
||||
cwd,
|
||||
cli_version,
|
||||
title,
|
||||
@@ -350,7 +365,7 @@ INSERT INTO threads (
|
||||
git_branch,
|
||||
git_origin_url,
|
||||
memory_mode
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
rollout_path = excluded.rollout_path,
|
||||
created_at = excluded.created_at,
|
||||
@@ -359,6 +374,8 @@ ON CONFLICT(id) DO UPDATE SET
|
||||
agent_nickname = excluded.agent_nickname,
|
||||
agent_role = excluded.agent_role,
|
||||
model_provider = excluded.model_provider,
|
||||
model = excluded.model,
|
||||
reasoning_effort = excluded.reasoning_effort,
|
||||
cwd = excluded.cwd,
|
||||
cli_version = excluded.cli_version,
|
||||
title = excluded.title,
|
||||
@@ -381,6 +398,13 @@ ON CONFLICT(id) DO UPDATE SET
|
||||
.bind(metadata.agent_nickname.as_deref())
|
||||
.bind(metadata.agent_role.as_deref())
|
||||
.bind(metadata.model_provider.as_str())
|
||||
.bind(metadata.model.as_deref())
|
||||
.bind(
|
||||
metadata
|
||||
.reasoning_effort
|
||||
.as_ref()
|
||||
.map(crate::extract::enum_to_string),
|
||||
)
|
||||
.bind(metadata.cwd.display().to_string())
|
||||
.bind(metadata.cli_version.as_str())
|
||||
.bind(metadata.title.as_str())
|
||||
|
||||
Reference in New Issue
Block a user