Files
codex/codex-rs/memories/write/src/runtime.rs
T
Celia Chen 6d9dbacf1a feat: add provider-aware model fallback to thread start (#29942)
## Why

Helper threads such as task title generation can request a model ID that
is valid for the default OpenAI provider but unavailable from the active
provider. With Amazon Bedrock, `gpt-5.4-mini` is rejected while the
provider static catalog exposes Bedrock model IDs such as
`openai.gpt-5.5` and `openai.gpt-5.4`. This causes repeated background
404s and can surface a misleading turn error even when the main turn
succeeds.

Clients need an explicit way to ask app-server to resolve an unavailable
helper model to the active provider default. That fallback must remain
limited to providers with an authoritative static catalog so custom or
dynamically discovered model IDs are not rewritten based on an
incomplete catalog.

Fixes #28741.

## What changed

- Add the experimental `allowProviderModelFallback` option to
`thread/start`, defaulting to `false` to preserve existing behavior.
- Thread the option through thread creation and model selection.
- When enabled for a static model manager, preserve requested models
present in the catalog and replace unavailable models with the provider
default.
- Continue preserving explicit model IDs for dynamic model managers
without fetching a catalog solely to validate them.
- Document the new `thread/start` behavior in the app-server API
overview.

## Test
Temporary test-client harness:
```
ThreadStartParams {
    model: Some("gpt-5.4-mini".to_string()),
    allow_provider_model_fallback: true,
    ..Default::default()
}
```
Command:
```
CODEX_HOME=/tmp/codex-bedrock-thread-start-home \
CODEX_E2E_BEDROCK_THREAD_START_ONLY=1 \
./target/debug/codex-app-server-test-client \
  --codex-bin ./target/debug/codex \
  -c 'model_provider="amazon-bedrock"' \
  send-message-v2 --experimental-api ignored
```
Relevant output:
```
> "method": "thread/start",
> "params": {
>   "model": "gpt-5.4-mini",
>   "modelProvider": null,
>   "allowProviderModelFallback": true,
>   ...
> }

< "result": {
<   "model": "openai.gpt-5.5",
<   "modelProvider": "amazon-bedrock",
<   ...
< }
```
2026-06-25 18:24:34 +00:00

390 lines
12 KiB
Rust

use codex_core::CodexThread;
use codex_core::ModelClient;
use codex_core::NewThread;
use codex_core::Prompt;
use codex_core::ResponseEvent;
use codex_core::StartThreadOptions;
use codex_core::ThreadManager;
use codex_core::config::Config;
use codex_core::content_items_to_text;
use codex_core::detached_memory_responses_metadata;
use codex_core::resolve_installation_id;
use codex_features::Feature;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_login::auth::AgentIdentityAuthPolicy;
use codex_login::auth_env_telemetry::collect_auth_env_telemetry;
use codex_login::default_client::originator;
use codex_model_provider::ModelProvider;
use codex_model_provider::SharedModelProvider;
use codex_model_provider::create_model_provider;
use codex_otel::SessionTelemetry;
use codex_otel::TelemetryAuthMode;
use codex_protocol::SessionId;
use codex_protocol::ThreadId;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::InitialHistory;
use codex_protocol::protocol::InternalSessionSource;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::ThreadSource;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::user_input::UserInput;
use codex_rollout_trace::InferenceTraceContext;
use codex_state::StateRuntime;
use codex_terminal_detection::user_agent;
use futures::StreamExt;
use std::sync::Arc;
use std::time::Duration;
pub(crate) struct SpawnedConsolidationAgent {
pub(crate) thread_id: ThreadId,
pub(crate) thread: Arc<CodexThread>,
}
#[derive(Clone, Debug)]
pub(crate) struct StageOneRequestContext {
pub(crate) model_info: ModelInfo,
pub(crate) session_telemetry: SessionTelemetry,
pub(crate) reasoning_effort: Option<ReasoningEffort>,
pub(crate) reasoning_summary: ReasoningSummary,
pub(crate) service_tier: Option<String>,
}
impl StageOneRequestContext {
pub(crate) fn start_timer(&self, name: &str) -> Option<codex_otel::Timer> {
self.session_telemetry.start_timer(name, &[]).ok()
}
pub(crate) fn counter(&self, name: &str, inc: i64, tags: &[(&str, &str)]) {
self.session_telemetry.counter(name, inc, tags);
}
pub(crate) fn histogram(&self, name: &str, value: i64, tags: &[(&str, &str)]) {
self.session_telemetry.histogram(name, value, tags);
}
}
pub(crate) struct MemoryStartupContext {
thread_id: ThreadId,
thread: Arc<CodexThread>,
thread_manager: Arc<ThreadManager>,
auth_manager: Arc<AuthManager>,
provider: SharedModelProvider,
session_telemetry: SessionTelemetry,
}
fn build_session_telemetry(
auth_manager: &AuthManager,
thread_id: ThreadId,
config: &Config,
source: SessionSource,
model: &str,
originator: String,
) -> SessionTelemetry {
let auth = auth_manager.auth_cached();
let auth = auth.as_ref();
let auth_mode = auth.map(CodexAuth::auth_mode).map(TelemetryAuthMode::from);
let account_id = auth.and_then(CodexAuth::get_account_id);
let account_email = auth.and_then(CodexAuth::get_account_email);
let auth_env_telemetry = collect_auth_env_telemetry(
&config.model_provider,
auth_manager.codex_api_key_env_enabled(),
);
SessionTelemetry::new(
thread_id,
model,
model,
account_id,
account_email,
auth_mode,
originator,
config.otel.log_user_prompt,
user_agent(),
source,
)
.with_auth_env(auth_env_telemetry.to_otel_metadata())
}
impl MemoryStartupContext {
pub(crate) fn new(
thread_manager: Arc<ThreadManager>,
auth_manager: Arc<AuthManager>,
thread_id: ThreadId,
thread: Arc<CodexThread>,
config: &Config,
source: SessionSource,
) -> Self {
let provider = create_model_provider(
config.model_provider.clone(),
Some(Arc::clone(&auth_manager)),
);
Self::new_with_provider(
thread_manager,
auth_manager,
thread_id,
thread,
config,
source,
provider,
)
}
#[cfg(test)]
pub(crate) fn new_for_testing(
thread_manager: Arc<ThreadManager>,
auth_manager: Arc<AuthManager>,
thread_id: ThreadId,
thread: Arc<CodexThread>,
config: &Config,
source: SessionSource,
provider: SharedModelProvider,
) -> Self {
Self::new_with_provider(
thread_manager,
auth_manager,
thread_id,
thread,
config,
source,
provider,
)
}
fn new_with_provider(
thread_manager: Arc<ThreadManager>,
auth_manager: Arc<AuthManager>,
thread_id: ThreadId,
thread: Arc<CodexThread>,
config: &Config,
source: SessionSource,
provider: SharedModelProvider,
) -> Self {
let model = config.model.as_deref().unwrap_or("unknown");
let session_telemetry = build_session_telemetry(
&auth_manager,
thread_id,
config,
source,
model,
originator().value,
);
Self {
thread_id,
thread,
thread_manager,
auth_manager,
provider,
session_telemetry,
}
}
pub(crate) fn thread_id(&self) -> ThreadId {
self.thread_id
}
pub(crate) fn state_db(&self) -> Option<Arc<StateRuntime>> {
self.thread.state_db()
}
pub(crate) fn provider(&self) -> &dyn ModelProvider {
self.provider.as_ref()
}
pub(crate) fn counter(&self, name: &str, inc: i64, tags: &[(&str, &str)]) {
self.session_telemetry.counter(name, inc, tags);
}
pub(crate) fn histogram(&self, name: &str, value: i64, tags: &[(&str, &str)]) {
self.session_telemetry.histogram(name, value, tags);
}
pub(crate) fn start_timer(&self, name: &str) -> Option<codex_otel::Timer> {
self.session_telemetry.start_timer(name, &[]).ok()
}
pub(crate) async fn stage_one_request_context(
&self,
config: &Config,
model_name: &str,
reasoning_effort: ReasoningEffort,
) -> StageOneRequestContext {
let config_snapshot = self.thread.config_snapshot().await;
let model_info = self
.thread_manager
.get_models_manager()
.get_model_info(model_name, &config.to_models_manager_config())
.await;
let reasoning_summary = config
.model_reasoning_summary
.unwrap_or(model_info.default_reasoning_summary);
StageOneRequestContext {
model_info,
session_telemetry: build_session_telemetry(
&self.auth_manager,
self.thread_id,
config,
config_snapshot.session_source,
model_name,
config_snapshot.originator,
),
reasoning_effort: Some(reasoning_effort),
reasoning_summary,
service_tier: config_snapshot.service_tier,
}
}
pub(crate) async fn stream_stage_one_prompt(
&self,
config: &Config,
prompt: &Prompt,
context: &StageOneRequestContext,
) -> anyhow::Result<(String, Option<TokenUsage>)> {
let installation_id = resolve_installation_id(&config.codex_home).await?;
let config_snapshot = self.thread.config_snapshot().await;
let session_source = config_snapshot.session_source;
let session_id = SessionId::from(self.thread_id);
let session_id_string = session_id.to_string();
let model_client = ModelClient::new(
Some(Arc::clone(&self.auth_manager)),
AgentIdentityAuthPolicy::JwtOnly,
self.thread_id,
config.model_provider.clone(),
session_source.clone(),
config_snapshot.originator,
config.model_verbosity,
config.features.enabled(Feature::EnableRequestCompression),
config.features.enabled(Feature::RuntimeMetrics),
/*beta_features_header*/ None,
config.features.enabled(Feature::ItemIds),
/*attestation_provider*/ None,
);
let mut client_session = model_client.new_session();
let window_id = format!("{}:0", self.thread_id);
let responses_metadata = detached_memory_responses_metadata(
installation_id,
session_id_string,
self.thread_id.to_string(),
window_id,
&session_source,
&config.cwd,
/*sandbox*/ None,
)
.await;
let mut stream = client_session
.stream(
prompt,
&context.model_info,
&context.session_telemetry,
context.reasoning_effort.clone(),
context.reasoning_summary,
context.service_tier.clone(),
&responses_metadata,
&InferenceTraceContext::disabled(),
)
.await?;
let mut result = String::new();
let mut token_usage = None;
while let Some(message) = stream.next().await.transpose()? {
match message {
ResponseEvent::OutputTextDelta(delta) => result.push_str(&delta),
ResponseEvent::OutputItemDone(item) => {
if result.is_empty()
&& let codex_protocol::models::ResponseItem::Message { content, .. } = item
&& let Some(text) = content_items_to_text(&content)
{
result.push_str(&text);
}
}
ResponseEvent::Completed {
token_usage: usage, ..
} => {
token_usage = usage;
break;
}
_ => {}
}
}
Ok((result, token_usage))
}
pub(crate) async fn spawn_consolidation_agent(
&self,
config: Config,
prompt: Vec<UserInput>,
) -> anyhow::Result<SpawnedConsolidationAgent> {
let environments = self
.thread_manager
.default_environment_selections(&config.cwd);
let NewThread {
thread_id, thread, ..
} = self
.thread_manager
.start_thread_with_options(StartThreadOptions {
config,
allow_provider_model_fallback: false,
initial_history: InitialHistory::New,
session_source: Some(SessionSource::Internal(
InternalSessionSource::MemoryConsolidation,
)),
thread_source: Some(ThreadSource::MemoryConsolidation),
dynamic_tools: Vec::new(),
metrics_service_name: None,
parent_trace: None,
environments,
thread_extension_init: Default::default(),
supports_openai_form_elicitation: false,
})
.await?;
let agent = SpawnedConsolidationAgent { thread_id, thread };
if let Err(err) = agent
.thread
.submit(Op::UserInput {
items: prompt,
final_output_json_schema: None,
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: Default::default(),
})
.await
{
if let Err(shutdown_err) = self.shutdown_consolidation_agent(agent).await {
tracing::warn!(
"failed to shut down consolidation agent after submit error: {shutdown_err}"
);
}
return Err(err.into());
}
Ok(agent)
}
pub(crate) async fn shutdown_consolidation_agent(
&self,
agent: SpawnedConsolidationAgent,
) -> anyhow::Result<()> {
let SpawnedConsolidationAgent { thread_id, thread } = agent;
let thread = self
.thread_manager
.remove_thread(&thread_id)
.await
.unwrap_or(thread);
tokio::time::timeout(Duration::from_secs(10), thread.shutdown_and_wait())
.await
.map_err(|_| {
anyhow::anyhow!("memory consolidation agent {thread_id} shutdown timed out")
})??;
Ok(())
}
}