1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
//! Transparent provider-auth recovery for engine runs.
//!
//! An expired OpenAI Codex OAuth access token surfaces mid-run as an
//! `AUTHENTICATION_ERROR`. Historically that error was relayed straight to the
//! caller (a Telegram/Discord/Slack user), because the token was only ever
//! refreshed as a side effect of the control panel polling `GET /provider/auth`
//! (TAN-593). This module wraps a single engine run so that, on such a failure,
//! the credential is refreshed and the run is retried exactly once before the
//! error is surfaced (TAN-595).
//!
//! The wrapper is a plain future: the caller's timeout and liveness ticker in
//! `execute_run` poll it directly, so the (at most one) retry shares the same
//! overall run budget and keeps emitting heartbeats.
use serde_json::json;
use super::sessions::{dispatch_error_code, publish_tenant_event};
use crate::http::AppState;
use tandem_types::{ModelSpec, SendMessageRequest, TenantContext};
const OPENAI_CODEX_PROVIDER_ID: &str = "openai-codex";
/// Whether an `AUTHENTICATION_ERROR` for this run is worth a transparent
/// refresh-and-retry.
///
/// True only when the run actually targets the Codex OAuth provider *and* a
/// refreshable credential exists. Resolving the run's provider mirrors the
/// engine's own route resolution (`resolve_model_route`): the request model's
/// provider wins, otherwise the session model's. Gating on the resolved
/// provider matters because a tenant can have a saved Codex OAuth credential
/// while a given run uses an unrelated API-key provider (Anthropic,
/// OpenRouter, …) — a bad key there must not trigger a Codex refresh and a
/// wasted rerun.
fn run_targets_codex_oauth(
tenant_context: &TenantContext,
request_model: Option<&ModelSpec>,
session_model: Option<&ModelSpec>,
) -> bool {
let provider_id = request_model
.map(|model| model.provider_id.trim())
.filter(|provider| !provider.is_empty())
.or_else(|| {
session_model
.map(|model| model.provider_id.trim())
.filter(|provider| !provider.is_empty())
});
if provider_id != Some(OPENAI_CODEX_PROVIDER_ID) {
return false;
}
tandem_core::load_provider_oauth_credential_for_tenant(tenant_context, OPENAI_CODEX_PROVIDER_ID)
.is_some()
}
/// Run one engine prompt, transparently recovering from an expired provider
/// token. On an `AUTHENTICATION_ERROR` backed by a refreshable Codex OAuth
/// credential, refresh + reload providers and retry the run once before
/// propagating the failure.
pub(super) async fn run_prompt_with_auth_retry(
state: &AppState,
session_id: &str,
run_id: &str,
req: SendMessageRequest,
correlation_id: Option<String>,
tenant_context: &TenantContext,
) -> anyhow::Result<()> {
let retry_req = req.clone();
let first = state
.engine_loop
.run_prompt_async_with_context(session_id.to_string(), req, correlation_id.clone())
.await;
let err = match first {
Ok(()) => return Ok(()),
Err(err) => err,
};
if dispatch_error_code(&err.to_string()) != "AUTHENTICATION_ERROR" {
return Err(err);
}
// Only retry when this run actually targets the Codex OAuth provider.
let session_model = state
.storage
.get_session(session_id)
.await
.and_then(|session| session.model);
if !run_targets_codex_oauth(
tenant_context,
retry_req.model.as_ref(),
session_model.as_ref(),
) {
return Err(err);
}
tracing::info!(
session_id = %session_id,
run_id = %run_id,
"AUTHENTICATION_ERROR during run — refreshing Codex OAuth and retrying once"
);
publish_tenant_event(
state,
tenant_context,
"session.auth.refresh_retry",
json!({ "sessionID": session_id, "runID": run_id }),
);
if let Err(refresh_err) =
crate::http::config_providers::refresh_openai_codex_oauth_if_needed(state, tenant_context)
.await
{
tracing::warn!(
session_id = %session_id,
run_id = %run_id,
error = %refresh_err,
"Codex OAuth refresh before retry failed"
);
}
state
.engine_loop
.run_prompt_async_with_context(session_id.to_string(), retry_req, correlation_id)
.await
}