diff --git a/backend/src/api/admin/dashboard_routes.rs b/backend/src/api/admin/dashboard_routes.rs index 421b11d18..32321d1a7 100644 --- a/backend/src/api/admin/dashboard_routes.rs +++ b/backend/src/api/admin/dashboard_routes.rs @@ -102,6 +102,7 @@ struct DashboardUsageRecordData { client_ip: Option, user_agent: Option, reasoning_effort: Option, + reasoning_preset: Option, compact: bool, request_kind: Option, subagent_kind: Option, @@ -517,6 +518,7 @@ fn dashboard_usage_record_data( let user_agent = metadata_string(&record.metadata, &["userAgent", "user_agent"]); let reasoning_effort = metadata_string(&record.metadata, &["reasoningEffort", "reasoning_effort"]); + let reasoning_preset = metadata_string(&record.metadata, &["reasoningPreset"]); let compact = record .metadata .get("compact") @@ -541,6 +543,7 @@ fn dashboard_usage_record_data( client_ip, user_agent, reasoning_effort, + reasoning_preset, compact, request_kind, subagent_kind, diff --git a/backend/src/api/admin/usage_routes.rs b/backend/src/api/admin/usage_routes.rs index 91bf3ce00..0a314ed86 100644 --- a/backend/src/api/admin/usage_routes.rs +++ b/backend/src/api/admin/usage_routes.rs @@ -88,6 +88,7 @@ pub(crate) struct UsageRecordData { client_ip: Option, user_agent: Option, reasoning_effort: Option, + reasoning_preset: Option, compact: bool, request_kind: Option, subagent_kind: Option, @@ -442,6 +443,7 @@ fn usage_record_data( let user_agent = metadata_string(&record.metadata, &["userAgent", "user_agent"]); let reasoning_effort = metadata_string(&record.metadata, &["reasoningEffort", "reasoning_effort"]); + let reasoning_preset = metadata_string(&record.metadata, &["reasoningPreset"]); let compact = record .metadata .get("compact") @@ -463,6 +465,7 @@ fn usage_record_data( client_ip, user_agent, reasoning_effort, + reasoning_preset, compact, request_kind, subagent_kind, diff --git a/backend/src/dispatch/recording.rs b/backend/src/dispatch/recording.rs index 118e82745..2e738055d 100644 --- a/backend/src/dispatch/recording.rs +++ b/backend/src/dispatch/recording.rs @@ -424,6 +424,7 @@ fn response_request_summary( "compact": semantics.compact, "requestKind": semantics.request_kind, "subagentKind": semantics.subagent_kind, + "reasoningPreset": semantics.reasoning_preset, "transport": backend_transport_name(transport), "inputType": json_value_kind(input), "inputItemsCount": input.and_then(Value::as_array).map(Vec::len), diff --git a/backend/src/telemetry/recorder.rs b/backend/src/telemetry/recorder.rs index c8c70fd6d..3c941fbac 100644 --- a/backend/src/telemetry/recorder.rs +++ b/backend/src/telemetry/recorder.rs @@ -439,6 +439,7 @@ pub(crate) fn enrich_response_request_semantics( object.insert("compact".to_string(), Value::Bool(semantics.compact)); insert_trimmed_string(object, "requestKind", semantics.request_kind.as_deref()); insert_trimmed_string(object, "subagentKind", semantics.subagent_kind.as_deref()); + insert_trimmed_string(object, "reasoningPreset", semantics.reasoning_preset); } pub(crate) fn reasoning_effort_from_request(request: &CodexResponsesRequest) -> Option<&str> { diff --git a/backend/src/upstream/openai/protocol/responses.rs b/backend/src/upstream/openai/protocol/responses.rs index 8c727235b..57ce00068 100644 --- a/backend/src/upstream/openai/protocol/responses.rs +++ b/backend/src/upstream/openai/protocol/responses.rs @@ -11,6 +11,10 @@ use crate::{ }, }; +const MULTI_AGENT_MODE_OPEN_TAG: &str = ""; +const MULTI_AGENT_MODE_CLOSE_TAG: &str = ""; +const PROACTIVE_MULTI_AGENT_MODE_PREFIX: &str = "Proactive multi-agent delegation is active."; + /// Codex Responses 上游请求体。 /// /// 发往上游的 Responses 请求。`body` 持有客户端原始 JSON object,逐字段(含顺序、 @@ -97,6 +101,8 @@ pub struct CodexRequestSemantics { pub request_kind: Option, /// Codex turn metadata 中的子代理类型。 pub subagent_kind: Option, + /// Codex 客户端选择的推理预设。 + pub reasoning_preset: Option<&'static str>, /// 请求是否为远端压缩。 pub compact: bool, } @@ -696,7 +702,7 @@ impl CodexResponsesRequest { self.body.get("client_metadata") } - /// 提取 Codex 请求类型、子代理类型与压缩语义。 + /// 提取 Codex 请求类型、子代理类型、推理预设与压缩语义。 pub fn semantics(&self) -> CodexRequestSemantics { let turn_metadata = self .turn_metadata @@ -717,6 +723,16 @@ impl CodexResponsesRequest { .and_then(|metadata| metadata.get("subagent_kind")) .and_then(Value::as_str) .and_then(non_empty_owned_string); + let reasoning_preset = (subagent_kind.is_none() + && self + .reasoning() + .and_then(|value| value.get("effort")) + .and_then(Value::as_str) + == Some("max") + && self + .latest_multi_agent_mode() + .is_some_and(|mode| mode.starts_with(PROACTIVE_MULTI_AGENT_MODE_PREFIX))) + .then_some("ultra"); let compact = request_kind.as_deref() == Some("compaction") || self .input() @@ -726,10 +742,19 @@ impl CodexResponsesRequest { CodexRequestSemantics { request_kind, subagent_kind, + reasoning_preset, compact, } } + fn latest_multi_agent_mode(&self) -> Option<&str> { + latest_multi_agent_mode(self.input()).or_else(|| { + self.local_replay_input + .as_deref() + .and_then(|input| latest_multi_agent_mode(input.as_slice())) + }) + } + /// 设置 / 合并 client metadata。 pub fn set_client_metadata(&mut self, client_metadata: Option) { match client_metadata { @@ -772,3 +797,25 @@ fn non_empty_owned_string(value: &str) -> Option { let value = value.trim(); (!value.is_empty()).then(|| value.to_string()) } + +fn latest_multi_agent_mode(input: &[Value]) -> Option<&str> { + input.iter().rev().find_map(|item| { + if item.get("role").and_then(Value::as_str) != Some("developer") { + return None; + } + item.get("content")? + .as_array()? + .iter() + .rev() + .filter_map(|content| content.get("text").and_then(Value::as_str)) + .find_map(multi_agent_mode_from_text) + }) +} + +fn multi_agent_mode_from_text(text: &str) -> Option<&str> { + let close = text.rfind(MULTI_AGENT_MODE_CLOSE_TAG)?; + let before_close = &text[..close]; + let open = before_close.rfind(MULTI_AGENT_MODE_OPEN_TAG)?; + let body = &before_close[open + MULTI_AGENT_MODE_OPEN_TAG.len()..]; + Some(body.trim()) +} diff --git a/backend/tests/api/admin/dashboard_routes.rs b/backend/tests/api/admin/dashboard_routes.rs index b4ad2c670..eb4a9d5ee 100644 --- a/backend/tests/api/admin/dashboard_routes.rs +++ b/backend/tests/api/admin/dashboard_routes.rs @@ -403,10 +403,12 @@ async fn dashboard_summary_should_return_backend_formatted_time_fields() { ) .await; let record_at = Utc::now() - Duration::seconds(1); - store - .append(&usage_record_with_tokens(record_at, 10)) - .await - .unwrap(); + let mut record = usage_record_with_tokens(record_at, 10); + record.metadata = json!({ + "reasoningEffort": "max", + "reasoningPreset": "ultra" + }); + store.append(&record).await.unwrap(); let body = dashboard_summary(app).await; @@ -414,6 +416,9 @@ async fn dashboard_summary_should_return_backend_formatted_time_fields() { body["data"]["usageRecords"][0]["createdAtDisplay"], china_datetime(&record_at) ); + assert_eq!(body["data"]["usageRecords"][0]["reasoningEffort"], "max"); + assert_eq!(body["data"]["usageRecords"][0]["reasoningPreset"], "ultra"); + assert!(body["data"]["usageRecords"][0]["subagentKind"].is_null()); assert!(body["data"]["usageRecords"][0].get("metadata").is_none()); } diff --git a/backend/tests/api/admin/usage_routes.rs b/backend/tests/api/admin/usage_routes.rs index 826009ef4..21bdf4830 100644 --- a/backend/tests/api/admin/usage_routes.rs +++ b/backend/tests/api/admin/usage_routes.rs @@ -46,10 +46,18 @@ async fn usage_route_returns_success_facts_without_legacy_level() { record.metadata = json!({ "compact": true, "requestKind": "compaction", + "reasoningEffort": "max", + "reasoningPreset": "ultra" + }); + usage.append(&record).await.unwrap(); + let mut subagent = success_record("subagent completed"); + subagent.id = "usage_route_subagent".to_string(); + subagent.client_api_key_id = Some("key_42".to_string()); + subagent.metadata = json!({ "subagentKind": "thread_spawn", "reasoningEffort": "max" }); - usage.append(&record).await.unwrap(); + usage.append(&subagent).await.unwrap(); let response = app .oneshot( @@ -65,16 +73,28 @@ async fn usage_route_returns_success_facts_without_legacy_level() { let body = response_json(response).await; assert_eq!(body["data"]["page"]["page"], 1); assert_eq!(body["data"]["page"]["pageSize"], 50); - assert_eq!(body["data"]["page"]["total"], 1); + assert_eq!(body["data"]["page"]["total"], 2); assert!(body["data"]["page"].get("nextCursor").is_none()); - let item = &body["data"]["items"][0]; + let items = body["data"]["items"].as_array().unwrap(); + let item = items + .iter() + .find(|item| item["id"] == "usage_route_success") + .unwrap(); assert_eq!(item["id"], "usage_route_success"); assert_eq!(item["clientApiKeyId"], "key_42"); assert_eq!(item["statusCode"], 200); assert_eq!(item["compact"], true); assert_eq!(item["requestKind"], "compaction"); - assert_eq!(item["subagentKind"], "thread_spawn"); + assert!(item["subagentKind"].is_null()); assert_eq!(item["reasoningEffort"], "max"); + assert_eq!(item["reasoningPreset"], "ultra"); + let subagent = items + .iter() + .find(|item| item["id"] == "usage_route_subagent") + .unwrap(); + assert_eq!(subagent["subagentKind"], "thread_spawn"); + assert_eq!(subagent["reasoningEffort"], "max"); + assert!(subagent["reasoningPreset"].is_null()); assert!(item.get("level").is_none()); } diff --git a/backend/tests/dispatch/service/usage_logging.rs b/backend/tests/dispatch/service/usage_logging.rs index 9adac3454..20a5192b0 100644 --- a/backend/tests/dispatch/service/usage_logging.rs +++ b/backend/tests/dispatch/service/usage_logging.rs @@ -138,7 +138,15 @@ async fn responses_should_use_imported_account_record_usage_cookie_and_usage_rec .body(Body::from( json!({ "model": "gpt-5.5", - "input": [], + "reasoning": {"effort": "max"}, + "input": [{ + "type": "message", + "role": "developer", + "content": [{ + "type": "input_text", + "text": "Proactive multi-agent delegation is active." + }] + }], "stream": false, "use_websocket": false }) @@ -177,8 +185,9 @@ async fn responses_should_use_imported_account_record_usage_cookie_and_usage_rec assert_eq!(event.account_id.as_deref(), Some("acct_chat")); assert_eq!(event.status_code, Some(200)); assert_eq!(event.response_id.as_deref(), Some("resp_usage")); - assert_eq!(event.response_id.as_deref(), Some("resp_usage")); assert_eq!(metadata["stream"], false); + assert_eq!(metadata["reasoningEffort"], "max"); + assert_eq!(metadata["reasoningPreset"], "ultra"); assert_eq!(event.input_tokens, Some(7)); } diff --git a/backend/tests/upstream/openai/protocol/responses.rs b/backend/tests/upstream/openai/protocol/responses.rs index 95a3fbb93..f7469f12c 100644 --- a/backend/tests/upstream/openai/protocol/responses.rs +++ b/backend/tests/upstream/openai/protocol/responses.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; + use serde_json::Value; use super::*; @@ -447,6 +449,120 @@ fn responses_request_semantics_should_detect_compaction_trigger_without_metadata assert!(request.semantics().compact); } +#[test] +fn responses_request_semantics_should_restore_ultra_from_proactive_mode() { + let body = json!({ + "model": "gpt-5.6-sol", + "reasoning": {"effort": "max"}, + "input": [developer_multi_agent_mode( + "Proactive multi-agent delegation is active. Use sub-agents when useful.", + )] + }); + + let request = build_codex_request(body.as_object().unwrap().clone(), &HeaderMap::new(), None); + + assert_eq!(request.semantics().reasoning_preset, Some("ultra")); + assert_eq!(request.reasoning().unwrap()["effort"], "max"); +} + +#[test] +fn responses_request_semantics_should_use_latest_multi_agent_mode() { + let body = json!({ + "model": "gpt-5.6-sol", + "reasoning": {"effort": "max"}, + "input": [ + developer_multi_agent_mode("Proactive multi-agent delegation is active."), + developer_multi_agent_mode("Do not spawn sub-agents unless explicitly requested."), + ] + }); + + let request = build_codex_request(body.as_object().unwrap().clone(), &HeaderMap::new(), None); + + assert_eq!(request.semantics().reasoning_preset, None); +} + +#[test] +fn responses_request_semantics_should_ignore_user_multi_agent_mode_marker() { + let body = json!({ + "model": "gpt-5.6-sol", + "reasoning": {"effort": "max"}, + "input": [{ + "type": "message", + "role": "user", + "content": [{ + "type": "input_text", + "text": "Proactive multi-agent delegation is active.", + }], + }] + }); + + let request = build_codex_request(body.as_object().unwrap().clone(), &HeaderMap::new(), None); + + assert_eq!(request.semantics().reasoning_preset, None); +} + +#[test] +fn responses_request_semantics_should_require_max_for_ultra() { + let body = json!({ + "model": "gpt-5.6-sol", + "reasoning": {"effort": "high"}, + "input": [developer_multi_agent_mode( + "Proactive multi-agent delegation is active.", + )] + }); + + let request = build_codex_request(body.as_object().unwrap().clone(), &HeaderMap::new(), None); + + assert_eq!(request.semantics().reasoning_preset, None); +} + +#[test] +fn responses_request_semantics_should_not_mark_subagent_as_ultra() { + let mut headers = HeaderMap::new(); + headers.insert( + "x-codex-turn-metadata", + r#"{"subagent_kind":"thread_spawn"}"#.parse().unwrap(), + ); + let body = json!({ + "model": "gpt-5.6-sol", + "reasoning": {"effort": "max"}, + "input": [developer_multi_agent_mode( + "Proactive multi-agent delegation is active.", + )] + }); + + let request = build_codex_request(body.as_object().unwrap().clone(), &headers, None); + + assert_eq!(request.semantics().reasoning_preset, None); +} + +#[test] +fn responses_request_semantics_should_read_mode_from_local_replay_history() { + let body = json!({ + "model": "gpt-5.6-sol", + "reasoning": {"effort": "max"}, + "input": [{"type": "message", "role": "user", "content": []}] + }); + let mut request = + build_codex_request(body.as_object().unwrap().clone(), &HeaderMap::new(), None); + request.local_replay_input = Some(Arc::new(vec![developer_multi_agent_mode( + "Proactive multi-agent delegation is active.", + )])); + + assert_eq!(request.semantics().reasoning_preset, Some("ultra")); +} + +fn developer_multi_agent_mode(mode: &str) -> Value { + json!({ + "type": "message", + "role": "developer", + "content": [{ + "type": "input_text", + "text": format!("{mode}"), + }], + }) +} + #[test] fn openai_streaming_response_with_previous_response_should_require_websocket() { let body = json!({ diff --git a/frontend/src/views/dashboard/components/UsageRecordCard.vue b/frontend/src/views/dashboard/components/UsageRecordCard.vue index 909045a0d..7b1c07b62 100644 --- a/frontend/src/views/dashboard/components/UsageRecordCard.vue +++ b/frontend/src/views/dashboard/components/UsageRecordCard.vue @@ -6,7 +6,6 @@ import BaseTable from '@/components/base/BaseTable/index.vue' import { usageAccountText, usageIsCompact, - usageReasoningEffort, usageRecordColumns, usageRecordType, usageRecordTypeClass, @@ -14,6 +13,7 @@ import { import UsageClientIpCell from '@/views/usage/components/UsageClientIpCell.vue' import UsageBillingCell from '@/views/usage/components/UsageBillingCell.vue' import UsageModelCell from '@/views/usage/components/UsageModelCell.vue' +import UsageReasoningEffortCell from '@/views/usage/components/UsageReasoningEffortCell.vue' import UsageTokenCell from '@/views/usage/components/UsageTokenCell.vue' defineProps<{ @@ -57,9 +57,7 @@ const dashboardUsageRecordColumns = usageRecordColumns.filter((column) => column