mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 16:06:08 +00:00
1014 lines
35 KiB
Rust
1014 lines
35 KiB
Rust
pub mod affinity;
|
|
mod audit;
|
|
pub mod fallback;
|
|
mod limits;
|
|
pub mod refresh;
|
|
// 上游调度辅助命名为 routing,避免出现 dispatch::dispatch 的模块套娃。
|
|
pub mod routing;
|
|
pub mod stream;
|
|
pub mod usage;
|
|
|
|
use std::{sync::Arc, time::Instant};
|
|
|
|
use axum::{
|
|
body::Body,
|
|
http::{
|
|
header::{CACHE_CONTROL, CONTENT_TYPE},
|
|
StatusCode,
|
|
},
|
|
response::{IntoResponse, Response},
|
|
Json,
|
|
};
|
|
use chrono::Utc;
|
|
use futures::{stream as futures_stream, StreamExt};
|
|
use serde_json::{json, Value};
|
|
use tokio::sync::Mutex;
|
|
|
|
use crate::{
|
|
codex::accounts::cookies::repository::CookieRepository,
|
|
codex::accounts::{
|
|
model::Account,
|
|
pool::{AccountAcquireRequest, AccountPool},
|
|
repository::AccountRepository,
|
|
},
|
|
codex::gateway::fingerprint::model::Fingerprint,
|
|
codex::gateway::identity::{build_conversation_identity, ensure_prompt_cache_key},
|
|
codex::gateway::installation::get_installation_id,
|
|
codex::gateway::oauth::TokenRefresher,
|
|
codex::gateway::protocol::codex_to_openai::openai_error,
|
|
codex::gateway::transport::{
|
|
client::{
|
|
build_reqwest_client, CodexBackendClient, CodexBackendStream,
|
|
CodexBackendWebSocketStream, CodexClientError, CodexRequestContext,
|
|
},
|
|
types::CodexResponsesRequest,
|
|
usage::TokenUsage,
|
|
websocket::{
|
|
transport_for_request, CodexTransport, CodexWebSocketError, CodexWebSocketPool,
|
|
},
|
|
},
|
|
codex::logs::{
|
|
event::{EventLevel, EventLog},
|
|
service::LogService,
|
|
},
|
|
config::AppConfig,
|
|
};
|
|
|
|
use crate::codex::serving::http::errors::codex_client_error_response;
|
|
|
|
pub(crate) use self::{
|
|
fallback::{
|
|
classify_upstream_account_retry, websocket_history_retry_metadata, UpstreamAccountRetry,
|
|
},
|
|
routing::{no_available_accounts_response, normalize_service_tier_for_upstream},
|
|
stream::{completed_response_json, CollectedResponse},
|
|
};
|
|
|
|
use self::affinity::{compute_variant_hash, SessionAffinityMap, SessionAffinityRepository};
|
|
use self::audit::{StreamAudit, WebSocketStreamAudit};
|
|
use self::fallback::{
|
|
apply_upstream_account_retry_with_deps, apply_upstream_retry_and_acquire_fallback_with_deps,
|
|
};
|
|
use self::{
|
|
limits::apply_rate_limit_headers_with_deps,
|
|
refresh::refresh_account_after_unauthorized,
|
|
routing::request_domain,
|
|
stream::{completed_response_metadata, ensure_stream_metadata},
|
|
usage::{record_empty_response_with_deps, record_usage_with_deps},
|
|
};
|
|
|
|
#[derive(Clone)]
|
|
struct CodexUpstreamDependencies {
|
|
config: Arc<AppConfig>,
|
|
account_pool: Arc<Mutex<AccountPool>>,
|
|
account_repository: Option<AccountRepository>,
|
|
cookie_repository: Option<CookieRepository>,
|
|
logs: LogService,
|
|
token_refresher: Option<Arc<dyn TokenRefresher>>,
|
|
fingerprint: Fingerprint, // 用于实际请求的指纹
|
|
session_affinity: Arc<SessionAffinityMap>,
|
|
session_affinity_repository: Option<SessionAffinityRepository>,
|
|
websocket_pool: Arc<CodexWebSocketPool>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub(crate) struct CodexUpstreamService {
|
|
deps: CodexUpstreamDependencies,
|
|
}
|
|
|
|
pub(crate) struct CodexUpstreamRepositories {
|
|
pub(crate) account: Option<AccountRepository>,
|
|
pub(crate) cookie: Option<CookieRepository>,
|
|
pub(crate) session_affinity: Option<SessionAffinityRepository>,
|
|
}
|
|
|
|
impl CodexUpstreamService {
|
|
pub(crate) fn new(
|
|
config: Arc<AppConfig>,
|
|
account_pool: Arc<Mutex<AccountPool>>,
|
|
repositories: CodexUpstreamRepositories,
|
|
logs: LogService,
|
|
token_refresher: Option<Arc<dyn TokenRefresher>>,
|
|
fingerprint: Fingerprint,
|
|
) -> Self {
|
|
Self {
|
|
deps: CodexUpstreamDependencies {
|
|
config,
|
|
account_pool,
|
|
account_repository: repositories.account,
|
|
cookie_repository: repositories.cookie,
|
|
logs,
|
|
token_refresher,
|
|
fingerprint,
|
|
session_affinity: Arc::new(SessionAffinityMap::with_default_ttl()),
|
|
session_affinity_repository: repositories.session_affinity,
|
|
websocket_pool: Arc::new(CodexWebSocketPool::with_default_max_age()),
|
|
},
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn acquire_account(&self, model: &str) -> Option<Account> {
|
|
self.deps
|
|
.account_pool
|
|
.lock()
|
|
.await
|
|
.acquire_with(AccountAcquireRequest::new(model, Utc::now()))
|
|
.map(|acquired| acquired.account)
|
|
}
|
|
|
|
pub(crate) async fn prepare_response_session(&self, request: &mut CodexResponsesRequest) {
|
|
if let Some(previous_response_id) = request.previous_response_id.as_deref() {
|
|
if let Some(conversation_id) = self
|
|
.deps
|
|
.session_affinity
|
|
.lookup_conversation_id(previous_response_id)
|
|
.await
|
|
{
|
|
request.prompt_cache_key = Some(conversation_id);
|
|
}
|
|
if request
|
|
.turn_state
|
|
.as_deref()
|
|
.is_none_or(|value| value.trim().is_empty())
|
|
{
|
|
request.turn_state = self
|
|
.deps
|
|
.session_affinity
|
|
.lookup_turn_state(previous_response_id)
|
|
.await;
|
|
}
|
|
}
|
|
ensure_prompt_cache_key(request);
|
|
}
|
|
|
|
pub(crate) async fn acquire_account_for_request(
|
|
&self,
|
|
request: &CodexResponsesRequest,
|
|
) -> Option<Account> {
|
|
let preferred_account_id = match request.previous_response_id.as_deref() {
|
|
Some(previous_response_id) => {
|
|
self.deps
|
|
.session_affinity
|
|
.lookup_account(previous_response_id)
|
|
.await
|
|
}
|
|
None => None,
|
|
};
|
|
let mut acquire_request = AccountAcquireRequest::new(&request.model, Utc::now());
|
|
if let Some(preferred_account_id) = preferred_account_id {
|
|
acquire_request = acquire_request.with_preferred_account_id(preferred_account_id);
|
|
}
|
|
self.deps
|
|
.account_pool
|
|
.lock()
|
|
.await
|
|
.acquire_with(acquire_request)
|
|
.map(|acquired| acquired.account)
|
|
}
|
|
|
|
pub(crate) async fn release_account(&self, account_id: &str) {
|
|
self.deps.account_pool.lock().await.release(account_id);
|
|
}
|
|
|
|
/// 获取当前使用的指纹(用于诊断)
|
|
pub(crate) fn fingerprint(&self) -> &Fingerprint {
|
|
&self.deps.fingerprint
|
|
}
|
|
|
|
pub(crate) async fn send_codex_request_with_refresh_retry(
|
|
&self,
|
|
request: &CodexResponsesRequest,
|
|
account: &Account,
|
|
request_id: &str,
|
|
) -> Result<crate::codex::gateway::transport::client::CodexBackendResponse, CodexClientError>
|
|
{
|
|
send_codex_request_with_refresh_retry_deps(&self.deps, request, account, request_id).await
|
|
}
|
|
|
|
pub(crate) async fn apply_retry_and_acquire_fallback(
|
|
&self,
|
|
account: &Account,
|
|
retry: UpstreamAccountRetry,
|
|
model: &str,
|
|
excluded_account_ids: &mut Vec<String>,
|
|
) -> Option<Account> {
|
|
apply_upstream_retry_and_acquire_fallback_with_deps(
|
|
&self.deps,
|
|
account,
|
|
retry,
|
|
model,
|
|
excluded_account_ids,
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub(crate) async fn apply_account_retry(&self, account: &Account, retry: UpstreamAccountRetry) {
|
|
apply_upstream_account_retry_with_deps(&self.deps, account, retry).await;
|
|
}
|
|
|
|
pub(crate) async fn responses_stream(
|
|
&self,
|
|
request: CodexResponsesRequest,
|
|
account: Account,
|
|
log_context: CodexRequestLogContext,
|
|
) -> Response {
|
|
let deps = self.deps.clone();
|
|
if matches!(
|
|
transport_for_request(&request),
|
|
CodexTransport::WebSocketPreferred | CodexTransport::WebSocketRequired
|
|
) {
|
|
return responses_websocket_stream(deps, request, account, log_context).await;
|
|
}
|
|
|
|
responses_http_sse_stream(deps, request, account, log_context).await
|
|
}
|
|
|
|
pub(crate) async fn persist_cookies(
|
|
&self,
|
|
account_id: &str,
|
|
set_cookie_headers: &[String],
|
|
) -> Result<(), ()> {
|
|
persist_upstream_cookies_with_deps(&self.deps, account_id, set_cookie_headers).await
|
|
}
|
|
|
|
pub(crate) async fn record_usage(&self, account_id: &str, usage: TokenUsage) -> Result<(), ()> {
|
|
record_usage_with_deps(&self.deps, account_id, usage).await
|
|
}
|
|
|
|
pub(crate) async fn record_empty_response(&self, account_id: &str) -> Result<(), ()> {
|
|
record_empty_response_with_deps(&self.deps, account_id).await
|
|
}
|
|
|
|
pub(crate) async fn record_response_affinity(
|
|
&self,
|
|
request: &CodexResponsesRequest,
|
|
account_id: &str,
|
|
body: &str,
|
|
turn_state: Option<&str>,
|
|
usage: Option<TokenUsage>,
|
|
) {
|
|
record_response_affinity_with_deps(
|
|
&self.deps, request, account_id, body, turn_state, usage,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
pub(crate) async fn log_response(
|
|
&self,
|
|
context: &CodexRequestLogContext,
|
|
status: StatusCode,
|
|
level: EventLevel,
|
|
message: &str,
|
|
metadata: Value,
|
|
) {
|
|
log_codex_upstream_response_with_deps(
|
|
&self.deps, context, status, level, message, metadata,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
pub(crate) async fn reload_session_affinity_from_repository(
|
|
&self,
|
|
) -> self::affinity::SessionAffinityRepositoryResult<usize> {
|
|
let Some(repository) = self.deps.session_affinity_repository.as_ref() else {
|
|
return Ok(0);
|
|
};
|
|
let now = Utc::now();
|
|
repository.delete_expired(now).await?;
|
|
let records = repository.list_active(now).await?;
|
|
Ok(self.deps.session_affinity.restore(records).await)
|
|
}
|
|
}
|
|
|
|
async fn responses_http_sse_stream(
|
|
deps: CodexUpstreamDependencies,
|
|
request: CodexResponsesRequest,
|
|
mut account: Account,
|
|
mut log_context: CodexRequestLogContext,
|
|
) -> Response {
|
|
let mut excluded_account_ids = Vec::new();
|
|
let stream_response = loop {
|
|
let stream_response = send_codex_stream_request_with_refresh_retry(
|
|
&deps,
|
|
&request,
|
|
&account,
|
|
log_context.request_id.as_str(),
|
|
)
|
|
.await;
|
|
match stream_response {
|
|
Ok(response) => break response,
|
|
Err(error) => {
|
|
deps.account_pool.lock().await.release(&account.id);
|
|
if let Some(retry) = classify_upstream_account_retry(&error) {
|
|
let fallback = apply_upstream_retry_and_acquire_fallback_with_deps(
|
|
&deps,
|
|
&account,
|
|
retry,
|
|
&request.model,
|
|
&mut excluded_account_ids,
|
|
)
|
|
.await;
|
|
log_codex_upstream_response_with_deps(
|
|
&deps,
|
|
&log_context,
|
|
retry.status(),
|
|
EventLevel::Warn,
|
|
"v1 responses stream 上游请求将使用备用账户重试",
|
|
retry.metadata(true),
|
|
)
|
|
.await;
|
|
if let Some(fallback) = fallback {
|
|
account = fallback;
|
|
log_context = log_context.with_account(&account.id);
|
|
continue;
|
|
}
|
|
}
|
|
let error_response = codex_client_error_response(error);
|
|
log_codex_upstream_response_with_deps(
|
|
&deps,
|
|
&log_context,
|
|
error_response.0,
|
|
EventLevel::Error,
|
|
"v1 responses stream 上游请求失败",
|
|
json!({"stream": true}),
|
|
)
|
|
.await;
|
|
return error_response.into_response();
|
|
}
|
|
}
|
|
};
|
|
|
|
if persist_upstream_cookies_with_deps(&deps, &account.id, &stream_response.set_cookie_headers)
|
|
.await
|
|
.is_err()
|
|
{
|
|
deps.account_pool.lock().await.release(&account.id);
|
|
log_codex_upstream_response_with_deps(
|
|
&deps,
|
|
&log_context,
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
EventLevel::Error,
|
|
"v1 responses stream 持久化 cookie 失败",
|
|
json!({"stream": true, "cookieStoreError": true}),
|
|
)
|
|
.await;
|
|
return (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(openai_error(
|
|
"Failed to persist upstream cookies",
|
|
"cookie_store_error",
|
|
)),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
let rate_limit_headers = stream_response.rate_limit_headers.clone();
|
|
let upstream = Box::pin(stream_response.response.bytes_stream());
|
|
let audit = StreamAudit::new(
|
|
deps,
|
|
log_context,
|
|
account.id,
|
|
account.plan_type,
|
|
rate_limit_headers,
|
|
);
|
|
|
|
use tokio::time::{interval, Duration};
|
|
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(15);
|
|
const HEARTBEAT_CHUNK: &[u8] = b": ping\n\n";
|
|
|
|
let body_stream = futures_stream::unfold(
|
|
Some((upstream, Vec::new(), audit, interval(HEARTBEAT_INTERVAL))),
|
|
|state| async move {
|
|
let (mut upstream, mut collected, mut audit, mut heartbeat_timer) = state?;
|
|
|
|
tokio::select! {
|
|
chunk_result = upstream.next() => {
|
|
match chunk_result {
|
|
Some(Ok(chunk)) => {
|
|
collected.extend_from_slice(&chunk);
|
|
Some((Ok(chunk), Some((upstream, collected, audit, heartbeat_timer))))
|
|
}
|
|
Some(Err(error)) => {
|
|
audit.log_transport_error(&error).await;
|
|
Some((Err(error), None))
|
|
}
|
|
None => {
|
|
audit.complete(&collected).await;
|
|
None
|
|
}
|
|
}
|
|
}
|
|
_ = heartbeat_timer.tick() => {
|
|
Some((
|
|
Ok(HEARTBEAT_CHUNK.into()),
|
|
Some((upstream, collected, audit, heartbeat_timer)),
|
|
))
|
|
}
|
|
}
|
|
},
|
|
);
|
|
|
|
Response::builder()
|
|
.status(StatusCode::OK)
|
|
.header(CONTENT_TYPE, "text/event-stream")
|
|
.header(CACHE_CONTROL, "no-cache")
|
|
.body(Body::from_stream(body_stream))
|
|
.unwrap_or_else(|_| {
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(openai_error(
|
|
"Failed to build stream response",
|
|
"stream_response_error",
|
|
)),
|
|
)
|
|
.into_response()
|
|
})
|
|
}
|
|
|
|
fn websocket_stream_error_allows_http_sse_fallback(error: &CodexClientError) -> bool {
|
|
match error {
|
|
CodexClientError::WebSocket(
|
|
CodexWebSocketError::Transport(_) | CodexWebSocketError::EmptyResponse,
|
|
) => true,
|
|
CodexClientError::Upstream { status, .. } => matches!(
|
|
*status,
|
|
StatusCode::NOT_FOUND
|
|
| StatusCode::METHOD_NOT_ALLOWED
|
|
| StatusCode::UPGRADE_REQUIRED
|
|
| StatusCode::NOT_IMPLEMENTED
|
|
),
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
async fn send_codex_request_with_refresh_retry_deps(
|
|
deps: &CodexUpstreamDependencies,
|
|
request: &CodexResponsesRequest,
|
|
account: &Account,
|
|
request_id: &str,
|
|
) -> Result<crate::codex::gateway::transport::client::CodexBackendResponse, CodexClientError> {
|
|
let result = match send_codex_request(deps, request, account, request_id).await {
|
|
Err(CodexClientError::Upstream {
|
|
status,
|
|
body,
|
|
retry_after_seconds,
|
|
}) if status == StatusCode::UNAUTHORIZED => {
|
|
let Some(refreshed) =
|
|
refresh_account_after_unauthorized(deps, request, account, request_id).await
|
|
else {
|
|
return Err(CodexClientError::Upstream {
|
|
status,
|
|
body,
|
|
retry_after_seconds,
|
|
});
|
|
};
|
|
send_codex_request(deps, request, &refreshed, request_id).await
|
|
}
|
|
result => result,
|
|
};
|
|
if let Ok(response) = &result {
|
|
apply_rate_limit_headers_with_deps(
|
|
deps,
|
|
&account.id,
|
|
account.plan_type.as_deref(),
|
|
&response.rate_limit_headers,
|
|
)
|
|
.await;
|
|
}
|
|
result
|
|
}
|
|
|
|
async fn send_codex_request(
|
|
deps: &CodexUpstreamDependencies,
|
|
request: &CodexResponsesRequest,
|
|
account: &Account,
|
|
request_id: &str,
|
|
) -> Result<crate::codex::gateway::transport::client::CodexBackendResponse, CodexClientError> {
|
|
let request_domain = request_domain(&deps.config.api.base_url);
|
|
let cookie_header = match (deps.cookie_repository.as_ref(), request_domain.as_deref()) {
|
|
(Some(repo), Some(domain)) => repo.cookie_header(&account.id, domain).await.ok().flatten(),
|
|
_ => None,
|
|
};
|
|
|
|
let account_scope = &account.id;
|
|
let identity = build_conversation_identity(
|
|
request.prompt_cache_key.as_deref(),
|
|
request.codex_window_id.as_deref(),
|
|
account_scope,
|
|
);
|
|
|
|
let installation_id = get_installation_id(Some(&deps.config.database.url));
|
|
|
|
let client = CodexBackendClient::new(
|
|
build_reqwest_client(deps.config.tls.force_http11)?,
|
|
deps.config.api.base_url.clone(),
|
|
deps.fingerprint.clone(),
|
|
)
|
|
.with_websocket_pool(deps.websocket_pool.clone(), account.id.clone());
|
|
client
|
|
.create_response(
|
|
request,
|
|
CodexRequestContext {
|
|
access_token: &account.access_token,
|
|
account_id: account.account_id.as_deref(),
|
|
request_id,
|
|
turn_state: request.turn_state.as_deref(),
|
|
turn_metadata: request.turn_metadata.as_deref(),
|
|
beta_features: request.beta_features.as_deref(),
|
|
include_timing_metrics: request.include_timing_metrics.as_deref(),
|
|
version: request.version.as_deref(),
|
|
codex_window_id: identity.window_id.as_deref(),
|
|
parent_thread_id: request.parent_thread_id.as_deref(),
|
|
cookie_header: cookie_header.as_deref(),
|
|
installation_id: Some(&installation_id),
|
|
session_id: identity.conversation_id.as_deref(),
|
|
},
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn send_codex_stream_request_with_refresh_retry(
|
|
deps: &CodexUpstreamDependencies,
|
|
request: &CodexResponsesRequest,
|
|
account: &Account,
|
|
request_id: &str,
|
|
) -> Result<CodexBackendStream, CodexClientError> {
|
|
match send_codex_stream_request(deps, request, account, request_id).await {
|
|
Err(CodexClientError::Upstream {
|
|
status,
|
|
body,
|
|
retry_after_seconds,
|
|
}) if status == StatusCode::UNAUTHORIZED => {
|
|
let Some(refreshed) =
|
|
refresh_account_after_unauthorized(deps, request, account, request_id).await
|
|
else {
|
|
return Err(CodexClientError::Upstream {
|
|
status,
|
|
body,
|
|
retry_after_seconds,
|
|
});
|
|
};
|
|
send_codex_stream_request(deps, request, &refreshed, request_id).await
|
|
}
|
|
result => result,
|
|
}
|
|
}
|
|
|
|
async fn send_codex_stream_request(
|
|
deps: &CodexUpstreamDependencies,
|
|
request: &CodexResponsesRequest,
|
|
account: &Account,
|
|
request_id: &str,
|
|
) -> Result<CodexBackendStream, CodexClientError> {
|
|
let request_domain = request_domain(&deps.config.api.base_url);
|
|
let cookie_header = match (deps.cookie_repository.as_ref(), request_domain.as_deref()) {
|
|
(Some(repo), Some(domain)) => repo.cookie_header(&account.id, domain).await.ok().flatten(),
|
|
_ => None,
|
|
};
|
|
|
|
// Build conversation identity for session affinity
|
|
// Use account.id as the scope since entry_id doesn't exist
|
|
let account_scope = &account.id;
|
|
let identity = build_conversation_identity(
|
|
request.prompt_cache_key.as_deref(),
|
|
request.codex_window_id.as_deref(),
|
|
account_scope,
|
|
);
|
|
|
|
// Get installation ID (cached after first call)
|
|
let installation_id = get_installation_id(Some(&deps.config.database.url));
|
|
|
|
let client = CodexBackendClient::new(
|
|
build_reqwest_client(deps.config.tls.force_http11)?,
|
|
deps.config.api.base_url.clone(),
|
|
deps.fingerprint.clone(),
|
|
)
|
|
.with_websocket_pool(deps.websocket_pool.clone(), account.id.clone());
|
|
client
|
|
.stream_response(
|
|
request,
|
|
CodexRequestContext {
|
|
access_token: &account.access_token,
|
|
account_id: account.account_id.as_deref(),
|
|
request_id,
|
|
turn_state: request.turn_state.as_deref(),
|
|
turn_metadata: request.turn_metadata.as_deref(),
|
|
beta_features: request.beta_features.as_deref(),
|
|
include_timing_metrics: request.include_timing_metrics.as_deref(),
|
|
version: request.version.as_deref(),
|
|
codex_window_id: identity.window_id.as_deref(),
|
|
parent_thread_id: request.parent_thread_id.as_deref(),
|
|
cookie_header: cookie_header.as_deref(),
|
|
installation_id: Some(&installation_id),
|
|
session_id: identity.conversation_id.as_deref(),
|
|
},
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn send_codex_websocket_stream_request_with_refresh_retry(
|
|
deps: &CodexUpstreamDependencies,
|
|
request: &CodexResponsesRequest,
|
|
account: &Account,
|
|
request_id: &str,
|
|
) -> Result<CodexBackendWebSocketStream, CodexClientError> {
|
|
match send_codex_websocket_stream_request(deps, request, account, request_id).await {
|
|
Err(CodexClientError::Upstream {
|
|
status,
|
|
body,
|
|
retry_after_seconds,
|
|
}) if status == StatusCode::UNAUTHORIZED => {
|
|
let Some(refreshed) =
|
|
refresh_account_after_unauthorized(deps, request, account, request_id).await
|
|
else {
|
|
return Err(CodexClientError::Upstream {
|
|
status,
|
|
body,
|
|
retry_after_seconds,
|
|
});
|
|
};
|
|
send_codex_websocket_stream_request(deps, request, &refreshed, request_id).await
|
|
}
|
|
result => result,
|
|
}
|
|
}
|
|
|
|
async fn send_codex_websocket_stream_request(
|
|
deps: &CodexUpstreamDependencies,
|
|
request: &CodexResponsesRequest,
|
|
account: &Account,
|
|
request_id: &str,
|
|
) -> Result<CodexBackendWebSocketStream, CodexClientError> {
|
|
let request_domain = request_domain(&deps.config.api.base_url);
|
|
let cookie_header = match (deps.cookie_repository.as_ref(), request_domain.as_deref()) {
|
|
(Some(repo), Some(domain)) => repo.cookie_header(&account.id, domain).await.ok().flatten(),
|
|
_ => None,
|
|
};
|
|
|
|
let account_scope = &account.id;
|
|
let identity = build_conversation_identity(
|
|
request.prompt_cache_key.as_deref(),
|
|
request.codex_window_id.as_deref(),
|
|
account_scope,
|
|
);
|
|
let installation_id = get_installation_id(Some(&deps.config.database.url));
|
|
|
|
let client = CodexBackendClient::new(
|
|
build_reqwest_client(deps.config.tls.force_http11)?,
|
|
deps.config.api.base_url.clone(),
|
|
deps.fingerprint.clone(),
|
|
)
|
|
.with_websocket_pool(deps.websocket_pool.clone(), account.id.clone());
|
|
client
|
|
.websocket_stream_response(
|
|
request,
|
|
CodexRequestContext {
|
|
access_token: &account.access_token,
|
|
account_id: account.account_id.as_deref(),
|
|
request_id,
|
|
turn_state: request.turn_state.as_deref(),
|
|
turn_metadata: request.turn_metadata.as_deref(),
|
|
beta_features: request.beta_features.as_deref(),
|
|
include_timing_metrics: request.include_timing_metrics.as_deref(),
|
|
version: request.version.as_deref(),
|
|
codex_window_id: identity.window_id.as_deref(),
|
|
parent_thread_id: request.parent_thread_id.as_deref(),
|
|
cookie_header: cookie_header.as_deref(),
|
|
installation_id: Some(&installation_id),
|
|
session_id: identity.conversation_id.as_deref(),
|
|
},
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn record_response_affinity_with_deps(
|
|
deps: &CodexUpstreamDependencies,
|
|
request: &CodexResponsesRequest,
|
|
account_id: &str,
|
|
body: &str,
|
|
turn_state: Option<&str>,
|
|
usage: Option<TokenUsage>,
|
|
) {
|
|
let Some(conversation_id) = request
|
|
.prompt_cache_key
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
else {
|
|
return;
|
|
};
|
|
let metadata = match completed_response_metadata(body) {
|
|
Ok(Some(metadata)) => metadata,
|
|
Ok(None) => return,
|
|
Err(error) => {
|
|
tracing::warn!(
|
|
error = %error,
|
|
"解析已完成响应 metadata 用于 session affinity 失败"
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
|
|
let variant_hash = compute_variant_hash(request);
|
|
let response_id = metadata.response_id;
|
|
let entry = deps
|
|
.session_affinity
|
|
.record(
|
|
response_id.clone(),
|
|
account_id.to_string(),
|
|
conversation_id.to_string(),
|
|
turn_state
|
|
.filter(|value| !value.trim().is_empty())
|
|
.map(ToString::to_string)
|
|
.or_else(|| request.turn_state.clone()),
|
|
Some(&request.instructions),
|
|
usage.map(|usage| usage.input_tokens),
|
|
Some(metadata.function_call_ids),
|
|
variant_hash,
|
|
)
|
|
.await;
|
|
if let Some(repository) = deps.session_affinity_repository.as_ref() {
|
|
if let Err(error) = repository
|
|
.upsert(&response_id, &entry, deps.session_affinity.ttl())
|
|
.await
|
|
{
|
|
tracing::warn!(
|
|
error = %error,
|
|
response_id = %response_id,
|
|
account_id = %account_id,
|
|
"持久化 session affinity 失败"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn responses_websocket_stream(
|
|
deps: CodexUpstreamDependencies,
|
|
request: CodexResponsesRequest,
|
|
mut account: Account,
|
|
mut log_context: CodexRequestLogContext,
|
|
) -> Response {
|
|
let mut excluded_account_ids = Vec::new();
|
|
let stream_response = loop {
|
|
let response = send_codex_websocket_stream_request_with_refresh_retry(
|
|
&deps,
|
|
&request,
|
|
&account,
|
|
log_context.request_id.as_str(),
|
|
)
|
|
.await;
|
|
|
|
match response {
|
|
Ok(response) => break response,
|
|
Err(error) => {
|
|
if transport_for_request(&request) == CodexTransport::WebSocketPreferred
|
|
&& websocket_stream_error_allows_http_sse_fallback(&error)
|
|
{
|
|
return responses_http_sse_stream(deps, request, account, log_context).await;
|
|
}
|
|
deps.account_pool.lock().await.release(&account.id);
|
|
if let Some(retry) = classify_upstream_account_retry(&error) {
|
|
if request.previous_response_id.is_some() {
|
|
// previous_response_id 的历史由上游账号持有,换账号会静默丢失会话上下文。
|
|
apply_upstream_account_retry_with_deps(&deps, &account, retry).await;
|
|
log_codex_upstream_response_with_deps(
|
|
&deps,
|
|
&log_context,
|
|
retry.status(),
|
|
EventLevel::Warn,
|
|
"v1 responses WebSocket history 请求保持原账户",
|
|
websocket_history_retry_metadata(retry, true),
|
|
)
|
|
.await;
|
|
} else {
|
|
let fallback = apply_upstream_retry_and_acquire_fallback_with_deps(
|
|
&deps,
|
|
&account,
|
|
retry,
|
|
&request.model,
|
|
&mut excluded_account_ids,
|
|
)
|
|
.await;
|
|
log_codex_upstream_response_with_deps(
|
|
&deps,
|
|
&log_context,
|
|
retry.status(),
|
|
EventLevel::Warn,
|
|
"v1 responses WebSocket 上游请求将使用备用账户重试",
|
|
retry.metadata(true),
|
|
)
|
|
.await;
|
|
if let Some(fallback) = fallback {
|
|
account = fallback;
|
|
log_context = log_context.with_account(&account.id);
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
let error_response = codex_client_error_response(error);
|
|
log_codex_upstream_response_with_deps(
|
|
&deps,
|
|
&log_context,
|
|
error_response.0,
|
|
EventLevel::Error,
|
|
"v1 responses WebSocket stream 上游请求失败",
|
|
json!({"stream": true, "transport": "websocket"}),
|
|
)
|
|
.await;
|
|
return error_response.into_response();
|
|
}
|
|
}
|
|
};
|
|
|
|
if persist_upstream_cookies_with_deps(&deps, &account.id, &stream_response.set_cookie_headers)
|
|
.await
|
|
.is_err()
|
|
{
|
|
deps.account_pool.lock().await.release(&account.id);
|
|
log_codex_upstream_response_with_deps(
|
|
&deps,
|
|
&log_context,
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
EventLevel::Error,
|
|
"v1 responses WebSocket stream 持久化 cookie 失败",
|
|
json!({"stream": true, "transport": "websocket", "cookieStoreError": true}),
|
|
)
|
|
.await;
|
|
return (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(openai_error(
|
|
"Failed to persist upstream cookies",
|
|
"cookie_store_error",
|
|
)),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
let audit = WebSocketStreamAudit::new(
|
|
deps,
|
|
log_context,
|
|
account.id,
|
|
account.plan_type,
|
|
request,
|
|
stream_response.turn_state,
|
|
stream_response.rate_limit_headers,
|
|
stream_response.rate_limit_updates,
|
|
);
|
|
let upstream = stream_response.body_stream;
|
|
|
|
use tokio::time::{interval, Duration};
|
|
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(15);
|
|
const HEARTBEAT_CHUNK: &str = ": ping\n\n";
|
|
|
|
let body_stream = futures_stream::unfold(
|
|
Some((upstream, Vec::new(), audit, interval(HEARTBEAT_INTERVAL))),
|
|
|state| async move {
|
|
let (mut upstream, mut collected, mut audit, mut heartbeat_timer) = state?;
|
|
|
|
tokio::select! {
|
|
chunk_result = upstream.next() => {
|
|
match chunk_result {
|
|
Some(Ok(chunk)) => {
|
|
collected.extend_from_slice(chunk.as_bytes());
|
|
Some((
|
|
Ok::<String, std::io::Error>(chunk),
|
|
Some((upstream, collected, audit, heartbeat_timer)),
|
|
))
|
|
}
|
|
Some(Err(error)) => {
|
|
audit.log_transport_error(&error).await;
|
|
Some((Err(std::io::Error::other(error.to_string())), None))
|
|
}
|
|
None => {
|
|
audit.complete(&collected).await;
|
|
None
|
|
}
|
|
}
|
|
}
|
|
_ = heartbeat_timer.tick() => {
|
|
Some((
|
|
Ok::<String, std::io::Error>(HEARTBEAT_CHUNK.to_string()),
|
|
Some((upstream, collected, audit, heartbeat_timer)),
|
|
))
|
|
}
|
|
}
|
|
},
|
|
);
|
|
|
|
Response::builder()
|
|
.status(StatusCode::OK)
|
|
.header(CONTENT_TYPE, "text/event-stream")
|
|
.header(CACHE_CONTROL, "no-cache")
|
|
.body(Body::from_stream(body_stream))
|
|
.unwrap_or_else(|_| {
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(openai_error(
|
|
"Failed to build stream response",
|
|
"stream_response_error",
|
|
)),
|
|
)
|
|
.into_response()
|
|
})
|
|
}
|
|
|
|
async fn persist_upstream_cookies_with_deps(
|
|
deps: &CodexUpstreamDependencies,
|
|
account_id: &str,
|
|
set_cookie_headers: &[String],
|
|
) -> Result<(), ()> {
|
|
let Some(cookie_repo) = deps.cookie_repository.as_ref() else {
|
|
return Ok(());
|
|
};
|
|
for cookie in set_cookie_headers {
|
|
cookie_repo
|
|
.capture_set_cookie(account_id, cookie)
|
|
.await
|
|
.map_err(|_| ())?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub(crate) struct CodexRequestLogContext {
|
|
request_id: String,
|
|
account_id: String,
|
|
model: String,
|
|
stream: bool,
|
|
started_at: Instant,
|
|
}
|
|
|
|
impl CodexRequestLogContext {
|
|
pub(crate) fn new(
|
|
request_id: &str,
|
|
account_id: &str,
|
|
model: &str,
|
|
stream: bool,
|
|
started_at: Instant,
|
|
) -> Self {
|
|
Self {
|
|
request_id: request_id.to_string(),
|
|
account_id: account_id.to_string(),
|
|
model: model.to_string(),
|
|
stream,
|
|
started_at,
|
|
}
|
|
}
|
|
|
|
fn latency_ms(&self) -> i64 {
|
|
self.started_at.elapsed().as_millis().min(i64::MAX as u128) as i64
|
|
}
|
|
|
|
pub(crate) fn with_account(&self, account_id: &str) -> Self {
|
|
Self {
|
|
request_id: self.request_id.clone(),
|
|
account_id: account_id.to_string(),
|
|
model: self.model.clone(),
|
|
stream: self.stream,
|
|
started_at: self.started_at,
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn log_codex_upstream_response_with_deps(
|
|
deps: &CodexUpstreamDependencies,
|
|
context: &CodexRequestLogContext,
|
|
status: StatusCode,
|
|
level: EventLevel,
|
|
message: &str,
|
|
mut metadata: Value,
|
|
) {
|
|
ensure_stream_metadata(&mut metadata, context.stream);
|
|
let mut event = EventLog::new("v1.response", level, message);
|
|
event.request_id = Some(context.request_id.clone());
|
|
event.account_id = Some(context.account_id.clone());
|
|
event.route = Some("/v1/responses".to_string());
|
|
event.model = Some(context.model.clone());
|
|
event.status_code = Some(i64::from(status.as_u16()));
|
|
event.latency_ms = Some(context.latency_ms());
|
|
event.metadata = metadata;
|
|
if let Err(error) = deps.logs.record(event).await {
|
|
tracing::warn!(error = %error, "写入 v1 response 事件日志失败");
|
|
}
|
|
}
|