From f127b67e731571d4e8c7574be3ef0451582daa62 Mon Sep 17 00:00:00 2001
From: MMEXA
Date: Sun, 12 Jul 2026 20:34:31 +0800
Subject: [PATCH 01/12] fix(openai): encode tool errors in Responses output
---
.../src/formats/conversion/request.rs | 68 ++++++++++++++++++
.../src/formats/openai/responses/mod.rs | 20 ++++++
.../src/formats/openai/responses/request.rs | 71 +++++++++++++++----
.../src/formats/openai/responses/response.rs | 42 +++++++++--
4 files changed, 182 insertions(+), 19 deletions(-)
diff --git a/crates/aether-ai-formats/src/formats/conversion/request.rs b/crates/aether-ai-formats/src/formats/conversion/request.rs
index f339c27dc..993cd67db 100644
--- a/crates/aether-ai-formats/src/formats/conversion/request.rs
+++ b/crates/aether-ai-formats/src/formats/conversion/request.rs
@@ -1024,6 +1024,74 @@ mod tests {
);
}
+ #[test]
+ fn claude_request_to_responses_encodes_error_tool_results_in_output() {
+ let body = json!({
+ "model": "claude-sonnet",
+ "messages": [{
+ "role": "user",
+ "content": [
+ {
+ "type": "tool_result",
+ "tool_use_id": "toolu_error_string",
+ "content": "lookup failed",
+ "is_error": true
+ },
+ {
+ "type": "tool_result",
+ "tool_use_id": "toolu_error_empty",
+ "content": "",
+ "is_error": true
+ },
+ {
+ "type": "tool_result",
+ "tool_use_id": "toolu_error_object",
+ "content": {"code": "ENOENT"},
+ "is_error": true
+ },
+ {
+ "type": "tool_result",
+ "tool_use_id": "toolu_error_image",
+ "content": [
+ {"type": "text", "text": "preview failed"},
+ {
+ "type": "image",
+ "source": {
+ "type": "base64",
+ "media_type": "image/png",
+ "data": "AAAA"
+ }
+ }
+ ],
+ "is_error": true
+ }
+ ]
+ }],
+ "max_tokens": 128,
+ });
+
+ let converted = registry::convert_request(
+ "claude:messages",
+ "openai:responses",
+ &body,
+ &FormatContext::default(),
+ )
+ .expect("responses request");
+ let input = converted["input"].as_array().expect("responses input");
+
+ assert_eq!(input[0]["output"], "[tool error]\nlookup failed");
+ assert_eq!(input[1]["output"], "[tool error]");
+ assert_eq!(input[2]["output"], "[tool error]\n{\"code\":\"ENOENT\"}");
+ assert_eq!(input[3]["output"], "[tool error]\npreview failed");
+ assert_eq!(input[4]["role"], "user");
+ assert_eq!(input[4]["content"][0]["type"], "input_image");
+ assert_eq!(
+ input[4]["content"][0]["image_url"],
+ "data:image/png;base64,AAAA"
+ );
+ assert!(input.iter().all(|item| item.get("is_error").is_none()));
+ }
+
#[test]
fn claude_request_to_responses_rejects_unrepresentable_tool_result_blocks() {
let body = json!({
diff --git a/crates/aether-ai-formats/src/formats/openai/responses/mod.rs b/crates/aether-ai-formats/src/formats/openai/responses/mod.rs
index 1c9d73917..16e73a0c7 100644
--- a/crates/aether-ai-formats/src/formats/openai/responses/mod.rs
+++ b/crates/aether-ai-formats/src/formats/openai/responses/mod.rs
@@ -1,5 +1,25 @@
+use serde_json::Value;
+
pub mod codex;
pub mod request;
pub mod response;
pub mod spec;
pub mod stream;
+
+const TOOL_ERROR_PREFIX: &str = "[tool error]";
+
+fn encode_tool_result_error(output: Value, is_error: bool) -> Value {
+ if !is_error {
+ return output;
+ }
+ let detail = match output {
+ Value::String(text) => text,
+ Value::Null => String::new(),
+ value => serde_json::to_string(&value).unwrap_or_else(|_| value.to_string()),
+ };
+ if detail.is_empty() {
+ Value::String(TOOL_ERROR_PREFIX.to_string())
+ } else {
+ Value::String(format!("{TOOL_ERROR_PREFIX}\n{detail}"))
+ }
+}
diff --git a/crates/aether-ai-formats/src/formats/openai/responses/request.rs b/crates/aether-ai-formats/src/formats/openai/responses/request.rs
index ef70667fc..519a2e505 100644
--- a/crates/aether-ai-formats/src/formats/openai/responses/request.rs
+++ b/crates/aether-ai-formats/src/formats/openai/responses/request.rs
@@ -2,6 +2,8 @@ use std::collections::{BTreeMap, VecDeque};
use serde_json::{json, Map, Value};
+use super::encode_tool_result_error;
+
use crate::{
formats::context::FormatContext,
formats::openai::shared::map_thinking_budget_to_openai_reasoning_effort,
@@ -449,6 +451,7 @@ fn canonical_messages_to_responses_input(canonical: &CanonicalRequest) -> Option
let (tool_output, extra_user_content) = responses_tool_result_payload(
output.as_ref(),
content_text.as_deref(),
+ *is_error,
extensions,
)?;
let call_id =
@@ -464,9 +467,6 @@ fn canonical_messages_to_responses_input(canonical: &CanonicalRequest) -> Option
);
item.insert("call_id".to_string(), Value::String(call_id));
item.insert("output".to_string(), tool_output);
- if *is_error {
- item.insert("is_error".to_string(), Value::Bool(true));
- }
let extension_fields =
openai_responses_item_extension_object(extensions, &item);
item.extend(extension_fields);
@@ -1133,18 +1133,19 @@ fn canonical_tool_choice_to_responses(
fn responses_tool_result_payload(
output: Option<&Value>,
content_text: Option<&str>,
+ is_error: bool,
extensions: &BTreeMap,
) -> Option<(Value, Vec)> {
if let Some(Value::Array(parts)) = output {
if is_claude_tool_result(extensions) {
- return claude_tool_result_parts_to_responses_payload(parts);
+ return claude_tool_result_parts_to_responses_payload(parts, is_error);
}
if let Some(output) = openai_chat_tool_result_parts_to_responses_output(parts) {
- return Some((output, Vec::new()));
+ return Some((encode_tool_result_error(output, is_error), Vec::new()));
}
}
Some((
- responses_tool_result_output(output, content_text),
+ responses_tool_result_output(output, content_text, is_error),
Vec::new(),
))
}
@@ -1260,14 +1261,22 @@ fn openai_chat_tool_result_fallback_part(part: &Value) -> Value {
})
}
-fn responses_tool_result_output(output: Option<&Value>, content_text: Option<&str>) -> Value {
+fn responses_tool_result_output(
+ output: Option<&Value>,
+ content_text: Option<&str>,
+ is_error: bool,
+) -> Value {
let text = match output {
Some(Value::String(text)) => text.clone(),
Some(Value::Null) => String::new(),
Some(value) => serde_json::to_string(value).unwrap_or_default(),
None => content_text.unwrap_or_default().to_string(),
};
- Value::String(non_empty_responses_tool_output(&text))
+ let output = encode_tool_result_error(Value::String(text), is_error);
+ match output {
+ Value::String(text) => Value::String(non_empty_responses_tool_output(&text)),
+ output => output,
+ }
}
pub(crate) fn claude_tool_result_parts_are_openai_responses_representable(parts: &[Value]) -> bool {
@@ -1322,7 +1331,10 @@ fn claude_document_block_is_openai_responses_representable(block: &Map Option<(Value, Vec)> {
+fn claude_tool_result_parts_to_responses_payload(
+ parts: &[Value],
+ is_error: bool,
+) -> Option<(Value, Vec)> {
let mut output_texts = Vec::new();
let mut extra_user_content = Vec::new();
@@ -1365,10 +1377,12 @@ fn claude_tool_result_parts_to_responses_payload(parts: &[Value]) -> Option<(Val
}
}
- Some((
- Value::String(non_empty_responses_tool_output(&output_texts.join("\n\n"))),
- extra_user_content,
- ))
+ let output = encode_tool_result_error(Value::String(output_texts.join("\n\n")), is_error);
+ let output = match output {
+ Value::String(text) => Value::String(non_empty_responses_tool_output(&text)),
+ output => output,
+ };
+ Some((output, extra_user_content))
}
fn claude_image_block_to_responses_input_part(block: &Map) -> Option {
@@ -1661,6 +1675,37 @@ mod tests {
assert_eq!(body["input"][0]["output"], "(empty)");
}
+ #[test]
+ fn responses_request_encodes_tool_errors_for_regular_and_compact_requests() {
+ let request = CanonicalRequest {
+ model: "gpt-5.6-sol".to_string(),
+ messages: vec![CanonicalMessage {
+ role: CanonicalRole::Tool,
+ content: vec![CanonicalContentBlock::ToolResult {
+ tool_use_id: "call_error".to_string(),
+ name: None,
+ output: Some(json!("command failed")),
+ content_text: None,
+ is_error: true,
+ extensions: Default::default(),
+ }],
+ extensions: Default::default(),
+ }],
+ ..CanonicalRequest::default()
+ };
+
+ for compact in [false, true] {
+ let body =
+ to_raw(&request, "gpt-5.6-sol", false, compact).expect("Responses request body");
+ let item = &body["input"][0];
+
+ assert_eq!(item["type"], "function_call_output");
+ assert_eq!(item["call_id"], "call_error");
+ assert_eq!(item["output"], "[tool error]\ncommand failed");
+ assert!(item.get("is_error").is_none());
+ }
+ }
+
#[test]
fn responses_request_replaces_empty_tool_call_identifiers() {
let request = CanonicalRequest {
diff --git a/crates/aether-ai-formats/src/formats/openai/responses/response.rs b/crates/aether-ai-formats/src/formats/openai/responses/response.rs
index 12048cda0..088ccc60d 100644
--- a/crates/aether-ai-formats/src/formats/openai/responses/response.rs
+++ b/crates/aether-ai-formats/src/formats/openai/responses/response.rs
@@ -5,6 +5,8 @@ use std::{
use serde_json::{json, Map, Value};
+use super::encode_tool_result_error;
+
use crate::{
formats::context::FormatContext,
protocol::canonical::{
@@ -270,13 +272,13 @@ pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, compact: bo
item.insert("call_id".to_string(), Value::String(tool_use_id.clone()));
item.insert(
"output".to_string(),
- result_output
- .clone()
- .unwrap_or_else(|| Value::String(content_text.clone().unwrap_or_default())),
+ encode_tool_result_error(
+ result_output.clone().unwrap_or_else(|| {
+ Value::String(content_text.clone().unwrap_or_default())
+ }),
+ *is_error,
+ ),
);
- if *is_error {
- item.insert("is_error".to_string(), Value::Bool(true));
- }
let extension_fields = openai_responses_item_extension_object(extensions, &item);
item.extend(extension_fields);
output.push(Value::Object(item));
@@ -618,6 +620,34 @@ mod tests {
assert_eq!(body["conversation"]["id"], "conv_123");
}
+ #[test]
+ fn responses_response_builder_encodes_tool_errors_in_output() {
+ let response = CanonicalResponse {
+ id: "resp_tool_error".to_string(),
+ model: "gpt-5.6-sol".to_string(),
+ content: vec![CanonicalContentBlock::ToolResult {
+ tool_use_id: "call_error".to_string(),
+ name: None,
+ output: Some(json!("command failed")),
+ content_text: None,
+ is_error: true,
+ extensions: BTreeMap::new(),
+ }],
+ outputs: Vec::new(),
+ stop_reason: Some(CanonicalStopReason::EndTurn),
+ usage: None,
+ extensions: BTreeMap::new(),
+ };
+
+ let body = to_raw(&response, &json!({}), false);
+ let item = &body["output"][0];
+
+ assert_eq!(item["type"], "function_call_output");
+ assert_eq!(item["call_id"], "call_error");
+ assert_eq!(item["output"], "[tool error]\ncommand failed");
+ assert!(item.get("is_error").is_none());
+ }
+
#[test]
fn compact_response_builder_emits_the_compaction_resource_shape() {
let mut extensions = BTreeMap::new();
From b1be370b2e9a34a38b5d0f41b6ca8b0c79fdf1b7 Mon Sep 17 00:00:00 2001
From: MMEXA
Date: Sun, 12 Jul 2026 21:08:30 +0800
Subject: [PATCH 02/12] fix(gateway): scope concurrency test helper to tests
---
apps/aether-gateway/src/main.rs | 1 +
1 file changed, 1 insertion(+)
diff --git a/apps/aether-gateway/src/main.rs b/apps/aether-gateway/src/main.rs
index 68d1413f3..00e15c825 100644
--- a/apps/aether-gateway/src/main.rs
+++ b/apps/aether-gateway/src/main.rs
@@ -286,6 +286,7 @@ fn available_parallelism_usize() -> usize {
.max(1)
}
+#[cfg(test)]
fn automatic_gateway_request_concurrency_for_parallelism(parallelism: usize) -> usize {
automatic_gateway_request_concurrency_for_capacity(parallelism, None)
}
From fc2dfb82d2aad9bf5454ad16c22ddb16d2614087 Mon Sep 17 00:00:00 2001
From: MMEXA
Date: Sun, 12 Jul 2026 23:04:33 +0800
Subject: [PATCH 03/12] fix(codex): preserve reset consume request body
---
.../src/control/tests/admin_endpoints.rs | 19 +++++++++++++++++++
.../src/handlers/shared/request_utils.rs | 5 +++++
2 files changed, 24 insertions(+)
diff --git a/apps/aether-gateway/src/control/tests/admin_endpoints.rs b/apps/aether-gateway/src/control/tests/admin_endpoints.rs
index cc43cdb94..9594c7221 100644
--- a/apps/aether-gateway/src/control/tests/admin_endpoints.rs
+++ b/apps/aether-gateway/src/control/tests/admin_endpoints.rs
@@ -454,6 +454,25 @@ fn classifies_admin_codex_reset_credit_consume_as_admin_proxy_route() {
assert!(!decision.is_execution_runtime_candidate());
}
+#[test]
+fn admin_codex_reset_credit_consume_buffers_idempotency_key_body() {
+ let headers = headers(&[]);
+ let uri: Uri = "/api/admin/endpoints/keys/key-codex/codex-reset-credit/consume"
+ .parse()
+ .expect("uri should parse");
+ let decision = classify_control_route(&http::Method::POST, &uri, &headers)
+ .expect("decision should resolve");
+ let context = GatewayPublicRequestContext::from_request_parts(
+ "trace-codex-reset-credit-consume",
+ &http::Method::POST,
+ &uri,
+ &headers,
+ Some(decision),
+ );
+
+ assert!(local_proxy_route_requires_buffered_body(&context));
+}
+
#[test]
fn admin_refresh_provider_quota_buffers_request_body_for_key_selection() {
let headers = headers(&[]);
diff --git a/apps/aether-gateway/src/handlers/shared/request_utils.rs b/apps/aether-gateway/src/handlers/shared/request_utils.rs
index aba7e9606..9cf01b71b 100644
--- a/apps/aether-gateway/src/handlers/shared/request_utils.rs
+++ b/apps/aether-gateway/src/handlers/shared/request_utils.rs
@@ -221,6 +221,11 @@ pub(crate) fn admin_proxy_local_requires_buffered_body(
| (Some("endpoints_manage"), http::Method::POST, Some("create_endpoint"))
| (Some("endpoints_manage"), http::Method::POST, Some("batch_delete_keys"))
| (Some("endpoints_manage"), http::Method::POST, Some("refresh_quota"))
+ | (
+ Some("endpoints_manage"),
+ http::Method::POST,
+ Some("codex_reset_credit_consume"),
+ )
| (Some("endpoints_manage"), http::Method::PUT, Some("update_key"))
| (Some("endpoints_manage"), http::Method::PUT, Some("update_endpoint"))
| (Some("modules_manage"), http::Method::PUT, Some("set_enabled"))
From e8afa03e454b59890a166e45e5eaf2a8ed02b5cc Mon Sep 17 00:00:00 2001
From: MMEXA
Date: Mon, 13 Jul 2026 06:07:54 +0800
Subject: [PATCH 04/12] =?UTF-8?q?fix(auth):=20=E6=8E=88=E6=9D=83=20Respons?=
=?UTF-8?q?es=20Compact=20=E4=BC=B4=E9=9A=8F=E7=AB=AF=E7=82=B9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
crates/aether-ai-formats/src/formats/id.rs | 30 +++++++++++++++++--
.../aether-data/src/repository/auth/types.rs | 16 +++++++++-
2 files changed, 43 insertions(+), 3 deletions(-)
diff --git a/crates/aether-ai-formats/src/formats/id.rs b/crates/aether-ai-formats/src/formats/id.rs
index fc83ffdc4..c45c8f66e 100644
--- a/crates/aether-ai-formats/src/formats/id.rs
+++ b/crates/aether-ai-formats/src/formats/id.rs
@@ -170,7 +170,11 @@ pub fn api_format_permission_covers(allowed_value: &str, requested_api_format: &
!allowed_value.is_empty()
&& !requested_api_format.is_empty()
&& (allowed_value == requested_api_format
- || allowed_value == "openai:responses" && requested_api_format == "openai:search")
+ || allowed_value == "openai:responses"
+ && matches!(
+ requested_api_format.as_str(),
+ "openai:responses:compact" | "openai:search"
+ ))
}
pub fn intersect_api_format_allowed_lists(left: &[String], right: &[String]) -> Vec {
@@ -269,11 +273,15 @@ mod tests {
}
#[test]
- fn responses_permission_covers_only_its_search_companion() {
+ fn responses_permission_covers_its_companion_endpoints() {
assert!(api_format_permission_covers(
"OPENAI:RESPONSES",
"openai:search"
));
+ assert!(api_format_permission_covers(
+ "OPENAI:RESPONSES",
+ "openai:responses:compact"
+ ));
assert!(api_format_permission_covers(
"openai:search",
"openai:search"
@@ -282,6 +290,10 @@ mod tests {
"openai:search",
"openai:responses"
));
+ assert!(!api_format_permission_covers(
+ "openai:responses:compact",
+ "openai:responses"
+ ));
assert!(!api_format_permission_covers(
"openai:responses",
"openai:chat"
@@ -290,6 +302,13 @@ mod tests {
api_format_permission_storage_aliases("openai:search"),
vec!["openai:search".to_string(), "openai:responses".to_string()]
);
+ assert_eq!(
+ api_format_permission_storage_aliases("openai:responses:compact"),
+ vec![
+ "openai:responses:compact".to_string(),
+ "openai:responses".to_string(),
+ ]
+ );
assert_eq!(
api_format_permission_storage_aliases("openai:responses"),
vec!["openai:responses".to_string()]
@@ -350,6 +369,13 @@ mod tests {
),
vec!["openai:search".to_string()]
);
+ assert_eq!(
+ intersect_api_format_allowed_lists(
+ &["openai:responses".to_string()],
+ &["openai:responses:compact".to_string()],
+ ),
+ vec!["openai:responses:compact".to_string()]
+ );
assert_eq!(
intersect_api_format_allowed_lists(
&["openai:search".to_string()],
diff --git a/crates/aether-data/src/repository/auth/types.rs b/crates/aether-data/src/repository/auth/types.rs
index f3b7039b4..899e2c4fe 100644
--- a/crates/aether-data/src/repository/auth/types.rs
+++ b/crates/aether-data/src/repository/auth/types.rs
@@ -827,7 +827,7 @@ mod tests {
};
#[test]
- fn api_format_policy_intersection_preserves_search_companion_scope() {
+ fn api_format_policy_intersection_preserves_companion_scope() {
assert_eq!(
aether_ai_formats::intersect_api_format_allowed_lists(
&["openai:search".to_string()],
@@ -842,6 +842,20 @@ mod tests {
),
vec!["openai:search".to_string()]
);
+ assert_eq!(
+ aether_ai_formats::intersect_api_format_allowed_lists(
+ &["openai:responses".to_string()],
+ &["openai:responses:compact".to_string()],
+ ),
+ vec!["openai:responses:compact".to_string()]
+ );
+ assert_eq!(
+ aether_ai_formats::intersect_api_format_allowed_lists(
+ &["openai:responses:compact".to_string()],
+ &["openai:responses".to_string()],
+ ),
+ vec!["openai:responses:compact".to_string()]
+ );
assert!(aether_ai_formats::intersect_api_format_allowed_lists(
&["openai:search".to_string()],
&["openai:chat".to_string()],
From 2fc604e047a63103232e0066780f24556c0bb36f Mon Sep 17 00:00:00 2001
From: MMEXA
Date: Mon, 13 Jul 2026 21:43:18 +0800
Subject: [PATCH 05/12] =?UTF-8?q?feat(codex):=20=E6=8C=89=E6=93=8D?=
=?UTF-8?q?=E4=BD=9C=E8=AF=AD=E4=B9=89=E8=B7=AF=E7=94=B1=20Responses=20V2?=
=?UTF-8?q?=20=E5=8E=8B=E7=BC=A9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../planner/candidate_materialization.rs | 6 +
.../ai_serving/planner/candidate_source.rs | 112 ++++++++++--
.../planner/standard/family/candidates.rs | 3 +
.../standard/openai/chat/decision/support.rs | 1 +
.../standard/openai/chat/plans/candidates.rs | 1 +
.../openai/responses/decision/support.rs | 9 +-
.../src/ai_serving/planner/state/scheduler.rs | 35 +++-
.../aether-gateway/src/ai_serving/pure/mod.rs | 2 +-
.../src/cache/candidate_page.rs | 31 ++++
apps/aether-gateway/src/control/auth/gate.rs | 2 +
.../src/data/candidate_selection.rs | 24 +++
apps/aether-gateway/src/data/tests.rs | 2 +
.../query/models/model_test/model_mapping.rs | 18 ++
.../src/handlers/admin/request/models.rs | 9 +-
.../handlers/public/support/user_me_usage.rs | 2 +
.../src/scheduler/candidate/enumeration.rs | 6 +-
.../src/scheduler/candidate/mod.rs | 37 ++++
.../src/scheduler/candidate/selection.rs | 4 +
.../src/scheduler/candidate/tests/affinity.rs | 3 +
.../src/scheduler/candidate/tests/model.rs | 6 +
.../scheduler/candidate/tests/selection.rs | 5 +
.../src/scheduler/candidate/tests/support.rs | 2 +
apps/aether-gateway/src/state/catalog.rs | 1 +
apps/aether-gateway/src/testkit.rs | 1 +
.../src/tests/ai_execute/finalize_local.rs | 6 +
.../ai_execute/finalize_local_cli/compact.rs | 1 +
.../finalize_local_cli/cross_format.rs | 3 +
.../ai_execute/finalize_local_cli/direct.rs | 2 +
.../finalize_local_provider/claude.rs | 3 +
.../finalize_local_provider/gemini.rs | 4 +
.../src/tests/ai_execute/lifecycle.rs | 1 +
.../src/tests/ai_execute/stream/decision.rs | 5 +
.../src/tests/ai_execute/stream/image.rs | 3 +
.../tests/ai_execute/stream/pii_redaction.rs | 1 +
.../tests/ai_execute/stream_cli/compact.rs | 1 +
.../src/tests/ai_execute/stream_cli/direct.rs | 1 +
.../src/tests/ai_execute/stream_provider.rs | 4 +
.../stream_provider_gemini/local_chat.rs | 1 +
.../stream_provider_gemini/local_cli.rs | 4 +
.../tests/ai_execute/sync/chat/failover.rs | 4 +
.../ai_execute/sync/chat/local_decision.rs | 11 ++
.../ai_execute/sync/chat/pii_redaction.rs | 1 +
.../ai_execute/sync/claude/claude_code.rs | 1 +
.../src/tests/ai_execute/sync/claude/kiro.rs | 2 +
.../ai_execute/sync/claude/local_chat.rs | 2 +
.../tests/ai_execute/sync/claude/local_cli.rs | 3 +
.../src/tests/ai_execute/sync/cli.rs | 9 +
.../src/tests/ai_execute/sync/gemini/cli.rs | 5 +
.../ai_execute/sync/gemini/local_chat.rs | 2 +
.../src/tests/ai_execute/sync/image.rs | 4 +
.../ai_execute/sync/pii_redaction_formats.rs | 1 +
.../src/tests/ai_execute/sync/search.rs | 1 +
apps/aether-gateway/src/tests/audit.rs | 1 +
apps/aether-gateway/src/tests/files/mod.rs | 1 +
apps/aether-gateway/src/tests/frontdoor.rs | 1 +
apps/aether-gateway/src/tests/frontdoor/ai.rs | 1 +
apps/aether-gateway/src/tests/usage.rs | 1 +
apps/aether-gateway/src/tests/usage/local.rs | 2 +
.../aether-gateway/src/tests/usage/pricing.rs | 1 +
.../src/tests/video/gemini_sync_create.rs | 1 +
.../src/tests/video/openai_sync_create.rs | 1 +
.../aether-admin/src/observability/usage.rs | 2 +
.../src/formats/openai/responses/mod.rs | 66 +++++++
crates/aether-ai-formats/src/lib.rs | 3 +
.../repository/candidate_selection/types.rs | 4 +
.../repository/candidate_selection/memory.rs | 3 +
.../repository/candidate_selection/mysql.rs | 17 ++
.../candidate_selection/postgres.rs | 23 ++-
.../repository/candidate_selection/sqlite.rs | 27 ++-
.../src/candidate/enumeration.rs | 4 +-
.../src/candidate/mod.rs | 5 +
.../src/candidate/types.rs | 1 +
crates/aether-scheduler-core/src/lib.rs | 11 +-
crates/aether-scheduler-core/src/model.rs | 169 +++++++++++++++---
crates/aether-usage-runtime/src/write.rs | 29 ++-
75 files changed, 732 insertions(+), 50 deletions(-)
diff --git a/apps/aether-gateway/src/ai_serving/planner/candidate_materialization.rs b/apps/aether-gateway/src/ai_serving/planner/candidate_materialization.rs
index 64fce324d..1648ada25 100644
--- a/apps/aether-gateway/src/ai_serving/planner/candidate_materialization.rs
+++ b/apps/aether-gateway/src/ai_serving/planner/candidate_materialization.rs
@@ -706,6 +706,7 @@ pub(crate) async fn build_lazy_requested_model_execution_candidate_attempt_sourc
trace_id: &str,
client_api_format: &str,
requested_model: &str,
+ request_operation: Option<&str>,
require_streaming: bool,
auth_snapshot: &GatewayAuthApiKeySnapshot,
client_session_affinity: Option<&ClientSessionAffinity>,
@@ -734,6 +735,7 @@ where
model_directive_policy,
client_api_format,
requested_model,
+ request_operation,
require_streaming,
required_capabilities,
auth_snapshot,
@@ -1141,6 +1143,7 @@ async fn resolve_priority_candidate_page_with_cache(
let key = CandidateResolvedPageCacheKey::new(
&cursor.requested_model,
+ cursor.page_cursor.resolved_page_cache_request_operation(),
&cursor.client_api_format,
true,
&cursor.auth_snapshot,
@@ -2190,6 +2193,7 @@ mod tests {
&model_directive_policy,
"openai:chat",
"gpt-5",
+ None,
true,
None,
&auth_snapshot,
@@ -2245,6 +2249,7 @@ mod tests {
&model_directive_policy,
"openai:chat",
"gpt-5",
+ None,
true,
None,
&auth_snapshot,
@@ -2282,6 +2287,7 @@ mod tests {
&model_directive_policy,
"openai:chat",
"gpt-5",
+ None,
true,
None,
&auth_snapshot,
diff --git a/apps/aether-gateway/src/ai_serving/planner/candidate_source.rs b/apps/aether-gateway/src/ai_serving/planner/candidate_source.rs
index 1ff3fc292..ba48d33eb 100644
--- a/apps/aether-gateway/src/ai_serving/planner/candidate_source.rs
+++ b/apps/aether-gateway/src/ai_serving/planner/candidate_source.rs
@@ -6,9 +6,10 @@ use aether_routing_core::ResolvedRoutingPolicy;
use aether_runtime::ConcurrencyPermit;
use aether_scheduler_core::{
enumerate_minimal_candidate_selection_with_model_directives, normalize_api_format,
- resolve_requested_global_model_name_with_model_directives,
- row_supports_requested_model_with_model_directives, ClientSessionAffinity,
- EnumerateMinimalCandidateSelectionInput, SchedulerMinimalCandidateSelectionCandidate,
+ resolve_requested_global_model_name_with_model_directives_and_request_operation,
+ row_supports_requested_model_with_model_directives_and_request_operation,
+ ClientSessionAffinity, EnumerateMinimalCandidateSelectionInput,
+ SchedulerMinimalCandidateSelectionCandidate,
};
use async_trait::async_trait;
use std::collections::{BTreeMap, BTreeSet, VecDeque};
@@ -56,6 +57,7 @@ struct GatewayLocalCandidatePreselectionPort<'a> {
state: PlannerAppState<'a>,
client_api_format: &'a str,
requested_model: &'a str,
+ request_operation: Option<&'a str>,
require_streaming: bool,
required_capabilities: Option<&'a serde_json::Value>,
auth_snapshot: &'a GatewayAuthApiKeySnapshot,
@@ -112,7 +114,7 @@ impl AiCandidatePreselectionPort for GatewayLocalCandidatePreselectionPort<'_> {
let auth_snapshot = matches_client_format.then_some(self.auth_snapshot);
let (candidates, skipped_candidates) = self
.state
- .list_selectable_candidates_with_skip_reasons(
+ .list_selectable_candidates_with_skip_reasons_for_request_operation(
candidate_api_format,
self.routing_model(candidate_api_format),
self.require_streaming,
@@ -121,6 +123,7 @@ impl AiCandidatePreselectionPort for GatewayLocalCandidatePreselectionPort<'_> {
self.client_session_affinity,
self.ranking_seed,
false,
+ self.request_operation,
)
.await?;
@@ -197,6 +200,7 @@ pub(crate) async fn preselect_local_execution_candidates_with_serving(
model_directive_policy: &crate::system_features::ModelDirectivePolicySnapshot,
client_api_format: &str,
requested_model: &str,
+ request_operation: Option<&str>,
require_streaming: bool,
required_capabilities: Option<&serde_json::Value>,
auth_snapshot: &GatewayAuthApiKeySnapshot,
@@ -221,6 +225,7 @@ pub(crate) async fn preselect_local_execution_candidates_with_serving(
model_directive_policy,
client_api_format,
requested_model,
+ request_operation,
require_streaming,
required_capabilities,
auth_snapshot,
@@ -239,6 +244,7 @@ pub(crate) async fn preselect_local_execution_candidates_for_api_formats_with_se
model_directive_policy: &crate::system_features::ModelDirectivePolicySnapshot,
client_api_format: &str,
requested_model: &str,
+ request_operation: Option<&str>,
require_streaming: bool,
required_capabilities: Option<&serde_json::Value>,
auth_snapshot: &GatewayAuthApiKeySnapshot,
@@ -263,6 +269,7 @@ pub(crate) async fn preselect_local_execution_candidates_for_api_formats_with_se
state,
client_api_format,
requested_model,
+ request_operation,
require_streaming,
required_capabilities,
auth_snapshot,
@@ -283,6 +290,7 @@ pub(crate) struct LocalCandidatePreselectionPageCursor<'a> {
trace_id: String,
client_api_format: String,
requested_model: String,
+ request_operation: Option,
require_streaming: bool,
required_capabilities: Option,
auth_snapshot: GatewayAuthApiKeySnapshot,
@@ -336,6 +344,7 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> {
model_directive_policy: &crate::system_features::ModelDirectivePolicySnapshot,
client_api_format: &str,
requested_model: &str,
+ request_operation: Option<&str>,
require_streaming: bool,
required_capabilities: Option<&serde_json::Value>,
auth_snapshot: &GatewayAuthApiKeySnapshot,
@@ -370,6 +379,7 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> {
trace_id: trace_id.unwrap_or_default().to_string(),
client_api_format: client_api_format.to_string(),
requested_model: requested_model.to_string(),
+ request_operation: request_operation.map(str::to_string),
require_streaming,
required_capabilities: required_capabilities.cloned(),
auth_snapshot: auth_snapshot.clone(),
@@ -452,6 +462,10 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> {
self.key_mode.cache_key_name()
}
+ pub(crate) fn resolved_page_cache_request_operation(&self) -> Option<&str> {
+ self.request_operation.as_deref()
+ }
+
pub(crate) fn resolved_page_cache_use_api_format_alias_match(&self) -> bool {
self.use_api_format_alias_match
}
@@ -515,6 +529,7 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> {
> {
let key = CandidatePageCacheKey::new(
&self.requested_model,
+ self.request_operation.as_deref(),
&self.client_api_format,
self.require_streaming,
&self.auth_snapshot,
@@ -965,11 +980,12 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> {
.map_err(|err| GatewayError::Internal(err.to_string()))?
.into_iter()
.filter(|row| {
- row_supports_requested_model_with_model_directives(
+ row_supports_requested_model_with_model_directives_and_request_operation(
row,
&routing_model,
normalized_api_format,
false,
+ self.request_operation.as_deref(),
)
})
.collect::>();
@@ -1009,12 +1025,15 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> {
if let Some(value) = self.resolved_global_model_names.get(normalized_api_format) {
value.clone()
} else {
- let Some(value) = resolve_requested_global_model_name_with_model_directives(
- &rows,
- &routing_model,
- normalized_api_format,
- false,
- ) else {
+ let Some(value) =
+ resolve_requested_global_model_name_with_model_directives_and_request_operation(
+ &rows,
+ &routing_model,
+ normalized_api_format,
+ false,
+ self.request_operation.as_deref(),
+ )
+ else {
return Ok(None);
};
self.resolved_global_model_names
@@ -1037,6 +1056,7 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> {
EnumerateMinimalCandidateSelectionInput {
rows,
normalized_api_format,
+ request_operation: self.request_operation.as_deref(),
requested_model_name: &routing_model,
resolved_global_model_name: resolved_global_model_name.as_str(),
require_streaming: self.require_streaming,
@@ -1316,6 +1336,7 @@ mod tests {
&model_directive_policy,
"openai:chat",
"gpt-5",
+ None,
true,
None,
&auth_snapshot,
@@ -1531,6 +1552,7 @@ mod tests {
priority: 1,
api_formats: None,
endpoint_ids: Some(vec!["endpoint-opg-openai".to_string()]),
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -1561,6 +1583,7 @@ mod tests {
&model_directive_policy,
"claude:messages",
"gpt-5.5-xhigh",
+ None,
false,
None,
&auth_snapshot,
@@ -1590,6 +1613,70 @@ mod tests {
);
}
+ #[tokio::test]
+ async fn paged_preselection_prefers_operation_scoped_mapping_for_compaction() {
+ let mut row = openai_responses_mapping_row();
+ row.global_model_mappings = None;
+ row.global_model_name = "gpt-5.6-sol".to_string();
+ row.model_provider_model_name = "gpt-5.6-sol".to_string();
+ row.model_provider_model_mappings = Some(vec![
+ StoredProviderModelMapping {
+ name: "gpt-5.6-sol".to_string(),
+ priority: 1,
+ api_formats: Some(vec!["openai:responses".to_string()]),
+ endpoint_ids: None,
+ operations: None,
+ },
+ StoredProviderModelMapping {
+ name: "gpt-5.6-terra".to_string(),
+ priority: 1,
+ api_formats: Some(vec!["openai:responses".to_string()]),
+ endpoint_ids: None,
+ operations: Some(vec!["compact".to_string()]),
+ },
+ ]);
+ let repository: Arc =
+ Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed([row]));
+ let data_state =
+ GatewayDataState::with_minimal_candidate_selection_reader_for_tests(repository);
+ let app = AppState::new()
+ .expect("gateway state should build")
+ .with_data_state_for_tests(data_state);
+ let auth_snapshot = unrestricted_auth_snapshot();
+ let model_directive_policy =
+ crate::system_features::ModelDirectivePolicySnapshot::load(&app).await;
+ let mut cursor = LocalCandidatePreselectionPageCursor::new(
+ PlannerAppState::new(&app),
+ &model_directive_policy,
+ "openai:responses",
+ "gpt-5.6-sol",
+ Some("compact"),
+ false,
+ None,
+ &auth_snapshot,
+ None,
+ None,
+ None,
+ true,
+ LocalCandidatePreselectionKeyMode::ProviderEndpointKeyModelAndApiFormat,
+ true,
+ None,
+ )
+ .await;
+
+ let page = cursor
+ .next_page()
+ .await
+ .expect("preselection should succeed")
+ .expect("compact mapping should find a provider");
+
+ assert_eq!(page.candidates.len(), 1);
+ assert_eq!(
+ page.candidates[0].selected_provider_model_name,
+ "gpt-5.6-terra"
+ );
+ }
+
#[tokio::test]
async fn custom_policy_suffix_uses_the_same_base_model_for_candidate_selection() {
let mut row = openai_responses_mapping_row();
@@ -1634,6 +1721,7 @@ mod tests {
&model_directive_policy,
"openai:responses",
"deployment-alias-VendorFuture",
+ None,
false,
None,
&auth_snapshot,
@@ -1695,6 +1783,7 @@ mod tests {
&model_directive_policy,
"claude:messages",
"deepseek-v4-pro",
+ None,
false,
None,
&auth_snapshot,
@@ -1773,6 +1862,7 @@ mod tests {
&model_directive_policy,
"claude:messages",
"gpt-5",
+ None,
false,
None,
&auth_snapshot,
diff --git a/apps/aether-gateway/src/ai_serving/planner/standard/family/candidates.rs b/apps/aether-gateway/src/ai_serving/planner/standard/family/candidates.rs
index 8cea21bd6..ffe92f270 100644
--- a/apps/aether-gateway/src/ai_serving/planner/standard/family/candidates.rs
+++ b/apps/aether-gateway/src/ai_serving/planner/standard/family/candidates.rs
@@ -124,6 +124,7 @@ pub(super) async fn materialize_local_standard_candidate_attempts(
&input.model_directive_policy,
spec_metadata.api_format,
&input.requested_model,
+ None,
false,
input.required_capabilities.as_ref(),
&input.auth_snapshot,
@@ -250,6 +251,7 @@ pub(super) async fn build_local_standard_candidate_attempt_source<'a>(
trace_id,
spec_metadata.api_format,
&input.requested_model,
+ None,
spec_metadata.require_streaming,
&input.auth_snapshot,
input.client_session_affinity.as_ref(),
@@ -345,6 +347,7 @@ async fn maybe_append_gemini_image_openai_image_preselection(
&input.model_directive_policy,
spec_metadata.api_format,
&input.requested_model,
+ None,
spec_metadata.require_streaming,
input.required_capabilities.as_ref(),
&input.auth_snapshot,
diff --git a/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/decision/support.rs b/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/decision/support.rs
index f57237ec2..4a61a6484 100644
--- a/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/decision/support.rs
+++ b/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/decision/support.rs
@@ -297,6 +297,7 @@ pub(crate) async fn build_lazy_local_openai_chat_candidate_attempt_source<'a>(
trace_id,
"openai:chat",
&input.requested_model,
+ None,
require_streaming,
&input.auth_snapshot,
input.client_session_affinity.as_ref(),
diff --git a/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/plans/candidates.rs b/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/plans/candidates.rs
index e0db3b2b1..0b644d19a 100644
--- a/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/plans/candidates.rs
+++ b/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/plans/candidates.rs
@@ -24,6 +24,7 @@ pub(crate) async fn list_local_openai_chat_candidates(
&input.model_directive_policy,
"openai:chat",
&input.requested_model,
+ None,
require_streaming,
input.required_capabilities.as_ref(),
&input.auth_snapshot,
diff --git a/apps/aether-gateway/src/ai_serving/planner/standard/openai/responses/decision/support.rs b/apps/aether-gateway/src/ai_serving/planner/standard/openai/responses/decision/support.rs
index c44d3782c..3ad3674c0 100644
--- a/apps/aether-gateway/src/ai_serving/planner/standard/openai/responses/decision/support.rs
+++ b/apps/aether-gateway/src/ai_serving/planner/standard/openai/responses/decision/support.rs
@@ -31,8 +31,8 @@ use crate::ai_serving::planner::spec_metadata::local_openai_responses_spec_metad
use crate::ai_serving::planner::CandidateFailureDiagnostic;
use crate::ai_serving::{
ai_local_execution_contract_for_formats, extract_pool_sticky_session_token,
- resolve_local_decision_execution_runtime_auth_context, ExecutionRuntimeAuthContext,
- GatewayControlDecision, PlannerAppState,
+ openai_responses_request_operation, resolve_local_decision_execution_runtime_auth_context,
+ ExecutionRuntimeAuthContext, GatewayControlDecision, PlannerAppState,
};
use crate::client_session_affinity::client_session_affinity_from_parts;
use crate::{AppState, GatewayError};
@@ -163,6 +163,7 @@ pub(crate) async fn materialize_local_openai_responses_candidate_attempts(
spec: LocalOpenAiResponsesSpec,
) -> Result<(Vec, usize), GatewayError> {
let spec_metadata = local_openai_responses_spec_metadata(spec);
+ let request_operation = openai_responses_request_operation(spec_metadata.api_format, body_json);
let planner_state = PlannerAppState::new(state);
let sticky_session_token = extract_pool_sticky_session_token(body_json);
let auth_context: &ExecutionRuntimeAuthContext = &input.auth_context;
@@ -176,6 +177,7 @@ pub(crate) async fn materialize_local_openai_responses_candidate_attempts(
&input.model_directive_policy,
spec_metadata.api_format,
&input.requested_model,
+ request_operation,
spec_metadata.require_streaming,
input.required_capabilities.as_ref(),
&input.auth_snapshot,
@@ -262,6 +264,7 @@ pub(crate) async fn build_local_openai_responses_candidate_attempt_source<'a>(
spec: LocalOpenAiResponsesSpec,
) -> Result<(LocalOpenAiResponsesCandidateAttemptSource<'a>, usize), GatewayError> {
let spec_metadata = local_openai_responses_spec_metadata(spec);
+ let request_operation = openai_responses_request_operation(spec_metadata.api_format, body_json);
let planner_state = PlannerAppState::new(state);
let sticky_session_token = extract_pool_sticky_session_token(body_json);
let auth_context: &ExecutionRuntimeAuthContext = &input.auth_context;
@@ -287,6 +290,7 @@ pub(crate) async fn build_local_openai_responses_candidate_attempt_source<'a>(
trace_id,
spec_metadata.api_format,
&input.requested_model,
+ request_operation,
spec_metadata.require_streaming,
&input.auth_snapshot,
input.client_session_affinity.as_ref(),
@@ -372,6 +376,7 @@ pub(crate) async fn build_local_openai_responses_image_candidate_attempt_source<
&input.model_directive_policy,
spec_metadata.api_format,
&input.requested_model,
+ None,
false,
input.required_capabilities.as_ref(),
&input.auth_snapshot,
diff --git a/apps/aether-gateway/src/ai_serving/planner/state/scheduler.rs b/apps/aether-gateway/src/ai_serving/planner/state/scheduler.rs
index 22571c73b..916307790 100644
--- a/apps/aether-gateway/src/ai_serving/planner/state/scheduler.rs
+++ b/apps/aether-gateway/src/ai_serving/planner/state/scheduler.rs
@@ -53,13 +53,45 @@ impl<'a> PlannerAppState<'a> {
Vec,
),
GatewayError,
+ > {
+ self.list_selectable_candidates_with_skip_reasons_for_request_operation(
+ api_format,
+ global_model_name,
+ require_streaming,
+ required_capabilities,
+ auth_snapshot,
+ client_session_affinity,
+ now_unix_secs,
+ enable_model_directives,
+ None,
+ )
+ .await
+ }
+
+ pub(crate) async fn list_selectable_candidates_with_skip_reasons_for_request_operation(
+ self,
+ api_format: &str,
+ global_model_name: &str,
+ require_streaming: bool,
+ required_capabilities: Option<&serde_json::Value>,
+ auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
+ client_session_affinity: Option<&ClientSessionAffinity>,
+ now_unix_secs: u64,
+ enable_model_directives: bool,
+ request_operation: Option<&str>,
+ ) -> Result<
+ (
+ Vec,
+ Vec,
+ ),
+ GatewayError,
> {
let wait_timeout = Duration::from_millis(API_KEY_CONCURRENCY_WAIT_TIMEOUT_MS);
let wait_interval = Duration::from_millis(API_KEY_CONCURRENCY_WAIT_POLL_INTERVAL_MS.max(1));
let wait_deadline = Instant::now() + wait_timeout;
let mut attempt_now_unix_secs = now_unix_secs;
loop {
- let result = crate::scheduler::candidate::list_selectable_candidates_with_skip_reasons(
+ let result = crate::scheduler::candidate::list_selectable_candidates_with_skip_reasons_for_request_operation(
self.app().data.as_ref(),
self.app(),
api_format,
@@ -70,6 +102,7 @@ impl<'a> PlannerAppState<'a> {
client_session_affinity,
attempt_now_unix_secs,
enable_model_directives,
+ request_operation,
)
.await?;
diff --git a/apps/aether-gateway/src/ai_serving/pure/mod.rs b/apps/aether-gateway/src/ai_serving/pure/mod.rs
index c1a22da71..585ef13ad 100644
--- a/apps/aether-gateway/src/ai_serving/pure/mod.rs
+++ b/apps/aether-gateway/src/ai_serving/pure/mod.rs
@@ -165,5 +165,5 @@ pub(crate) use aether_ai_formats::api::{
pub(crate) use aether_ai_formats::{
api_format_defaults_to_client_error_failover, api_format_defaults_to_non_stream,
api_format_permission_covers, intersect_api_format_allowed_lists, is_embedding_api_format,
- is_rerank_api_format,
+ is_rerank_api_format, openai_responses_request_operation,
};
diff --git a/apps/aether-gateway/src/cache/candidate_page.rs b/apps/aether-gateway/src/cache/candidate_page.rs
index 0c22546bf..1b6323e74 100644
--- a/apps/aether-gateway/src/cache/candidate_page.rs
+++ b/apps/aether-gateway/src/cache/candidate_page.rs
@@ -82,6 +82,7 @@ pub(crate) struct CandidateRowPageCacheKey {
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) struct CandidatePageCacheKey {
requested_model: String,
+ request_operation: String,
client_api_format: String,
auth_identity: CandidatePageAuthIdentity,
require_streaming: bool,
@@ -131,6 +132,7 @@ impl CandidatePageCacheKey {
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
requested_model: &str,
+ request_operation: Option<&str>,
client_api_format: &str,
require_streaming: bool,
auth_snapshot: &GatewayAuthApiKeySnapshot,
@@ -145,6 +147,7 @@ impl CandidatePageCacheKey {
) -> Self {
Self {
requested_model: normalize_text_key(requested_model),
+ request_operation: normalize_text_key(request_operation.unwrap_or_default()),
client_api_format: normalize_api_format(client_api_format),
auth_identity: CandidatePageAuthIdentity::from_auth_snapshot(auth_snapshot),
require_streaming,
@@ -164,6 +167,7 @@ impl CandidateResolvedPageCacheKey {
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
requested_model: &str,
+ request_operation: Option<&str>,
client_api_format: &str,
require_streaming: bool,
auth_snapshot: &GatewayAuthApiKeySnapshot,
@@ -180,6 +184,7 @@ impl CandidateResolvedPageCacheKey {
Self {
page_key: CandidatePageCacheKey::new(
requested_model,
+ request_operation,
client_api_format,
require_streaming,
auth_snapshot,
@@ -564,6 +569,7 @@ mod tests {
let auth_b = auth_snapshot("user-b", "key-a");
let base = CandidatePageCacheKey::new(
"gpt-4o",
+ None,
"openai:chat",
true,
&auth_a,
@@ -578,6 +584,7 @@ mod tests {
);
let different_user = CandidatePageCacheKey::new(
"gpt-4o",
+ None,
"openai:chat",
true,
&auth_b,
@@ -592,6 +599,22 @@ mod tests {
);
let different_model = CandidatePageCacheKey::new(
"gpt-4.1",
+ None,
+ "openai:chat",
+ true,
+ &auth_a,
+ Some(&json!({"vision": true})),
+ None,
+ Some("bearer"),
+ 7,
+ "provider_endpoint_key_model",
+ true,
+ None,
+ "policy-a",
+ );
+ let different_operation = CandidatePageCacheKey::new(
+ "gpt-4o",
+ Some("compact"),
"openai:chat",
true,
&auth_a,
@@ -606,6 +629,7 @@ mod tests {
);
let different_format = CandidatePageCacheKey::new(
"gpt-4o",
+ None,
"openai:responses",
true,
&auth_a,
@@ -620,6 +644,7 @@ mod tests {
);
let different_capabilities = CandidatePageCacheKey::new(
"gpt-4o",
+ None,
"openai:chat",
true,
&auth_a,
@@ -634,6 +659,7 @@ mod tests {
);
let same_policy = CandidatePageCacheKey::new(
"gpt-4o",
+ None,
"openai:chat",
true,
&auth_a,
@@ -648,6 +674,7 @@ mod tests {
);
let different_policy = CandidatePageCacheKey::new(
"gpt-4o",
+ None,
"openai:chat",
true,
&auth_a,
@@ -664,12 +691,14 @@ mod tests {
assert_eq!(base, same_policy);
assert_ne!(base, different_user);
assert_ne!(base, different_model);
+ assert_ne!(base, different_operation);
assert_ne!(base, different_format);
assert_ne!(base, different_capabilities);
assert_ne!(base, different_policy);
let resolved_base = CandidateResolvedPageCacheKey::new(
"gpt-4o",
+ None,
"openai:chat",
true,
&auth_a,
@@ -685,6 +714,7 @@ mod tests {
);
let resolved_same_policy = CandidateResolvedPageCacheKey::new(
"gpt-4o",
+ None,
"openai:chat",
true,
&auth_a,
@@ -700,6 +730,7 @@ mod tests {
);
let resolved_different_policy = CandidateResolvedPageCacheKey::new(
"gpt-4o",
+ None,
"openai:chat",
true,
&auth_a,
diff --git a/apps/aether-gateway/src/control/auth/gate.rs b/apps/aether-gateway/src/control/auth/gate.rs
index 3a63bad65..047432688 100644
--- a/apps/aether-gateway/src/control/auth/gate.rs
+++ b/apps/aether-gateway/src/control/auth/gate.rs
@@ -610,6 +610,7 @@ mod tests {
priority: 1,
api_formats: Some(vec!["openai:chat".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -905,6 +906,7 @@ mod tests {
priority: 1,
api_formats: Some(vec!["gemini:generate_content".to_string()]),
endpoint_ids: None,
+ operations: None,
}]);
let state = state_with_rows(vec![row]);
let decision = decision_with_allowed_models(vec!["gpt-5".to_string()]);
diff --git a/apps/aether-gateway/src/data/candidate_selection.rs b/apps/aether-gateway/src/data/candidate_selection.rs
index a8a5d794b..003a61661 100644
--- a/apps/aether-gateway/src/data/candidate_selection.rs
+++ b/apps/aether-gateway/src/data/candidate_selection.rs
@@ -239,6 +239,29 @@ pub(crate) async fn enumerate_minimal_candidate_selection_with_required_capabili
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
required_capabilities: Option<&serde_json::Value>,
enable_model_directives: bool,
+) -> Result, DataLayerError> {
+ enumerate_minimal_candidate_selection_with_required_capabilities_for_request_operation(
+ state,
+ api_format,
+ requested_model_name,
+ require_streaming,
+ auth_snapshot,
+ required_capabilities,
+ enable_model_directives,
+ None,
+ )
+ .await
+}
+
+pub(crate) async fn enumerate_minimal_candidate_selection_with_required_capabilities_for_request_operation(
+ state: &(impl MinimalCandidateSelectionRowSource + Sync),
+ api_format: &str,
+ requested_model_name: &str,
+ require_streaming: bool,
+ auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
+ required_capabilities: Option<&serde_json::Value>,
+ enable_model_directives: bool,
+ request_operation: Option<&str>,
) -> Result, DataLayerError> {
let normalized_api_format = normalize_api_format(api_format);
if normalized_api_format.is_empty() {
@@ -267,6 +290,7 @@ pub(crate) async fn enumerate_minimal_candidate_selection_with_required_capabili
EnumerateMinimalCandidateSelectionInput {
rows,
normalized_api_format: &normalized_api_format,
+ request_operation,
requested_model_name,
resolved_global_model_name: resolved_global_model_name.as_str(),
require_streaming,
diff --git a/apps/aether-gateway/src/data/tests.rs b/apps/aether-gateway/src/data/tests.rs
index 12078c90e..f851fa79b 100644
--- a/apps/aether-gateway/src/data/tests.rs
+++ b/apps/aether-gateway/src/data/tests.rs
@@ -588,6 +588,7 @@ fn sample_minimal_candidate_selection_row(
priority: 1,
api_formats: Some(vec!["openai:chat".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: None,
model_is_active: true,
@@ -905,6 +906,7 @@ async fn data_state_reads_minimal_candidate_selection_with_auth_filters() {
enumerate_minimal_candidate_selection(EnumerateMinimalCandidateSelectionInput {
rows,
normalized_api_format: "openai:chat",
+ request_operation: None,
requested_model_name: "gpt-4.1",
resolved_global_model_name: "gpt-4.1",
require_streaming: false,
diff --git a/apps/aether-gateway/src/handlers/admin/provider/query/models/model_test/model_mapping.rs b/apps/aether-gateway/src/handlers/admin/provider/query/models/model_test/model_mapping.rs
index e4f670e40..ed456959d 100644
--- a/apps/aether-gateway/src/handlers/admin/provider/query/models/model_test/model_mapping.rs
+++ b/apps/aether-gateway/src/handlers/admin/provider/query/models/model_test/model_mapping.rs
@@ -213,6 +213,7 @@ fn provider_query_parse_embedded_provider_model_mappings(
priority: 1,
api_formats: None,
endpoint_ids: None,
+ operations: None,
}]))
}
@@ -237,6 +238,7 @@ fn provider_query_parse_provider_model_mappings_array(
priority: 1,
api_formats: None,
endpoint_ids: None,
+ operations: None,
});
}
}
@@ -293,6 +295,11 @@ fn provider_query_parse_provider_model_mapping_object_lenient(
object.get("endpoint_ids"),
"models.provider_model_mappings.endpoint_ids",
)?;
+ let operations = provider_query_parse_mapping_string_list(
+ object.get("operations"),
+ "models.provider_model_mappings.operations",
+ )?
+ .and_then(provider_query_normalize_request_operations);
Ok(Some(StoredProviderModelMapping {
name: name.to_string(),
@@ -303,9 +310,19 @@ fn provider_query_parse_provider_model_mapping_object_lenient(
})?,
api_formats,
endpoint_ids,
+ operations,
}))
}
+fn provider_query_normalize_request_operations(values: Vec) -> Option> {
+ let operations = values
+ .into_iter()
+ .map(|value| value.trim().to_ascii_lowercase())
+ .filter(|value| !value.is_empty())
+ .collect::>();
+ (!operations.is_empty()).then_some(operations)
+}
+
fn provider_query_parse_mapping_string_list(
value: Option<&Value>,
field_name: &str,
@@ -389,6 +406,7 @@ mod tests {
priority: 1,
api_formats: Some(vec![api_format.to_string()]),
endpoint_ids: None,
+ operations: None,
}
}
diff --git a/apps/aether-gateway/src/handlers/admin/request/models.rs b/apps/aether-gateway/src/handlers/admin/request/models.rs
index 1da58be85..b24daf716 100644
--- a/apps/aether-gateway/src/handlers/admin/request/models.rs
+++ b/apps/aether-gateway/src/handlers/admin/request/models.rs
@@ -15,7 +15,7 @@ use serde_json::json;
use std::collections::{BTreeMap, BTreeSet};
use uuid::Uuid;
-fn normalize_provider_model_mappings_api_formats(
+fn normalize_provider_model_mapping_scopes(
value: Option,
) -> Option {
let Some(mut value) = value else {
@@ -36,6 +36,9 @@ fn normalize_provider_model_mappings_api_formats(
normalize_provider_model_mapping_string_array_field(object, "endpoint_ids", |value| {
value.trim().to_string()
});
+ normalize_provider_model_mapping_string_array_field(object, "operations", |value| {
+ value.trim().to_ascii_lowercase()
+ });
}
Some(value)
}
@@ -155,7 +158,7 @@ impl<'a> AdminAppState<'a> {
"price_per_request",
)?;
let tiered_pricing = normalize_json_object(payload.tiered_pricing, "tiered_pricing")?;
- let provider_model_mappings = normalize_provider_model_mappings_api_formats(
+ let provider_model_mappings = normalize_provider_model_mapping_scopes(
normalize_json_array(payload.provider_model_mappings, "provider_model_mappings")?,
);
let config = normalize_json_object(payload.config, "config")?;
@@ -241,7 +244,7 @@ impl<'a> AdminAppState<'a> {
existing.tiered_pricing.clone()
};
let provider_model_mappings = if fields.contains("provider_model_mappings") {
- normalize_provider_model_mappings_api_formats(normalize_json_array(
+ normalize_provider_model_mapping_scopes(normalize_json_array(
payload.provider_model_mappings,
"provider_model_mappings",
)?)
diff --git a/apps/aether-gateway/src/handlers/public/support/user_me_usage.rs b/apps/aether-gateway/src/handlers/public/support/user_me_usage.rs
index 5c63cef02..e66900885 100644
--- a/apps/aether-gateway/src/handlers/public/support/user_me_usage.rs
+++ b/apps/aether-gateway/src/handlers/public/support/user_me_usage.rs
@@ -488,6 +488,7 @@ fn build_users_me_usage_record_payload(
"cache_read_input_tokens": item.cache_read_input_tokens,
"status_code": item.status_code,
"error_message": item.error_message,
+ "request_type": item.request_type,
"input_price_per_1m": input_price_per_1m,
"output_price_per_1m": output_price_per_1m,
"cache_creation_price_per_1m": cache_creation_price_per_1m,
@@ -525,6 +526,7 @@ fn build_users_me_usage_active_payload(item: &StoredRequestUsageAudit) -> serde_
let mut payload = json!({
"id": item.id,
"status": item.status,
+ "request_type": item.request_type,
"input_tokens": item.input_tokens,
"effective_input_tokens": users_me_usage_effective_input_tokens(item),
"output_tokens": item.output_tokens,
diff --git a/apps/aether-gateway/src/scheduler/candidate/enumeration.rs b/apps/aether-gateway/src/scheduler/candidate/enumeration.rs
index c04fc1cfe..12a6f4f21 100644
--- a/apps/aether-gateway/src/scheduler/candidate/enumeration.rs
+++ b/apps/aether-gateway/src/scheduler/candidate/enumeration.rs
@@ -2,7 +2,7 @@ use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
use crate::data::auth::GatewayAuthApiKeySnapshot;
use crate::data::candidate_selection::{
- enumerate_minimal_candidate_selection_with_required_capabilities,
+ enumerate_minimal_candidate_selection_with_required_capabilities_for_request_operation,
MinimalCandidateSelectionRowSource,
};
use crate::GatewayError;
@@ -15,8 +15,9 @@ pub(super) async fn enumerate_scheduler_candidates(
required_capabilities: Option<&serde_json::Value>,
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
enable_model_directives: bool,
+ request_operation: Option<&str>,
) -> Result, GatewayError> {
- enumerate_minimal_candidate_selection_with_required_capabilities(
+ enumerate_minimal_candidate_selection_with_required_capabilities_for_request_operation(
selection_row_source,
api_format,
global_model_name,
@@ -24,6 +25,7 @@ pub(super) async fn enumerate_scheduler_candidates(
auth_snapshot,
required_capabilities,
enable_model_directives,
+ request_operation,
)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
diff --git a/apps/aether-gateway/src/scheduler/candidate/mod.rs b/apps/aether-gateway/src/scheduler/candidate/mod.rs
index f793cef21..1da74c2ad 100644
--- a/apps/aether-gateway/src/scheduler/candidate/mod.rs
+++ b/apps/aether-gateway/src/scheduler/candidate/mod.rs
@@ -116,6 +116,42 @@ pub(crate) async fn list_selectable_candidates_with_skip_reasons(
client_session_affinity,
now_unix_secs,
enable_model_directives,
+ None,
+ )
+ .await
+}
+
+pub(crate) async fn list_selectable_candidates_with_skip_reasons_for_request_operation(
+ selection_row_source: &(impl MinimalCandidateSelectionRowSource + Sync),
+ runtime_state: &impl SchedulerRuntimeState,
+ api_format: &str,
+ global_model_name: &str,
+ require_streaming: bool,
+ required_capabilities: Option<&serde_json::Value>,
+ auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
+ client_session_affinity: Option<&ClientSessionAffinity>,
+ now_unix_secs: u64,
+ enable_model_directives: bool,
+ request_operation: Option<&str>,
+) -> Result<
+ (
+ Vec,
+ Vec,
+ ),
+ GatewayError,
+> {
+ collect_selectable_candidates_with_skip_reasons(
+ selection_row_source,
+ runtime_state,
+ api_format,
+ global_model_name,
+ require_streaming,
+ required_capabilities,
+ auth_snapshot,
+ client_session_affinity,
+ now_unix_secs,
+ enable_model_directives,
+ request_operation,
)
.await
}
@@ -237,6 +273,7 @@ pub(crate) async fn list_selectable_candidates_for_required_capability_without_r
client_session_affinity,
now_unix_secs,
false,
+ None,
)
.await?;
all_attempts_blocked_by_auth_limit &=
diff --git a/apps/aether-gateway/src/scheduler/candidate/selection.rs b/apps/aether-gateway/src/scheduler/candidate/selection.rs
index 7ef674b11..5a784dced 100644
--- a/apps/aether-gateway/src/scheduler/candidate/selection.rs
+++ b/apps/aether-gateway/src/scheduler/candidate/selection.rs
@@ -81,6 +81,7 @@ pub(super) async fn select_minimal_candidate(
required_capabilities,
auth_snapshot,
enable_model_directives,
+ None,
)
.await?;
let selected = collect_selectable_enumerated_candidates_with_skip_reasons(
@@ -137,6 +138,7 @@ pub(super) async fn collect_selectable_candidates(
client_session_affinity,
now_unix_secs,
enable_model_directives,
+ None,
)
.await?
.0)
@@ -153,6 +155,7 @@ pub(super) async fn collect_selectable_candidates_with_skip_reasons(
client_session_affinity: Option<&ClientSessionAffinity>,
now_unix_secs: u64,
enable_model_directives: bool,
+ request_operation: Option<&str>,
) -> Result<
(
Vec,
@@ -174,6 +177,7 @@ pub(super) async fn collect_selectable_candidates_with_skip_reasons(
required_capabilities,
auth_snapshot,
enable_model_directives,
+ request_operation,
)
.await?;
collect_selectable_enumerated_candidates_with_skip_reasons(
diff --git a/apps/aether-gateway/src/scheduler/candidate/tests/affinity.rs b/apps/aether-gateway/src/scheduler/candidate/tests/affinity.rs
index 50facf3dc..0fe2b2467 100644
--- a/apps/aether-gateway/src/scheduler/candidate/tests/affinity.rs
+++ b/apps/aether-gateway/src/scheduler/candidate/tests/affinity.rs
@@ -107,6 +107,7 @@ async fn same_priority_candidates_are_distributed_by_affinity_key() {
priority: 1,
api_formats: Some(vec!["openai:chat".to_string()]),
endpoint_ids: None,
+ operations: None,
}]);
let mut second = sample_row();
@@ -124,6 +125,7 @@ async fn same_priority_candidates_are_distributed_by_affinity_key() {
priority: 1,
api_formats: Some(vec!["openai:chat".to_string()]),
endpoint_ids: None,
+ operations: None,
}]);
let candidates = Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(vec![
@@ -142,6 +144,7 @@ async fn same_priority_candidates_are_distributed_by_affinity_key() {
enumerate_minimal_candidate_selection(EnumerateMinimalCandidateSelectionInput {
rows,
normalized_api_format: "openai:chat",
+ request_operation: None,
requested_model_name: "gpt-4.1",
resolved_global_model_name: "gpt-4.1",
require_streaming: false,
diff --git a/apps/aether-gateway/src/scheduler/candidate/tests/model.rs b/apps/aether-gateway/src/scheduler/candidate/tests/model.rs
index 94b79ed2c..cfe925f84 100644
--- a/apps/aether-gateway/src/scheduler/candidate/tests/model.rs
+++ b/apps/aether-gateway/src/scheduler/candidate/tests/model.rs
@@ -45,6 +45,7 @@ fn provider_model_mapping_respects_endpoint_scope() {
priority: 1,
api_formats: Some(vec!["openai:chat".to_string()]),
endpoint_ids: Some(vec!["endpoint-selected".to_string()]),
+ operations: None,
}]);
assert_eq!(
@@ -101,6 +102,7 @@ fn resolves_requested_global_model_from_provider_model_alias() {
priority: 1,
api_formats: Some(vec!["openai:chat".to_string()]),
endpoint_ids: None,
+ operations: None,
}]);
let resolved = resolve_requested_global_model_name(&[row], "gpt-5.2", "openai:chat");
@@ -156,6 +158,7 @@ async fn enumerate_minimal_candidate_selection_resolves_provider_model_alias() {
priority: 1,
api_formats: Some(vec!["openai:chat".to_string()]),
endpoint_ids: None,
+ operations: None,
}]);
let candidates = Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(vec![
@@ -193,6 +196,7 @@ async fn enumerate_minimal_candidate_selection_filters_endpoint_scoped_alias_row
priority: 1,
api_formats: Some(vec!["openai:chat".to_string()]),
endpoint_ids: Some(vec!["endpoint-selected".to_string()]),
+ operations: None,
}]);
let mut other = selected.clone();
@@ -248,6 +252,7 @@ async fn enumerate_minimal_candidate_selection_keeps_only_resolved_global_model_
priority: 1,
api_formats: Some(vec!["openai:chat".to_string()]),
endpoint_ids: None,
+ operations: None,
}]);
let candidates = Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(vec![
@@ -287,6 +292,7 @@ async fn enumerate_minimal_candidate_selection_allows_resolved_global_model_in_a
priority: 1,
api_formats: Some(vec!["openai:chat".to_string()]),
endpoint_ids: None,
+ operations: None,
}]);
let candidates = Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(vec![
diff --git a/apps/aether-gateway/src/scheduler/candidate/tests/selection.rs b/apps/aether-gateway/src/scheduler/candidate/tests/selection.rs
index 824964199..b1578c777 100644
--- a/apps/aether-gateway/src/scheduler/candidate/tests/selection.rs
+++ b/apps/aether-gateway/src/scheduler/candidate/tests/selection.rs
@@ -105,6 +105,7 @@ async fn collect_selectable_candidates_with_skip_reasons(
None,
now_unix_secs,
false,
+ None,
)
.await
}
@@ -843,6 +844,7 @@ async fn selects_next_candidate_when_first_provider_quota_is_exhausted() {
priority: 1,
api_formats: Some(vec!["openai:chat".to_string()]),
endpoint_ids: None,
+ operations: None,
}]);
first.key_global_priority_by_format = Some(serde_json::json!({"openai:chat": 1}));
@@ -858,6 +860,7 @@ async fn selects_next_candidate_when_first_provider_quota_is_exhausted() {
priority: 1,
api_formats: Some(vec!["openai:chat".to_string()]),
endpoint_ids: None,
+ operations: None,
}]);
second.key_global_priority_by_format = Some(serde_json::json!({"openai:chat": 2}));
@@ -914,6 +917,7 @@ async fn cooled_down_when_recent_failures_are_recorded_for_same_key() {
priority: 1,
api_formats: Some(vec!["openai:chat".to_string()]),
endpoint_ids: None,
+ operations: None,
}]);
first.key_global_priority_by_format = Some(serde_json::json!({"openai:chat": 1}));
@@ -929,6 +933,7 @@ async fn cooled_down_when_recent_failures_are_recorded_for_same_key() {
priority: 1,
api_formats: Some(vec!["openai:chat".to_string()]),
endpoint_ids: None,
+ operations: None,
}]);
second.key_global_priority_by_format = Some(serde_json::json!({"openai:chat": 2}));
diff --git a/apps/aether-gateway/src/scheduler/candidate/tests/support.rs b/apps/aether-gateway/src/scheduler/candidate/tests/support.rs
index e1e17dd78..1bf9e6f13 100644
--- a/apps/aether-gateway/src/scheduler/candidate/tests/support.rs
+++ b/apps/aether-gateway/src/scheduler/candidate/tests/support.rs
@@ -40,12 +40,14 @@ pub(super) fn sample_row() -> StoredMinimalCandidateSelectionRow {
priority: 1,
api_formats: Some(vec!["openai:chat".to_string()]),
endpoint_ids: None,
+ operations: None,
},
StoredProviderModelMapping {
name: "gpt-4.1-responses".to_string(),
priority: 1,
api_formats: Some(vec!["openai:responses".to_string()]),
endpoint_ids: None,
+ operations: None,
},
]),
model_supports_streaming: None,
diff --git a/apps/aether-gateway/src/state/catalog.rs b/apps/aether-gateway/src/state/catalog.rs
index 1e3d5a823..b61fa3f4c 100644
--- a/apps/aether-gateway/src/state/catalog.rs
+++ b/apps/aether-gateway/src/state/catalog.rs
@@ -1404,6 +1404,7 @@ mod tests {
let ttl = Duration::from_secs(300);
let cache_key = CandidatePageCacheKey::new(
"gpt-5",
+ None,
"openai:chat",
true,
&sample_auth_snapshot(),
diff --git a/apps/aether-gateway/src/testkit.rs b/apps/aether-gateway/src/testkit.rs
index 98f74f5a5..04d40c6e8 100644
--- a/apps/aether-gateway/src/testkit.rs
+++ b/apps/aether-gateway/src/testkit.rs
@@ -247,6 +247,7 @@ fn openai_chat_pressure_candidates(
priority: 1,
api_formats: Some(vec!["openai:chat".to_string()]),
endpoint_ids: Some(vec![pressure_endpoint_id(index)]),
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
diff --git a/apps/aether-gateway/src/tests/ai_execute/finalize_local.rs b/apps/aether-gateway/src/tests/ai_execute/finalize_local.rs
index 7a787497c..c2103c842 100644
--- a/apps/aether-gateway/src/tests/ai_execute/finalize_local.rs
+++ b/apps/aether-gateway/src/tests/ai_execute/finalize_local.rs
@@ -138,6 +138,7 @@ async fn gateway_executes_openai_chat_sync_upstream_stream_via_local_finalize_re
priority: 1,
api_formats: Some(vec!["openai:chat".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -637,6 +638,7 @@ async fn gateway_executes_openai_chat_cross_format_upstream_stream_via_local_fin
priority: 1,
api_formats: Some(vec!["gemini:generate_content".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -1082,6 +1084,7 @@ async fn gateway_executes_openai_chat_cross_format_tool_use_upstream_stream_via_
priority: 1,
api_formats: Some(vec!["claude:messages".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -1564,6 +1567,7 @@ async fn gateway_executes_openai_chat_antigravity_cross_format_sync_via_local_fi
priority: 1,
api_formats: Some(vec!["gemini:generate_content".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -2074,6 +2078,7 @@ async fn gateway_executes_openai_chat_cross_format_claude_upstream_sync_via_loca
priority: 1,
api_formats: Some(vec!["claude:messages".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -2433,6 +2438,7 @@ async fn gateway_executes_openai_chat_cross_format_gemini_upstream_sync_via_loca
priority: 1,
api_formats: Some(vec!["gemini:generate_content".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
diff --git a/apps/aether-gateway/src/tests/ai_execute/finalize_local_cli/compact.rs b/apps/aether-gateway/src/tests/ai_execute/finalize_local_cli/compact.rs
index 58f5fe141..2232af5ca 100644
--- a/apps/aether-gateway/src/tests/ai_execute/finalize_local_cli/compact.rs
+++ b/apps/aether-gateway/src/tests/ai_execute/finalize_local_cli/compact.rs
@@ -113,6 +113,7 @@ async fn gateway_executes_openai_responses_compact_openai_family_upstream_stream
priority: 1,
api_formats: Some(vec!["openai:responses:compact".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
diff --git a/apps/aether-gateway/src/tests/ai_execute/finalize_local_cli/cross_format.rs b/apps/aether-gateway/src/tests/ai_execute/finalize_local_cli/cross_format.rs
index af570cfbd..03471fe8b 100644
--- a/apps/aether-gateway/src/tests/ai_execute/finalize_local_cli/cross_format.rs
+++ b/apps/aether-gateway/src/tests/ai_execute/finalize_local_cli/cross_format.rs
@@ -113,6 +113,7 @@ async fn gateway_executes_openai_responses_cross_format_upstream_stream_via_loca
priority: 1,
api_formats: Some(vec!["gemini:generate_content".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -586,6 +587,7 @@ async fn gateway_executes_openai_responses_cross_format_function_call_upstream_s
priority: 1,
api_formats: Some(vec!["gemini:generate_content".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -1071,6 +1073,7 @@ async fn gateway_executes_openai_responses_antigravity_cross_format_upstream_str
priority: 1,
api_formats: Some(vec!["gemini:generate_content".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
diff --git a/apps/aether-gateway/src/tests/ai_execute/finalize_local_cli/direct.rs b/apps/aether-gateway/src/tests/ai_execute/finalize_local_cli/direct.rs
index 7a35e3dde..429e52774 100644
--- a/apps/aether-gateway/src/tests/ai_execute/finalize_local_cli/direct.rs
+++ b/apps/aether-gateway/src/tests/ai_execute/finalize_local_cli/direct.rs
@@ -135,6 +135,7 @@ async fn gateway_executes_openai_responses_sync_upstream_stream_via_local_finali
priority: 1,
api_formats: Some(vec!["openai:responses".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -650,6 +651,7 @@ async fn gateway_executes_kiro_claude_cli_sync_upstream_stream_via_local_finaliz
priority: 1,
api_formats: Some(vec!["claude:messages".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
diff --git a/apps/aether-gateway/src/tests/ai_execute/finalize_local_provider/claude.rs b/apps/aether-gateway/src/tests/ai_execute/finalize_local_provider/claude.rs
index 101a2b829..5991495b0 100644
--- a/apps/aether-gateway/src/tests/ai_execute/finalize_local_provider/claude.rs
+++ b/apps/aether-gateway/src/tests/ai_execute/finalize_local_provider/claude.rs
@@ -131,6 +131,7 @@ async fn gateway_executes_claude_chat_sync_same_format_via_local_finalize_respon
priority: 1,
api_formats: Some(vec!["claude:messages".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -587,6 +588,7 @@ async fn gateway_executes_claude_chat_sync_upstream_stream_via_local_finalize_re
priority: 1,
api_formats: Some(vec!["claude:messages".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -1048,6 +1050,7 @@ async fn gateway_executes_claude_cli_sync_upstream_stream_via_local_finalize_res
priority: 1,
api_formats: Some(vec!["claude:messages".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
diff --git a/apps/aether-gateway/src/tests/ai_execute/finalize_local_provider/gemini.rs b/apps/aether-gateway/src/tests/ai_execute/finalize_local_provider/gemini.rs
index 0a2c2ed67..8fa7f5745 100644
--- a/apps/aether-gateway/src/tests/ai_execute/finalize_local_provider/gemini.rs
+++ b/apps/aether-gateway/src/tests/ai_execute/finalize_local_provider/gemini.rs
@@ -136,6 +136,7 @@ async fn gateway_executes_gemini_chat_sync_same_format_via_local_finalize_respon
priority: 1,
api_formats: Some(vec!["gemini:generate_content".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -640,6 +641,7 @@ async fn gateway_executes_gemini_chat_sync_upstream_stream_via_local_finalize_re
priority: 1,
api_formats: Some(vec!["gemini:generate_content".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -1134,6 +1136,7 @@ async fn gateway_executes_gemini_cli_sync_upstream_stream_via_local_finalize_res
priority: 1,
api_formats: Some(vec!["gemini:generate_content".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -1642,6 +1645,7 @@ async fn gateway_executes_antigravity_gemini_cli_sync_upstream_stream_via_local_
priority: 1,
api_formats: Some(vec!["gemini:generate_content".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
diff --git a/apps/aether-gateway/src/tests/ai_execute/lifecycle.rs b/apps/aether-gateway/src/tests/ai_execute/lifecycle.rs
index e91578634..c3cae80b1 100644
--- a/apps/aether-gateway/src/tests/ai_execute/lifecycle.rs
+++ b/apps/aether-gateway/src/tests/ai_execute/lifecycle.rs
@@ -111,6 +111,7 @@ fn sample_local_openai_candidate_row() -> StoredMinimalCandidateSelectionRow {
priority: 1,
api_formats: Some(vec!["openai:chat".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
diff --git a/apps/aether-gateway/src/tests/ai_execute/stream/decision.rs b/apps/aether-gateway/src/tests/ai_execute/stream/decision.rs
index cdb8346cb..3f901976d 100644
--- a/apps/aether-gateway/src/tests/ai_execute/stream/decision.rs
+++ b/apps/aether-gateway/src/tests/ai_execute/stream/decision.rs
@@ -131,6 +131,7 @@ async fn gateway_executes_openai_chat_stream_via_local_decision_gate_without_exe
priority: 1,
api_formats: Some(vec!["openai:chat".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -535,6 +536,7 @@ async fn gateway_executes_openai_chat_stream_via_local_openai_responses_cross_fo
priority: 1,
api_formats: Some(vec!["openai:responses".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -1020,6 +1022,7 @@ async fn gateway_executes_openai_chat_stream_via_local_cross_format_gemini_candi
priority: 1,
api_formats: Some(vec!["gemini:generate_content".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -1458,6 +1461,7 @@ async fn gateway_executes_openai_chat_stream_with_custom_path_via_local_decision
priority: 1,
api_formats: Some(vec!["openai:chat".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -1961,6 +1965,7 @@ async fn gateway_retries_next_local_openai_chat_stream_candidate_after_retryable
priority: 1,
api_formats: Some(vec!["openai:chat".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
diff --git a/apps/aether-gateway/src/tests/ai_execute/stream/image.rs b/apps/aether-gateway/src/tests/ai_execute/stream/image.rs
index 3ab8b0422..8ede08204 100644
--- a/apps/aether-gateway/src/tests/ai_execute/stream/image.rs
+++ b/apps/aether-gateway/src/tests/ai_execute/stream/image.rs
@@ -131,6 +131,7 @@ async fn gateway_executes_codex_image_stream_via_local_decision_gate_after_oauth
priority: 1,
api_formats: Some(vec!["openai:image".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -517,6 +518,7 @@ async fn gateway_bridges_codex_image_sync_json_to_streaming_image_sse_impl() {
priority: 1,
api_formats: Some(vec!["openai:image".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -840,6 +842,7 @@ fn image_bridge_candidate_row(
priority: 1,
api_formats: Some(vec!["openai:image".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(false),
model_is_active: true,
diff --git a/apps/aether-gateway/src/tests/ai_execute/stream/pii_redaction.rs b/apps/aether-gateway/src/tests/ai_execute/stream/pii_redaction.rs
index c6cfabde1..3f3b276dc 100644
--- a/apps/aether-gateway/src/tests/ai_execute/stream/pii_redaction.rs
+++ b/apps/aether-gateway/src/tests/ai_execute/stream/pii_redaction.rs
@@ -150,6 +150,7 @@ fn candidate_row() -> StoredMinimalCandidateSelectionRow {
priority: 1,
api_formats: Some(vec!["openai:chat".to_string()]),
endpoint_ids: Some(vec!["endpoint-ai-execute-stream-pii-redaction".to_string()]),
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
diff --git a/apps/aether-gateway/src/tests/ai_execute/stream_cli/compact.rs b/apps/aether-gateway/src/tests/ai_execute/stream_cli/compact.rs
index c7a0a8841..2d7207c5a 100644
--- a/apps/aether-gateway/src/tests/ai_execute/stream_cli/compact.rs
+++ b/apps/aether-gateway/src/tests/ai_execute/stream_cli/compact.rs
@@ -123,6 +123,7 @@ async fn gateway_executes_openai_responses_compact_as_unary_request_impl() {
priority: 1,
api_formats: Some(vec!["openai:responses:compact".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
diff --git a/apps/aether-gateway/src/tests/ai_execute/stream_cli/direct.rs b/apps/aether-gateway/src/tests/ai_execute/stream_cli/direct.rs
index d885cc479..6842b23b9 100644
--- a/apps/aether-gateway/src/tests/ai_execute/stream_cli/direct.rs
+++ b/apps/aether-gateway/src/tests/ai_execute/stream_cli/direct.rs
@@ -136,6 +136,7 @@ async fn gateway_executes_codex_cli_stream_via_local_decision_gate_after_oauth_r
priority: 1,
api_formats: Some(vec!["openai:responses".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
diff --git a/apps/aether-gateway/src/tests/ai_execute/stream_provider.rs b/apps/aether-gateway/src/tests/ai_execute/stream_provider.rs
index 06566661f..68ec79da7 100644
--- a/apps/aether-gateway/src/tests/ai_execute/stream_provider.rs
+++ b/apps/aether-gateway/src/tests/ai_execute/stream_provider.rs
@@ -182,6 +182,7 @@ async fn gateway_executes_kiro_claude_cli_stream_via_local_provider_catalog_cand
priority: 1,
api_formats: Some(vec!["claude:messages".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -700,6 +701,7 @@ async fn gateway_executes_claude_cli_stream_via_local_decision_gate_without_wait
priority: 1,
api_formats: Some(vec!["claude:messages".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -1161,6 +1163,7 @@ async fn gateway_executes_claude_code_cli_stream_via_local_decision_gate_with_lo
priority: 1,
api_formats: Some(vec!["claude:messages".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -1694,6 +1697,7 @@ async fn gateway_executes_claude_chat_stream_via_local_decision_gate_with_local_
priority: 1,
api_formats: Some(vec!["claude:messages".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
diff --git a/apps/aether-gateway/src/tests/ai_execute/stream_provider_gemini/local_chat.rs b/apps/aether-gateway/src/tests/ai_execute/stream_provider_gemini/local_chat.rs
index a3852bde8..3b2a21f91 100644
--- a/apps/aether-gateway/src/tests/ai_execute/stream_provider_gemini/local_chat.rs
+++ b/apps/aether-gateway/src/tests/ai_execute/stream_provider_gemini/local_chat.rs
@@ -101,6 +101,7 @@ async fn gateway_executes_gemini_chat_stream_via_local_decision_gate_with_local_
priority: 1,
api_formats: Some(vec!["gemini:generate_content".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
diff --git a/apps/aether-gateway/src/tests/ai_execute/stream_provider_gemini/local_cli.rs b/apps/aether-gateway/src/tests/ai_execute/stream_provider_gemini/local_cli.rs
index 5e2b73e5e..7b7d98ad1 100644
--- a/apps/aether-gateway/src/tests/ai_execute/stream_provider_gemini/local_cli.rs
+++ b/apps/aether-gateway/src/tests/ai_execute/stream_provider_gemini/local_cli.rs
@@ -101,6 +101,7 @@ async fn gateway_executes_gemini_cli_stream_via_local_decision_gate_with_local_s
priority: 1,
api_formats: Some(vec!["gemini:generate_content".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -553,6 +554,7 @@ async fn gateway_executes_gemini_cli_stream_via_local_decision_gate_after_oauth_
priority: 1,
api_formats: Some(vec!["gemini:generate_content".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -1108,6 +1110,7 @@ async fn gateway_executes_vertex_ai_gemini_cli_stream_via_local_decision_gate_wi
priority: 1,
api_formats: Some(vec!["gemini:generate_content".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -1573,6 +1576,7 @@ async fn gateway_executes_antigravity_gemini_cli_stream_via_local_decision_gate_
priority: 1,
api_formats: Some(vec!["gemini:generate_content".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
diff --git a/apps/aether-gateway/src/tests/ai_execute/sync/chat/failover.rs b/apps/aether-gateway/src/tests/ai_execute/sync/chat/failover.rs
index eda10935b..cc8de1774 100644
--- a/apps/aether-gateway/src/tests/ai_execute/sync/chat/failover.rs
+++ b/apps/aether-gateway/src/tests/ai_execute/sync/chat/failover.rs
@@ -91,6 +91,7 @@ async fn gateway_skips_unsupported_local_openai_chat_sync_candidate_before_tryin
priority: 1,
api_formats: Some(vec!["openai:chat".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -296,6 +297,7 @@ async fn gateway_skips_unsupported_local_openai_chat_sync_candidate_before_tryin
priority: 1,
api_formats: Some(vec!["openai:chat".to_string()]),
endpoint_ids: None,
+ operations: None,
}]);
let candidate_selection_repository =
Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(vec![
@@ -497,6 +499,7 @@ async fn gateway_surfaces_local_execution_runtime_miss_reason_when_all_openai_ch
priority: 1,
api_formats: Some(vec!["openai:chat".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -775,6 +778,7 @@ async fn gateway_retries_next_local_openai_chat_sync_candidate_after_auth_failur
priority: 1,
api_formats: Some(vec!["openai:chat".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
diff --git a/apps/aether-gateway/src/tests/ai_execute/sync/chat/local_decision.rs b/apps/aether-gateway/src/tests/ai_execute/sync/chat/local_decision.rs
index 2bd34498a..9d92d1fd6 100644
--- a/apps/aether-gateway/src/tests/ai_execute/sync/chat/local_decision.rs
+++ b/apps/aether-gateway/src/tests/ai_execute/sync/chat/local_decision.rs
@@ -150,6 +150,7 @@ async fn proxy_pii_redaction_local_openai_chat_runtime_masks_headers_and_restore
priority: 1,
api_formats: Some(vec!["openai:chat".to_string()]),
endpoint_ids: Some(vec!["endpoint-redaction-1".to_string()]),
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -454,6 +455,7 @@ async fn gateway_executes_openai_chat_sync_via_local_decision_gate_without_execu
priority: 1,
api_formats: Some(vec!["openai:chat".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -647,6 +649,7 @@ async fn gateway_executes_openai_chat_sync_via_local_decision_gate_without_execu
priority: 1,
api_formats: Some(vec!["openai:chat".to_string()]),
endpoint_ids: None,
+ operations: None,
}]);
let candidate_selection_repository =
Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(vec![
@@ -839,6 +842,7 @@ async fn gateway_executes_openai_chat_sync_with_regex_model_mapping_in_execution
priority: 1,
api_formats: Some(vec!["openai:chat".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -1130,6 +1134,7 @@ async fn gateway_executes_openai_chat_sync_via_local_cross_format_gemini_candida
priority: 1,
api_formats: Some(vec!["gemini:generate_content".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -1151,6 +1156,7 @@ async fn gateway_executes_openai_chat_sync_via_local_cross_format_gemini_candida
priority: 2,
api_formats: Some(vec!["gemini:generate_content".to_string()]),
endpoint_ids: None,
+ operations: None,
}]);
row
}
@@ -1698,6 +1704,7 @@ async fn gateway_returns_openai_chat_error_for_local_cross_format_claude_cli_syn
priority: 1,
api_formats: Some(vec!["claude:messages".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -2102,6 +2109,7 @@ async fn gateway_returns_openai_chat_error_for_local_cross_format_gemini_cli_syn
priority: 1,
api_formats: Some(vec!["gemini:generate_content".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -2531,6 +2539,7 @@ async fn gateway_returns_openai_chat_error_for_local_cross_format_claude_sync_fa
priority: 1,
api_formats: Some(vec!["claude:messages".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -2939,6 +2948,7 @@ async fn gateway_returns_openai_chat_error_for_local_cross_format_gemini_sync_fa
priority: 1,
api_formats: Some(vec!["gemini:generate_content".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -3387,6 +3397,7 @@ async fn gateway_executes_openai_chat_sync_with_custom_path_via_local_decision_g
priority: 1,
api_formats: Some(vec!["openai:chat".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
diff --git a/apps/aether-gateway/src/tests/ai_execute/sync/chat/pii_redaction.rs b/apps/aether-gateway/src/tests/ai_execute/sync/chat/pii_redaction.rs
index 537f7c120..ad5f6866b 100644
--- a/apps/aether-gateway/src/tests/ai_execute/sync/chat/pii_redaction.rs
+++ b/apps/aether-gateway/src/tests/ai_execute/sync/chat/pii_redaction.rs
@@ -113,6 +113,7 @@ fn candidate_row(test_id: &str) -> StoredMinimalCandidateSelectionRow {
priority: 1,
api_formats: Some(vec!["openai:chat".to_string()]),
endpoint_ids: Some(vec![format!("endpoint-{test_id}")]),
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
diff --git a/apps/aether-gateway/src/tests/ai_execute/sync/claude/claude_code.rs b/apps/aether-gateway/src/tests/ai_execute/sync/claude/claude_code.rs
index c365a7d36..e5d38946a 100644
--- a/apps/aether-gateway/src/tests/ai_execute/sync/claude/claude_code.rs
+++ b/apps/aether-gateway/src/tests/ai_execute/sync/claude/claude_code.rs
@@ -129,6 +129,7 @@ async fn gateway_executes_claude_code_cli_sync_via_local_decision_gate_with_loca
priority: 1,
api_formats: Some(vec!["claude:messages".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
diff --git a/apps/aether-gateway/src/tests/ai_execute/sync/claude/kiro.rs b/apps/aether-gateway/src/tests/ai_execute/sync/claude/kiro.rs
index 6239a7a1c..16a3db78a 100644
--- a/apps/aether-gateway/src/tests/ai_execute/sync/claude/kiro.rs
+++ b/apps/aether-gateway/src/tests/ai_execute/sync/claude/kiro.rs
@@ -197,6 +197,7 @@ async fn gateway_executes_kiro_claude_cli_sync_via_local_provider_catalog_candid
priority: 1,
api_formats: Some(vec!["claude:messages".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -835,6 +836,7 @@ async fn gateway_executes_kiro_claude_cli_sync_via_local_provider_catalog_candid
priority: 1,
api_formats: Some(vec!["claude:messages".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
diff --git a/apps/aether-gateway/src/tests/ai_execute/sync/claude/local_chat.rs b/apps/aether-gateway/src/tests/ai_execute/sync/claude/local_chat.rs
index f5f11be5b..733c60669 100644
--- a/apps/aether-gateway/src/tests/ai_execute/sync/claude/local_chat.rs
+++ b/apps/aether-gateway/src/tests/ai_execute/sync/claude/local_chat.rs
@@ -124,6 +124,7 @@ async fn gateway_executes_claude_chat_sync_via_local_decision_gate_with_local_sy
priority: 1,
api_formats: Some(vec!["claude:messages".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -694,6 +695,7 @@ async fn gateway_returns_claude_chat_error_for_local_sync_failure_impl() {
priority: 1,
api_formats: Some(vec!["claude:messages".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
diff --git a/apps/aether-gateway/src/tests/ai_execute/sync/claude/local_cli.rs b/apps/aether-gateway/src/tests/ai_execute/sync/claude/local_cli.rs
index eabb52d58..4687c1aa4 100644
--- a/apps/aether-gateway/src/tests/ai_execute/sync/claude/local_cli.rs
+++ b/apps/aether-gateway/src/tests/ai_execute/sync/claude/local_cli.rs
@@ -124,6 +124,7 @@ async fn gateway_executes_claude_cli_sync_via_local_decision_gate_with_local_syn
priority: 1,
api_formats: Some(vec!["claude:messages".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -570,6 +571,7 @@ async fn gateway_returns_claude_cli_error_for_local_sync_failure_impl() {
priority: 1,
api_formats: Some(vec!["claude:messages".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -853,6 +855,7 @@ async fn gateway_marks_claude_cli_cross_format_runtime_miss_when_format_conversi
priority: 1,
api_formats: Some(vec!["openai:responses".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
diff --git a/apps/aether-gateway/src/tests/ai_execute/sync/cli.rs b/apps/aether-gateway/src/tests/ai_execute/sync/cli.rs
index 7c8f16999..b15c80c62 100644
--- a/apps/aether-gateway/src/tests/ai_execute/sync/cli.rs
+++ b/apps/aether-gateway/src/tests/ai_execute/sync/cli.rs
@@ -141,6 +141,7 @@ async fn gateway_executes_openai_responses_sync_via_local_decision_gate_with_loc
priority: 1,
api_formats: Some(vec!["openai:responses".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -650,6 +651,7 @@ async fn gateway_waits_for_api_key_concurrency_slot_then_executes_openai_respons
priority: 1,
api_formats: Some(vec!["openai:responses".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -1042,6 +1044,7 @@ async fn gateway_executes_openai_responses_sync_after_api_key_concurrency_wait_b
priority: 1,
api_formats: Some(vec!["openai:responses".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -1412,6 +1415,7 @@ async fn gateway_returns_openai_responses_error_for_local_sync_failure_impl() {
priority: 1,
api_formats: Some(vec!["openai:responses".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -1725,6 +1729,7 @@ async fn gateway_returns_openai_responses_error_for_local_cross_format_gemini_cl
priority: 1,
api_formats: Some(vec!["gemini:generate_content".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -2131,6 +2136,7 @@ async fn gateway_returns_openai_responses_error_for_local_cross_format_claude_sy
priority: 1,
api_formats: Some(vec!["claude:messages".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -2516,6 +2522,7 @@ async fn gateway_returns_openai_responses_error_for_local_cross_format_claude_ch
priority: 1,
api_formats: Some(vec!["claude:messages".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -2904,6 +2911,7 @@ async fn gateway_returns_openai_responses_error_for_local_cross_format_gemini_ch
priority: 1,
api_formats: Some(vec!["gemini:generate_content".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -3301,6 +3309,7 @@ async fn gateway_executes_codex_cli_sync_via_local_decision_gate_after_oauth_ref
priority: 1,
api_formats: Some(vec!["openai:responses".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
diff --git a/apps/aether-gateway/src/tests/ai_execute/sync/gemini/cli.rs b/apps/aether-gateway/src/tests/ai_execute/sync/gemini/cli.rs
index 90034fe5c..770bd5846 100644
--- a/apps/aether-gateway/src/tests/ai_execute/sync/gemini/cli.rs
+++ b/apps/aether-gateway/src/tests/ai_execute/sync/gemini/cli.rs
@@ -123,6 +123,7 @@ async fn gateway_executes_gemini_cli_sync_via_local_decision_gate_with_local_syn
priority: 1,
api_formats: Some(vec!["gemini:generate_content".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -561,6 +562,7 @@ async fn gateway_returns_gemini_cli_error_for_local_sync_failure_impl() {
priority: 1,
api_formats: Some(vec!["gemini:generate_content".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -864,6 +866,7 @@ async fn gateway_executes_gemini_cli_sync_via_local_decision_gate_after_oauth_re
priority: 1,
api_formats: Some(vec!["gemini:generate_content".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -1426,6 +1429,7 @@ async fn gateway_executes_vertex_ai_gemini_cli_sync_via_local_decision_gate_with
priority: 1,
api_formats: Some(vec!["gemini:generate_content".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -1878,6 +1882,7 @@ async fn gateway_executes_antigravity_gemini_cli_sync_via_local_decision_gate_af
priority: 1,
api_formats: Some(vec!["gemini:generate_content".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
diff --git a/apps/aether-gateway/src/tests/ai_execute/sync/gemini/local_chat.rs b/apps/aether-gateway/src/tests/ai_execute/sync/gemini/local_chat.rs
index 0e60c4ccf..79d89436e 100644
--- a/apps/aether-gateway/src/tests/ai_execute/sync/gemini/local_chat.rs
+++ b/apps/aether-gateway/src/tests/ai_execute/sync/gemini/local_chat.rs
@@ -123,6 +123,7 @@ async fn gateway_executes_gemini_chat_sync_via_local_decision_gate_with_local_sy
priority: 1,
api_formats: Some(vec!["gemini:generate_content".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -551,6 +552,7 @@ async fn gateway_returns_gemini_chat_error_for_local_sync_failure_impl() {
priority: 1,
api_formats: Some(vec!["gemini:generate_content".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
diff --git a/apps/aether-gateway/src/tests/ai_execute/sync/image.rs b/apps/aether-gateway/src/tests/ai_execute/sync/image.rs
index 915de228e..cd65a8a9b 100644
--- a/apps/aether-gateway/src/tests/ai_execute/sync/image.rs
+++ b/apps/aether-gateway/src/tests/ai_execute/sync/image.rs
@@ -129,6 +129,7 @@ async fn gateway_converts_openai_image_sync_to_gemini_image_provider_impl() {
priority: 1,
api_formats: Some(vec!["gemini:generate_content".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -481,6 +482,7 @@ async fn gateway_converts_gemini_image_sync_to_openai_image_provider_impl() {
priority: 1,
api_formats: Some(vec!["openai:image".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -833,6 +835,7 @@ async fn gateway_executes_codex_image_sync_via_local_decision_gate_after_oauth_r
priority: 1,
api_formats: Some(vec!["openai:image".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -1242,6 +1245,7 @@ async fn gateway_plans_chatgpt_web_image_sync_with_internal_web_executor_url_imp
priority: 1,
api_formats: Some(vec!["openai:image".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
diff --git a/apps/aether-gateway/src/tests/ai_execute/sync/pii_redaction_formats.rs b/apps/aether-gateway/src/tests/ai_execute/sync/pii_redaction_formats.rs
index 5137d1dc3..45e786726 100644
--- a/apps/aether-gateway/src/tests/ai_execute/sync/pii_redaction_formats.rs
+++ b/apps/aether-gateway/src/tests/ai_execute/sync/pii_redaction_formats.rs
@@ -608,6 +608,7 @@ fn candidate_row(case: &RedactionFormatCase) -> StoredMinimalCandidateSelectionR
priority: 1,
api_formats: Some(vec![case.provider_format.api_format().to_string()]),
endpoint_ids: Some(vec![format!("endpoint-{}", case.test_id)]),
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
diff --git a/apps/aether-gateway/src/tests/ai_execute/sync/search.rs b/apps/aether-gateway/src/tests/ai_execute/sync/search.rs
index eb1de4c43..fd11355bd 100644
--- a/apps/aether-gateway/src/tests/ai_execute/sync/search.rs
+++ b/apps/aether-gateway/src/tests/ai_execute/sync/search.rs
@@ -119,6 +119,7 @@ async fn gateway_executes_codex_search_with_responses_permission_and_search_cont
priority: 1,
api_formats: Some(vec!["openai:responses".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(false),
model_is_active: true,
diff --git a/apps/aether-gateway/src/tests/audit.rs b/apps/aether-gateway/src/tests/audit.rs
index 497a329c9..776a0203e 100644
--- a/apps/aether-gateway/src/tests/audit.rs
+++ b/apps/aether-gateway/src/tests/audit.rs
@@ -112,6 +112,7 @@ fn sample_local_openai_candidate_row() -> StoredMinimalCandidateSelectionRow {
priority: 1,
api_formats: Some(vec!["openai:chat".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
diff --git a/apps/aether-gateway/src/tests/files/mod.rs b/apps/aether-gateway/src/tests/files/mod.rs
index 5c85a232a..bc374ba78 100644
--- a/apps/aether-gateway/src/tests/files/mod.rs
+++ b/apps/aether-gateway/src/tests/files/mod.rs
@@ -115,6 +115,7 @@ fn sample_files_candidate_row() -> StoredMinimalCandidateSelectionRow {
priority: 1,
api_formats: Some(vec!["gemini:files".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
diff --git a/apps/aether-gateway/src/tests/frontdoor.rs b/apps/aether-gateway/src/tests/frontdoor.rs
index 11a59093e..15ae3a645 100644
--- a/apps/aether-gateway/src/tests/frontdoor.rs
+++ b/apps/aether-gateway/src/tests/frontdoor.rs
@@ -291,6 +291,7 @@ fn sample_models_candidate_row(
priority: 1,
api_formats: Some(vec![api_format.to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
diff --git a/apps/aether-gateway/src/tests/frontdoor/ai.rs b/apps/aether-gateway/src/tests/frontdoor/ai.rs
index 7e4951a61..2ddf5b6ab 100644
--- a/apps/aether-gateway/src/tests/frontdoor/ai.rs
+++ b/apps/aether-gateway/src/tests/frontdoor/ai.rs
@@ -74,6 +74,7 @@ fn sample_codex_models_candidate_row(
priority: 1,
api_formats: Some(vec!["openai:responses".to_string()]),
endpoint_ids: None,
+ operations: None,
},
]);
row
diff --git a/apps/aether-gateway/src/tests/usage.rs b/apps/aether-gateway/src/tests/usage.rs
index b5c78b0a8..77e865e61 100644
--- a/apps/aether-gateway/src/tests/usage.rs
+++ b/apps/aether-gateway/src/tests/usage.rs
@@ -101,6 +101,7 @@ pub(super) fn sample_local_openai_candidate_row() -> StoredMinimalCandidateSelec
priority: 1,
api_formats: Some(vec!["openai:chat".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
diff --git a/apps/aether-gateway/src/tests/usage/local.rs b/apps/aether-gateway/src/tests/usage/local.rs
index 37135c491..ea8a63ec4 100644
--- a/apps/aether-gateway/src/tests/usage/local.rs
+++ b/apps/aether-gateway/src/tests/usage/local.rs
@@ -1611,6 +1611,7 @@ async fn gateway_records_failed_usage_when_all_local_claude_cli_candidates_are_s
priority: 1,
api_formats: Some(vec!["openai:responses".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
@@ -1926,6 +1927,7 @@ fn gateway_keeps_failed_usage_request_capture_lightweight_for_large_local_claude
priority: 1,
api_formats: Some(vec!["openai:responses".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
diff --git a/apps/aether-gateway/src/tests/usage/pricing.rs b/apps/aether-gateway/src/tests/usage/pricing.rs
index d277521a3..6a6986599 100644
--- a/apps/aether-gateway/src/tests/usage/pricing.rs
+++ b/apps/aether-gateway/src/tests/usage/pricing.rs
@@ -268,6 +268,7 @@ fn sample_candidate_row(spec: ProviderSpec) -> StoredMinimalCandidateSelectionRo
priority: 1,
api_formats: Some(vec![spec.api_format.to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(true),
model_is_active: true,
diff --git a/apps/aether-gateway/src/tests/video/gemini_sync_create.rs b/apps/aether-gateway/src/tests/video/gemini_sync_create.rs
index ae96d6242..3a6db5bd8 100644
--- a/apps/aether-gateway/src/tests/video/gemini_sync_create.rs
+++ b/apps/aether-gateway/src/tests/video/gemini_sync_create.rs
@@ -110,6 +110,7 @@ async fn gateway_executes_gemini_video_create_via_local_decision_gate_with_local
priority: 1,
api_formats: Some(vec!["gemini:video".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(false),
model_is_active: true,
diff --git a/apps/aether-gateway/src/tests/video/openai_sync_create.rs b/apps/aether-gateway/src/tests/video/openai_sync_create.rs
index a15e0ef1a..536c1939a 100644
--- a/apps/aether-gateway/src/tests/video/openai_sync_create.rs
+++ b/apps/aether-gateway/src/tests/video/openai_sync_create.rs
@@ -115,6 +115,7 @@ async fn gateway_executes_openai_video_create_via_local_decision_gate_with_local
priority: 1,
api_formats: Some(vec!["openai:video".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: Some(false),
model_is_active: true,
diff --git a/crates/aether-admin/src/observability/usage.rs b/crates/aether-admin/src/observability/usage.rs
index 5ac2f2cb0..36ff451c5 100644
--- a/crates/aether-admin/src/observability/usage.rs
+++ b/crates/aether-admin/src/observability/usage.rs
@@ -1188,6 +1188,7 @@ fn admin_usage_active_request_json(
let mut value = json!({
"id": item.id,
"status": item.status,
+ "request_type": item.request_type,
"input_tokens": item.input_tokens,
"effective_input_tokens": admin_usage_effective_input_tokens(item),
"output_tokens": item.output_tokens,
@@ -1304,6 +1305,7 @@ pub fn admin_usage_record_json(
"status_code": item.status_code,
"error_message": item.error_message,
"status": item.status,
+ "request_type": item.request_type,
"has_fallback": admin_usage_has_fallback(item),
"has_retry": false,
"has_rectified": false,
diff --git a/crates/aether-ai-formats/src/formats/openai/responses/mod.rs b/crates/aether-ai-formats/src/formats/openai/responses/mod.rs
index 16e73a0c7..7fca6658e 100644
--- a/crates/aether-ai-formats/src/formats/openai/responses/mod.rs
+++ b/crates/aether-ai-formats/src/formats/openai/responses/mod.rs
@@ -8,6 +8,34 @@ pub mod stream;
const TOOL_ERROR_PREFIX: &str = "[tool error]";
+/// Semantic operation carried by an OpenAI Responses request that asks the
+/// service to compact a thread. The request still uses the Responses wire
+/// contract and transport endpoint.
+pub const OPENAI_RESPONSES_OPERATION_COMPACT: &str = "compact";
+
+/// Resolves the operation expressed by an OpenAI Responses wire request.
+///
+/// `responses_compaction_v2` is represented by a `compaction_trigger` input
+/// item on the normal Responses request. The legacy Compact API format is
+/// retained as the same operation for observability and scoped model mapping.
+pub fn openai_responses_request_operation(api_format: &str, body: &Value) -> Option<&'static str> {
+ if aether_ai_formats::is_openai_responses_compact_format(api_format) {
+ return Some(OPENAI_RESPONSES_OPERATION_COMPACT);
+ }
+ if !aether_ai_formats::is_openai_responses_format(api_format) {
+ return None;
+ }
+
+ body.get("input")
+ .and_then(Value::as_array)
+ .is_some_and(|items| {
+ items
+ .iter()
+ .any(|item| item.get("type").and_then(Value::as_str) == Some("compaction_trigger"))
+ })
+ .then_some(OPENAI_RESPONSES_OPERATION_COMPACT)
+}
+
fn encode_tool_result_error(output: Value, is_error: bool) -> Value {
if !is_error {
return output;
@@ -23,3 +51,41 @@ fn encode_tool_result_error(output: Value, is_error: bool) -> Value {
Value::String(format!("{TOOL_ERROR_PREFIX}\n{detail}"))
}
}
+
+#[cfg(test)]
+mod tests {
+ use serde_json::json;
+
+ use super::{openai_responses_request_operation, OPENAI_RESPONSES_OPERATION_COMPACT};
+
+ #[test]
+ fn resolves_compaction_trigger_as_compact_operation_on_responses_transport() {
+ assert_eq!(
+ openai_responses_request_operation(
+ "openai:responses",
+ &json!({
+ "input": [
+ {"role": "user", "content": "keep working"},
+ {"type": "compaction_trigger"}
+ ]
+ }),
+ ),
+ Some(OPENAI_RESPONSES_OPERATION_COMPACT)
+ );
+ assert_eq!(
+ openai_responses_request_operation(
+ "openai:responses",
+ &json!({"input": [{"role": "user", "content": "keep working"}]}),
+ ),
+ None
+ );
+ }
+
+ #[test]
+ fn resolves_legacy_compact_contract_without_a_body_marker() {
+ assert_eq!(
+ openai_responses_request_operation("openai:responses:compact", &json!({})),
+ Some(OPENAI_RESPONSES_OPERATION_COMPACT)
+ );
+ }
+}
diff --git a/crates/aether-ai-formats/src/lib.rs b/crates/aether-ai-formats/src/lib.rs
index b5ce22cf9..892943480 100644
--- a/crates/aether-ai-formats/src/lib.rs
+++ b/crates/aether-ai-formats/src/lib.rs
@@ -49,6 +49,9 @@ pub use formats::openai::responses::codex::{
pub use formats::openai::responses::request::{
validate_openai_responses_request_contract, OpenAiResponsesRequestContractViolation,
};
+pub use formats::openai::responses::{
+ openai_responses_request_operation, OPENAI_RESPONSES_OPERATION_COMPACT,
+};
pub use formats::registry::{
build_stream_transcoder, convert_request, convert_request_pure,
convert_request_pure_with_context, convert_response, convert_response_pure, emit_request_pure,
diff --git a/crates/aether-data-contracts/src/repository/candidate_selection/types.rs b/crates/aether-data-contracts/src/repository/candidate_selection/types.rs
index 309773285..46f160b6c 100644
--- a/crates/aether-data-contracts/src/repository/candidate_selection/types.rs
+++ b/crates/aether-data-contracts/src/repository/candidate_selection/types.rs
@@ -7,6 +7,10 @@ pub struct StoredProviderModelMapping {
pub api_formats: Option>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub endpoint_ids: Option>,
+ /// Optional request-operation scope. An omitted scope applies to every
+ /// operation supported by the selected API format.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub operations: Option>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
diff --git a/crates/aether-data/src/repository/candidate_selection/memory.rs b/crates/aether-data/src/repository/candidate_selection/memory.rs
index 4b136bba4..25b811606 100644
--- a/crates/aether-data/src/repository/candidate_selection/memory.rs
+++ b/crates/aether-data/src/repository/candidate_selection/memory.rs
@@ -426,6 +426,7 @@ mod tests {
priority: 0,
api_formats: Some(vec!["openai:chat".to_string()]),
endpoint_ids: None,
+ operations: None,
}]);
let repository = InMemoryMinimalCandidateSelectionReadRepository::seed(vec![
mapped,
@@ -458,6 +459,7 @@ mod tests {
priority: 0,
api_formats: Some(vec!["openai:responses".to_string()]),
endpoint_ids: None,
+ operations: None,
}]);
let mut responses = search.clone();
@@ -521,6 +523,7 @@ mod tests {
priority: 1,
api_formats: None,
endpoint_ids: Some(vec!["endpoint-openai".to_string()]),
+ operations: None,
}]);
let mut scoped_out = selected.clone();
diff --git a/crates/aether-data/src/repository/candidate_selection/mysql.rs b/crates/aether-data/src/repository/candidate_selection/mysql.rs
index 9ae01cb74..5fe1eedcb 100644
--- a/crates/aether-data/src/repository/candidate_selection/mysql.rs
+++ b/crates/aether-data/src/repository/candidate_selection/mysql.rs
@@ -677,6 +677,7 @@ fn parse_embedded_provider_model_mappings(
priority: 1,
api_formats: None,
endpoint_ids: None,
+ operations: None,
}]))
}
@@ -697,6 +698,7 @@ fn parse_provider_model_mappings_array(
priority: 1,
api_formats: None,
endpoint_ids: None,
+ operations: None,
});
}
_ => {}
@@ -741,6 +743,11 @@ fn parse_provider_model_mapping_object_lenient(
object.get("endpoint_ids").cloned(),
"models.provider_model_mappings.endpoint_ids",
)?;
+ let operations = parse_string_list(
+ object.get("operations").cloned(),
+ "models.provider_model_mappings.operations",
+ )?
+ .and_then(normalize_request_operations);
Ok(Some(StoredProviderModelMapping {
name: name.to_string(),
@@ -751,9 +758,19 @@ fn parse_provider_model_mapping_object_lenient(
})?,
api_formats,
endpoint_ids,
+ operations,
}))
}
+fn normalize_request_operations(values: Vec) -> Option> {
+ let operations = values
+ .into_iter()
+ .map(|value| value.trim().to_ascii_lowercase())
+ .filter(|value| !value.is_empty())
+ .collect::>();
+ (!operations.is_empty()).then_some(operations)
+}
+
fn api_format_aliases(api_format: &str) -> Vec {
aether_ai_formats::api_format_storage_aliases(api_format)
}
diff --git a/crates/aether-data/src/repository/candidate_selection/postgres.rs b/crates/aether-data/src/repository/candidate_selection/postgres.rs
index ccf7d5835..2759d119c 100644
--- a/crates/aether-data/src/repository/candidate_selection/postgres.rs
+++ b/crates/aether-data/src/repository/candidate_selection/postgres.rs
@@ -1350,6 +1350,7 @@ fn parse_embedded_provider_model_mappings(
priority: 1,
api_formats: None,
endpoint_ids: None,
+ operations: None,
}]))
}
@@ -1372,6 +1373,7 @@ fn parse_provider_model_mappings_array(
priority: 1,
api_formats: None,
endpoint_ids: None,
+ operations: None,
});
}
}
@@ -1428,6 +1430,11 @@ fn parse_provider_model_mapping_object_lenient(
object.get("endpoint_ids").cloned(),
"models.provider_model_mappings.endpoint_ids",
)?;
+ let operations = parse_string_list(
+ object.get("operations").cloned(),
+ "models.provider_model_mappings.operations",
+ )?
+ .and_then(normalize_request_operations);
Ok(Some(StoredProviderModelMapping {
name: name.to_string(),
@@ -1438,9 +1445,19 @@ fn parse_provider_model_mapping_object_lenient(
})?,
api_formats,
endpoint_ids,
+ operations,
}))
}
+fn normalize_request_operations(values: Vec) -> Option> {
+ let operations = values
+ .into_iter()
+ .map(|value| value.trim().to_ascii_lowercase())
+ .filter(|value| !value.is_empty())
+ .collect::>();
+ (!operations.is_empty()).then_some(operations)
+}
+
#[cfg(test)]
mod tests {
use serde_json::json;
@@ -1624,7 +1641,7 @@ mod tests {
#[test]
fn parse_provider_model_mappings_accepts_stringified_array() {
let parsed = parse_provider_model_mappings(Some(json!(
- "[{\"name\":\"gpt-5.2\",\"priority\":2,\"api_formats\":[\"openai:chat\"]}]"
+ "[{\"name\":\"gpt-5.2\",\"priority\":2,\"api_formats\":[\"openai:chat\"],\"operations\":[\"COMPACT\"]}]"
)))
.expect("stringified provider_model_mappings should parse");
@@ -1635,6 +1652,7 @@ mod tests {
priority: 2,
api_formats: Some(vec!["openai:chat".to_string()]),
endpoint_ids: None,
+ operations: Some(vec!["compact".to_string()]),
}])
);
}
@@ -1651,6 +1669,7 @@ mod tests {
priority: 1,
api_formats: None,
endpoint_ids: None,
+ operations: None,
}])
);
}
@@ -1674,12 +1693,14 @@ mod tests {
priority: 1,
api_formats: None,
endpoint_ids: None,
+ operations: None,
},
StoredProviderModelMapping {
name: "gpt-5.2-mini".to_string(),
priority: 1,
api_formats: None,
endpoint_ids: None,
+ operations: None,
}
])
);
diff --git a/crates/aether-data/src/repository/candidate_selection/sqlite.rs b/crates/aether-data/src/repository/candidate_selection/sqlite.rs
index 4190d56af..4fda5f725 100644
--- a/crates/aether-data/src/repository/candidate_selection/sqlite.rs
+++ b/crates/aether-data/src/repository/candidate_selection/sqlite.rs
@@ -1070,6 +1070,7 @@ fn parse_embedded_provider_model_mappings(
priority: 1,
api_formats: None,
endpoint_ids: None,
+ operations: None,
}]))
}
@@ -1090,6 +1091,7 @@ fn parse_provider_model_mappings_array(
priority: 1,
api_formats: None,
endpoint_ids: None,
+ operations: None,
});
}
_ => {}
@@ -1134,6 +1136,11 @@ fn parse_provider_model_mapping_object_lenient(
object.get("endpoint_ids").cloned(),
"models.provider_model_mappings.endpoint_ids",
)?;
+ let operations = parse_string_list(
+ object.get("operations").cloned(),
+ "models.provider_model_mappings.operations",
+ )?
+ .and_then(normalize_request_operations);
Ok(Some(StoredProviderModelMapping {
name: name.to_string(),
@@ -1144,9 +1151,19 @@ fn parse_provider_model_mapping_object_lenient(
})?,
api_formats,
endpoint_ids,
+ operations,
}))
}
+fn normalize_request_operations(values: Vec) -> Option> {
+ let operations = values
+ .into_iter()
+ .map(|value| value.trim().to_ascii_lowercase())
+ .filter(|value| !value.is_empty())
+ .collect::>();
+ (!operations.is_empty()).then_some(operations)
+}
+
fn api_format_aliases(api_format: &str) -> Vec {
aether_ai_formats::api_format_storage_aliases(api_format)
}
@@ -1211,6 +1228,14 @@ mod tests {
Some(vec!["alias-global".to_string()])
);
assert_eq!(rows[1].global_model_supports_streaming, Some(true));
+ assert_eq!(
+ rows[1]
+ .model_provider_model_mappings
+ .as_ref()
+ .and_then(|mappings| mappings.first())
+ .and_then(|mapping| mapping.operations.as_ref()),
+ Some(&vec!["compact".to_string()])
+ );
let requested = repository
.list_for_exact_api_format_and_requested_model_page(
@@ -1392,7 +1417,7 @@ INSERT INTO models (
)
VALUES (
'model-1', 'provider-1', 'global-1', 'provider-model',
- '[{"name":"alias-provider","api_formats":["openai:chat"],"priority":1}]',
+ '[{"name":"alias-provider","api_formats":["openai:chat"],"operations":["COMPACT"],"priority":1}]',
1, 1, 1, 1, 1
),
(
diff --git a/crates/aether-scheduler-core/src/candidate/enumeration.rs b/crates/aether-scheduler-core/src/candidate/enumeration.rs
index 07472e545..892bed4aa 100644
--- a/crates/aether-scheduler-core/src/candidate/enumeration.rs
+++ b/crates/aether-scheduler-core/src/candidate/enumeration.rs
@@ -27,6 +27,7 @@ fn enumerate_minimal_candidate_selection_inner(
let EnumerateMinimalCandidateSelectionInput {
rows,
normalized_api_format,
+ request_operation,
requested_model_name,
resolved_global_model_name,
require_streaming,
@@ -63,11 +64,12 @@ fn enumerate_minimal_candidate_selection_inner(
continue;
}
let Some((selected_provider_model_name, mapping_matched_model)) =
- crate::resolve_provider_model_name_with_model_directives(
+ crate::resolve_provider_model_name_with_model_directives_and_request_operation(
&row,
requested_model_name,
normalized_api_format,
enable_model_directives,
+ request_operation,
)
else {
continue;
diff --git a/crates/aether-scheduler-core/src/candidate/mod.rs b/crates/aether-scheduler-core/src/candidate/mod.rs
index 579102284..d27642b5b 100644
--- a/crates/aether-scheduler-core/src/candidate/mod.rs
+++ b/crates/aether-scheduler-core/src/candidate/mod.rs
@@ -71,6 +71,7 @@ mod tests {
priority: 1,
api_formats: Some(vec!["openai:chat".to_string()]),
endpoint_ids: None,
+ operations: None,
}]),
model_supports_streaming: None,
model_is_active: true,
@@ -199,6 +200,7 @@ mod tests {
super::enumerate_minimal_candidate_selection(EnumerateMinimalCandidateSelectionInput {
rows: vec![sample_row("1"), disallowed],
normalized_api_format: "openai:chat",
+ request_operation: None,
requested_model_name: "gpt-5",
resolved_global_model_name: "gpt-5",
require_streaming: false,
@@ -221,6 +223,7 @@ mod tests {
super::enumerate_minimal_candidate_selection(EnumerateMinimalCandidateSelectionInput {
rows: vec![row],
normalized_api_format: "openai:chat",
+ request_operation: None,
requested_model_name: "gpt-5",
resolved_global_model_name: "gpt-5",
require_streaming: false,
@@ -244,6 +247,7 @@ mod tests {
super::enumerate_minimal_candidate_selection(EnumerateMinimalCandidateSelectionInput {
rows: vec![later_priority, earlier_priority],
normalized_api_format: "openai:chat",
+ request_operation: None,
requested_model_name: "gpt-5",
resolved_global_model_name: "gpt-5",
require_streaming: false,
@@ -295,6 +299,7 @@ mod tests {
super::enumerate_minimal_candidate_selection(EnumerateMinimalCandidateSelectionInput {
rows: vec![missing_capability, matching_capability],
normalized_api_format: "openai:chat",
+ request_operation: None,
requested_model_name: "gpt-5",
resolved_global_model_name: "gpt-5",
require_streaming: false,
diff --git a/crates/aether-scheduler-core/src/candidate/types.rs b/crates/aether-scheduler-core/src/candidate/types.rs
index dc6191e08..7aaff708b 100644
--- a/crates/aether-scheduler-core/src/candidate/types.rs
+++ b/crates/aether-scheduler-core/src/candidate/types.rs
@@ -32,6 +32,7 @@ pub struct SchedulerMinimalCandidateSelectionCandidate {
pub struct EnumerateMinimalCandidateSelectionInput<'a> {
pub rows: Vec,
pub normalized_api_format: &'a str,
+ pub request_operation: Option<&'a str>,
pub requested_model_name: &'a str,
pub resolved_global_model_name: &'a str,
pub require_streaming: bool,
diff --git a/crates/aether-scheduler-core/src/lib.rs b/crates/aether-scheduler-core/src/lib.rs
index ae6b26159..3fbfe0498 100644
--- a/crates/aether-scheduler-core/src/lib.rs
+++ b/crates/aether-scheduler-core/src/lib.rs
@@ -40,10 +40,13 @@ pub use health::{
pub use model::{
candidate_model_names, extract_global_priority_for_format, matches_model_mapping,
normalize_api_format, resolve_provider_model_name,
- resolve_provider_model_name_with_model_directives, resolve_requested_global_model_name,
- resolve_requested_global_model_name_with_model_directives, row_supports_requested_model,
- row_supports_requested_model_with_model_directives, row_supports_required_capability,
- select_provider_model_name,
+ resolve_provider_model_name_with_model_directives,
+ resolve_provider_model_name_with_model_directives_and_request_operation,
+ resolve_requested_global_model_name, resolve_requested_global_model_name_with_model_directives,
+ resolve_requested_global_model_name_with_model_directives_and_request_operation,
+ row_supports_requested_model, row_supports_requested_model_with_model_directives,
+ row_supports_requested_model_with_model_directives_and_request_operation,
+ row_supports_required_capability, select_provider_model_name,
};
pub use provider::{build_provider_concurrent_limit_map, should_skip_provider_quota};
pub use ranking::{
diff --git a/crates/aether-scheduler-core/src/model.rs b/crates/aether-scheduler-core/src/model.rs
index f143645f2..90f2f4bbc 100644
--- a/crates/aether-scheduler-core/src/model.rs
+++ b/crates/aether-scheduler-core/src/model.rs
@@ -25,17 +25,33 @@ pub fn resolve_requested_global_model_name_with_model_directives(
requested_model_name: &str,
api_format: &str,
enable_model_directives: bool,
+) -> Option {
+ resolve_requested_global_model_name_with_model_directives_and_request_operation(
+ rows,
+ requested_model_name,
+ api_format,
+ enable_model_directives,
+ None,
+ )
+}
+
+pub fn resolve_requested_global_model_name_with_model_directives_and_request_operation(
+ rows: &[StoredMinimalCandidateSelectionRow],
+ requested_model_name: &str,
+ api_format: &str,
+ enable_model_directives: bool,
+ request_operation: Option<&str>,
) -> Option {
requested_model_name_candidates(requested_model_name, enable_model_directives).find_map(
|requested_model_name| {
let requested_model_name = requested_model_name.as_ref();
resolve_global_model_name_by(rows, |row| {
- row_has_available_provider_model(row, api_format)
+ row_has_available_provider_model(row, api_format, request_operation)
&& row.global_model_name == requested_model_name
})
.or_else(|| {
resolve_global_model_name_by(rows, |row| {
- row_default_provider_model_name_available(row, api_format)
+ row_default_provider_model_name_available(row, api_format, request_operation)
&& row.model_provider_model_name == requested_model_name
})
})
@@ -45,7 +61,7 @@ pub fn resolve_requested_global_model_name_with_model_directives(
.as_ref()
.is_some_and(|mappings| {
mappings.iter().any(|mapping| {
- mapping_scope_matches(mapping, row, api_format)
+ mapping_scope_matches(mapping, row, api_format, request_operation)
&& mapping.name == requested_model_name
})
})
@@ -53,7 +69,7 @@ pub fn resolve_requested_global_model_name_with_model_directives(
})
.or_else(|| {
resolve_global_model_name_by(rows, |row| {
- row_has_available_provider_model(row, api_format)
+ row_has_available_provider_model(row, api_format, request_operation)
&& row.global_model_mappings.as_ref().is_some_and(|patterns| {
patterns
.iter()
@@ -78,10 +94,31 @@ pub fn row_supports_requested_model_with_model_directives(
requested_model_name: &str,
api_format: &str,
enable_model_directives: bool,
+) -> bool {
+ row_supports_requested_model_with_model_directives_and_request_operation(
+ row,
+ requested_model_name,
+ api_format,
+ enable_model_directives,
+ None,
+ )
+}
+
+pub fn row_supports_requested_model_with_model_directives_and_request_operation(
+ row: &StoredMinimalCandidateSelectionRow,
+ requested_model_name: &str,
+ api_format: &str,
+ enable_model_directives: bool,
+ request_operation: Option<&str>,
) -> bool {
requested_model_name_candidates(requested_model_name, enable_model_directives).any(
|requested_model_name| {
- row_supports_requested_model_exact(row, requested_model_name.as_ref(), api_format)
+ row_supports_requested_model_exact(
+ row,
+ requested_model_name.as_ref(),
+ api_format,
+ request_operation,
+ )
},
)
}
@@ -90,10 +127,11 @@ fn row_supports_requested_model_exact(
row: &StoredMinimalCandidateSelectionRow,
requested_model_name: &str,
api_format: &str,
+ request_operation: Option<&str>,
) -> bool {
- row_has_available_provider_model(row, api_format)
+ row_has_available_provider_model(row, api_format, request_operation)
&& (row.global_model_name == requested_model_name
- || (row_default_provider_model_name_available(row, api_format)
+ || (row_default_provider_model_name_available(row, api_format, request_operation)
&& row.model_provider_model_name == requested_model_name)
|| row.global_model_mappings.as_ref().is_some_and(|patterns| {
patterns
@@ -105,7 +143,7 @@ fn row_supports_requested_model_exact(
.as_ref()
.is_some_and(|mappings| {
mappings.iter().any(|mapping| {
- mapping_scope_matches(mapping, row, api_format)
+ mapping_scope_matches(mapping, row, api_format, request_operation)
&& mapping.name == requested_model_name
})
})
@@ -145,7 +183,24 @@ pub fn resolve_provider_model_name_with_model_directives(
api_format: &str,
enable_model_directives: bool,
) -> Option<(String, Option)> {
- let selected_provider_model_name = resolve_selected_provider_model_name(row, api_format)?;
+ resolve_provider_model_name_with_model_directives_and_request_operation(
+ row,
+ requested_model_name,
+ api_format,
+ enable_model_directives,
+ None,
+ )
+}
+
+pub fn resolve_provider_model_name_with_model_directives_and_request_operation(
+ row: &StoredMinimalCandidateSelectionRow,
+ requested_model_name: &str,
+ api_format: &str,
+ enable_model_directives: bool,
+ request_operation: Option<&str>,
+) -> Option<(String, Option)> {
+ let selected_provider_model_name =
+ resolve_selected_provider_model_name(row, api_format, request_operation)?;
let Some(key_allowed_models) = row.key_allowed_models.as_ref() else {
return Some((selected_provider_model_name, None));
};
@@ -175,7 +230,7 @@ pub fn resolve_provider_model_name_with_model_directives(
sorted_allowed_models.sort_unstable();
for &allowed_model in &sorted_allowed_models {
- if row_has_candidate_model_name(row, api_format, allowed_model) {
+ if row_has_candidate_model_name(row, api_format, request_operation, allowed_model) {
let allowed_model = allowed_model.to_owned();
return Some((selected_provider_model_name.clone(), Some(allowed_model)));
}
@@ -198,13 +253,14 @@ pub fn select_provider_model_name(
row: &StoredMinimalCandidateSelectionRow,
api_format: &str,
) -> String {
- resolve_selected_provider_model_name(row, api_format)
+ resolve_selected_provider_model_name(row, api_format, None)
.unwrap_or_else(|| row.model_provider_model_name.clone())
}
fn resolve_selected_provider_model_name(
row: &StoredMinimalCandidateSelectionRow,
api_format: &str,
+ request_operation: Option<&str>,
) -> Option {
let Some(mappings) = row.model_provider_model_mappings.as_ref() else {
return Some(row.model_provider_model_name.clone());
@@ -212,17 +268,20 @@ fn resolve_selected_provider_model_name(
if let Some(mapping) = mappings
.iter()
- .filter(|mapping| mapping_scope_matches(mapping, row, api_format))
+ .filter(|mapping| mapping_scope_matches(mapping, row, api_format, request_operation))
.min_by(|left, right| {
left.priority
.cmp(&right.priority)
+ .then_with(|| {
+ mapping_operation_scope_rank(right).cmp(&mapping_operation_scope_rank(left))
+ })
.then(left.name.cmp(&right.name))
})
{
return Some(mapping.name.clone());
}
- row_default_provider_model_name_available(row, api_format)
+ row_default_provider_model_name_available(row, api_format, request_operation)
.then(|| row.model_provider_model_name.clone())
}
@@ -231,12 +290,12 @@ pub fn candidate_model_names(
api_format: &str,
) -> BTreeSet {
let mut names = BTreeSet::new();
- if row_default_provider_model_name_available(row, api_format) {
+ if row_default_provider_model_name_available(row, api_format, None) {
names.insert(row.model_provider_model_name.clone());
}
if let Some(mappings) = row.model_provider_model_mappings.as_ref() {
for mapping in mappings {
- if mapping_scope_matches(mapping, row, api_format) {
+ if mapping_scope_matches(mapping, row, api_format, None) {
names.insert(mapping.name.clone());
}
}
@@ -247,13 +306,15 @@ pub fn candidate_model_names(
fn row_has_available_provider_model(
row: &StoredMinimalCandidateSelectionRow,
api_format: &str,
+ request_operation: Option<&str>,
) -> bool {
- resolve_selected_provider_model_name(row, api_format).is_some()
+ resolve_selected_provider_model_name(row, api_format, request_operation).is_some()
}
fn row_default_provider_model_name_available(
row: &StoredMinimalCandidateSelectionRow,
api_format: &str,
+ request_operation: Option<&str>,
) -> bool {
let Some(mappings) = row.model_provider_model_mappings.as_ref() else {
return true;
@@ -264,7 +325,7 @@ fn row_default_provider_model_name_available(
continue;
}
has_explicit_default_mapping = true;
- if mapping_scope_matches(mapping, row, api_format) {
+ if mapping_scope_matches(mapping, row, api_format, request_operation) {
return true;
}
}
@@ -275,6 +336,7 @@ fn mapping_scope_matches(
mapping: &StoredProviderModelMapping,
row: &StoredMinimalCandidateSelectionRow,
api_format: &str,
+ request_operation: Option<&str>,
) -> bool {
let api_format_matches_scope = mapping.api_formats.as_ref().is_none_or(|api_formats| {
api_formats
@@ -285,13 +347,28 @@ fn mapping_scope_matches(
return false;
}
- mapping.endpoint_ids.as_ref().is_none_or(|endpoint_ids| {
+ let endpoint_matches_scope = mapping.endpoint_ids.as_ref().is_none_or(|endpoint_ids| {
endpoint_ids
.iter()
.any(|endpoint_id| endpoint_id == &row.endpoint_id)
+ });
+ if !endpoint_matches_scope {
+ return false;
+ }
+
+ mapping.operations.as_ref().is_none_or(|operations| {
+ request_operation.is_some_and(|request_operation| {
+ operations
+ .iter()
+ .any(|operation| operation.eq_ignore_ascii_case(request_operation))
+ })
})
}
+fn mapping_operation_scope_rank(mapping: &StoredProviderModelMapping) -> u8 {
+ u8::from(mapping.operations.is_some())
+}
+
pub fn row_supports_required_capability(
row: &StoredMinimalCandidateSelectionRow,
required_capability: &str,
@@ -401,16 +478,18 @@ pub fn normalize_api_format(value: &str) -> String {
fn row_has_candidate_model_name(
row: &StoredMinimalCandidateSelectionRow,
api_format: &str,
+ request_operation: Option<&str>,
model_name: &str,
) -> bool {
- (row_default_provider_model_name_available(row, api_format)
+ (row_default_provider_model_name_available(row, api_format, request_operation)
&& row.model_provider_model_name == model_name)
|| row
.model_provider_model_mappings
.as_ref()
.is_some_and(|mappings| {
mappings.iter().any(|mapping| {
- mapping_scope_matches(mapping, row, api_format) && mapping.name == model_name
+ mapping_scope_matches(mapping, row, api_format, request_operation)
+ && mapping.name == model_name
})
})
}
@@ -484,6 +563,7 @@ mod tests {
use super::{
matches_model_mapping, resolve_provider_model_name,
resolve_provider_model_name_with_model_directives,
+ resolve_provider_model_name_with_model_directives_and_request_operation,
resolve_requested_global_model_name_with_model_directives, row_supports_requested_model,
row_supports_requested_model_with_model_directives,
};
@@ -518,6 +598,7 @@ mod tests {
priority: 1,
api_formats: Some(vec!["openai:chat".to_string()]),
endpoint_ids: None,
+ operations: None,
}]);
let resolved = resolve_provider_model_name(&row, "gpt-5", "openai:chat")
@@ -581,6 +662,7 @@ mod tests {
priority: 1,
api_formats: Some(vec!["openai:responses".to_string()]),
endpoint_ids: None,
+ operations: None,
}]);
assert!(row_supports_requested_model(
@@ -599,6 +681,7 @@ mod tests {
priority: 1,
api_formats: Some(vec!["openai:search".to_string()]),
endpoint_ids: None,
+ operations: None,
}]);
assert!(!row_supports_requested_model(
&row,
@@ -607,6 +690,51 @@ mod tests {
));
}
+ #[test]
+ fn operation_scoped_mapping_overrides_generic_mapping_for_compaction() {
+ let mut row = sample_row("gpt-5.6-sol", "gpt-5.6-sol");
+ row.endpoint_api_format = "openai:responses".to_string();
+ row.model_provider_model_mappings = Some(vec![
+ StoredProviderModelMapping {
+ name: "gpt-5.6-sol".to_string(),
+ priority: 1,
+ api_formats: Some(vec!["openai:responses".to_string()]),
+ endpoint_ids: None,
+ operations: None,
+ },
+ StoredProviderModelMapping {
+ name: "gpt-5.6-terra".to_string(),
+ priority: 1,
+ api_formats: Some(vec!["openai:responses".to_string()]),
+ endpoint_ids: None,
+ operations: Some(vec!["compact".to_string()]),
+ },
+ ]);
+
+ assert_eq!(
+ resolve_provider_model_name_with_model_directives_and_request_operation(
+ &row,
+ "gpt-5.6-sol",
+ "openai:responses",
+ false,
+ None,
+ )
+ .map(|resolved| resolved.0),
+ Some("gpt-5.6-sol".to_string())
+ );
+ assert_eq!(
+ resolve_provider_model_name_with_model_directives_and_request_operation(
+ &row,
+ "gpt-5.6-sol",
+ "openai:responses",
+ false,
+ Some("compact"),
+ )
+ .map(|resolved| resolved.0),
+ Some("gpt-5.6-terra".to_string())
+ );
+ }
+
#[test]
fn model_directive_suffix_prefers_exact_model_before_base_fallback() {
let exact = sample_row("gpt-5.4-high", "gpt-5.4-high-upstream");
@@ -685,6 +813,7 @@ mod tests {
priority: 1,
api_formats: None,
endpoint_ids: Some(vec!["endpoint-openai".to_string()]),
+ operations: None,
}]);
assert!(!row_supports_requested_model(
diff --git a/crates/aether-usage-runtime/src/write.rs b/crates/aether-usage-runtime/src/write.rs
index aa6e572a5..f2ab5ca93 100644
--- a/crates/aether-usage-runtime/src/write.rs
+++ b/crates/aether-usage-runtime/src/write.rs
@@ -254,8 +254,13 @@ pub fn build_lifecycle_usage_seed(
let model = context_string(context, "model")
.or_else(|| non_empty_str(plan.model_name.as_deref()))
.unwrap_or_else(|| "unknown".to_string());
- let request_type =
- infer_request_type_from_contracts(api_format.as_deref(), endpoint_api_format.as_deref());
+ let provider_request = context_body_value(context, "provider_request_body")
+ .or_else(|| plan_json_body_capture_for_usage(plan));
+ let request_type = infer_request_type_from_contracts(
+ api_format.as_deref(),
+ endpoint_api_format.as_deref(),
+ provider_request.as_ref(),
+ );
let api_family = api_format
.as_deref()
.and_then(infer_api_family)
@@ -804,6 +809,7 @@ pub fn build_terminal_usage_context_seed(
let request_type = infer_request_type_from_contracts(
Some(client_contract.as_str()),
Some(provider_contract.as_str()),
+ request_capture.provider_request.as_ref(),
);
let has_format_conversion = resolve_has_format_conversion(
context,
@@ -1697,6 +1703,7 @@ fn build_usage_event_data_seed_with_detail(
let request_type = Some(infer_request_type_from_contracts(
api_format.as_deref(),
endpoint_api_format.as_deref(),
+ request_capture.provider_request.as_ref(),
));
let api_family = api_format
.as_deref()
@@ -2467,7 +2474,20 @@ fn infer_request_type(api_format: Option<&str>) -> String {
fn infer_request_type_from_contracts(
client_api_format: Option<&str>,
provider_api_format: Option<&str>,
+ provider_request: Option<&Value>,
) -> String {
+ let empty_body = Value::Null;
+ let provider_request = provider_request.unwrap_or(&empty_body);
+ for api_format in [provider_api_format, client_api_format]
+ .into_iter()
+ .flatten()
+ {
+ if let Some(operation) =
+ aether_ai_formats::openai_responses_request_operation(api_format, provider_request)
+ {
+ return operation.to_string();
+ }
+ }
if matches!(
infer_endpoint_kind(provider_api_format.unwrap_or_default()),
Some("image")
@@ -3711,7 +3731,9 @@ mod tests {
"candidate_id": "cand-pending-event-1",
"candidate_index": 3,
"original_request_body": {"messages": [{"content": "omit me"}]},
- "provider_request_body": {"input": "omit me too"}
+ "provider_request_body": {
+ "input": [{"type": "compaction_trigger"}]
+ }
})),
),
1_700_000_020,
@@ -3723,6 +3745,7 @@ mod tests {
assert_eq!(record.request_id, "req-pending-event-1");
assert_eq!(record.status, "pending");
assert_eq!(record.billing_status, "pending");
+ assert_eq!(record.request_type.as_deref(), Some("compact"));
assert_eq!(record.finalized_at_unix_secs, None);
assert_eq!(record.updated_at_unix_secs, 1_700_000_020);
assert!(record.request_body.is_none());
From b09d1f1c336cb4902b1e9437d448b75fc6fbddc0 Mon Sep 17 00:00:00 2001
From: MMEXA
Date: Sun, 12 Jul 2026 23:05:00 +0800
Subject: [PATCH 06/12] fix(usage): expose pending reasoning and exact reset
expiry
---
crates/aether-usage-runtime/src/write.rs | 24 +++++++++++++++++--
.../components/ProviderDetailDrawer.vue | 6 ++---
.../codex-reset-credit-display.spec.ts | 20 ++++++++++++----
.../components/codex-reset-credit-display.ts | 12 ++++++----
.../__tests__/UsageRecordsTable.spec.ts | 9 +++++++
5 files changed, 58 insertions(+), 13 deletions(-)
diff --git a/crates/aether-usage-runtime/src/write.rs b/crates/aether-usage-runtime/src/write.rs
index f2ab5ca93..751da9c00 100644
--- a/crates/aether-usage-runtime/src/write.rs
+++ b/crates/aether-usage-runtime/src/write.rs
@@ -2029,6 +2029,22 @@ fn build_runtime_request_metadata_seed(
provider_request_body_ref.as_deref(),
plan.body.body_bytes_b64.as_deref(),
);
+ let provider_request_body = plan.body.json_body.as_ref().or_else(|| {
+ context_value_ref(context, "provider_request_body").filter(|value| !value.is_null())
+ });
+ let provider_api_format = context_string(context, "provider_api_format")
+ .or_else(|| non_empty_str(Some(plan.provider_api_format.as_str())));
+ let provider_model = context_string(context, "mapped_model")
+ .or_else(|| non_empty_str(plan.model_name.as_deref()));
+ let source_model =
+ context_string(context, "model").or_else(|| non_empty_str(plan.model_name.as_deref()));
+ metadata = attach_provider_request_body_metadata(
+ metadata,
+ provider_api_format.as_deref(),
+ provider_model.as_deref(),
+ source_model.as_deref(),
+ provider_request_body,
+ );
if let Some(proxy) = plan.proxy.as_ref() {
if let Some(node_id) = proxy
.node_id
@@ -3625,7 +3641,8 @@ mod tests {
content_encoding: None,
body: RequestBody::from_json(json!({
"model": "gpt-5.4",
- "messages": [{"role": "user", "content": "hello"}]
+ "messages": [{"role": "user", "content": "hello"}],
+ "reasoning": {"effort": "max"}
})),
stream: false,
client_api_format: "claude:messages".to_string(),
@@ -3684,7 +3701,10 @@ mod tests {
.as_ref()
.and_then(Value::as_object)
.expect("pending usage should only keep lightweight request metadata");
- assert_eq!(metadata.len(), 1);
+ assert_eq!(
+ metadata.get("provider_reasoning_effort"),
+ Some(&json!("max"))
+ );
let body_size = metadata
.get("body_size")
.and_then(Value::as_object)
diff --git a/frontend/src/features/providers/components/ProviderDetailDrawer.vue b/frontend/src/features/providers/components/ProviderDetailDrawer.vue
index aaa318121..d18c4f4cc 100644
--- a/frontend/src/features/providers/components/ProviderDetailDrawer.vue
+++ b/frontend/src/features/providers/components/ProviderDetailDrawer.vue
@@ -310,7 +310,7 @@
:title="item.title"
class="tabular-nums"
>
- {{ item.displayKey }} {{ formatCodexResetCreditDays(item.remainingSeconds) }}
+ {{ item.displayKey }} {{ formatCodexResetCreditExpiresAt(item.expiresAt) }}
{
])
})
- it('formats reset credit remaining days with a one-day minimum', () => {
- expect(formatCodexResetCreditDays(1)).toBe('1天')
- expect(formatCodexResetCreditDays(86_401)).toBe('2天')
+ it('formats reset credit expiry as a precise local timestamp', () => {
+ const expiresAt = new Date(2026, 6, 12, 22, 4, 41).getTime() / 1000
+ expect(formatCodexResetCreditExpiresAt(expiresAt)).toBe('07-12 22:04:41')
+ expect(formatCodexResetCreditExpiresAt(null)).toBe('-')
+ })
+
+ it('derives a stable expiry timestamp from remaining seconds', () => {
+ const snapshot: QuotaResetCreditsSnapshot = {
+ available_count: 1,
+ updated_at: 1_700_000_000,
+ credits: [{ status: 'available', remaining_seconds: 600 }],
+ }
+
+ expect(getVisibleCodexResetCreditItems(snapshot, 1_700_000_300)[0]?.expiresAt)
+ .toBe(1_700_000_600)
})
})
diff --git a/frontend/src/features/providers/components/codex-reset-credit-display.ts b/frontend/src/features/providers/components/codex-reset-credit-display.ts
index 9cdc9edc6..9b89c2bd9 100644
--- a/frontend/src/features/providers/components/codex-reset-credit-display.ts
+++ b/frontend/src/features/providers/components/codex-reset-credit-display.ts
@@ -66,7 +66,7 @@ export function getVisibleCodexResetCreditItems(
if (remainingSeconds === null || remainingSeconds <= 0) return null
return {
id: item.id,
- expiresAt: item.expires_at,
+ expiresAt: nowUnixSecs + remainingSeconds,
remainingSeconds,
} satisfies CodexResetCreditDisplayCandidate
})
@@ -83,7 +83,11 @@ export function getVisibleCodexResetCreditItems(
})
}
-export function formatCodexResetCreditDays(remainingSeconds: number): string {
- const days = Math.max(1, Math.ceil(remainingSeconds / 86_400))
- return `${days}天`
+export function formatCodexResetCreditExpiresAt(expiresAt: number | null | undefined): string {
+ if (typeof expiresAt !== 'number' || !Number.isFinite(expiresAt)) return '-'
+ const date = new Date(expiresAt * 1000)
+ if (Number.isNaN(date.getTime())) return '-'
+
+ const pad = (value: number) => String(value).padStart(2, '0')
+ return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
diff --git a/frontend/src/features/usage/components/__tests__/UsageRecordsTable.spec.ts b/frontend/src/features/usage/components/__tests__/UsageRecordsTable.spec.ts
index e399faec7..79fa4bec1 100644
--- a/frontend/src/features/usage/components/__tests__/UsageRecordsTable.spec.ts
+++ b/frontend/src/features/usage/components/__tests__/UsageRecordsTable.spec.ts
@@ -288,6 +288,15 @@ describe('UsageRecordsTable', () => {
expect(root.textContent).toContain('xhigh')
})
+ it('shows request reasoning effort while the record is pending', () => {
+ const root = mountUsageRecordsTable([buildRecord({
+ status: 'pending',
+ reasoning_effort: 'max',
+ })])
+
+ expect(root.textContent).toContain('max')
+ })
+
it('shows fast badge for priority service tier', () => {
const root = mountUsageRecordsTable([buildRecord({ service_tier: 'priority' })])
From 3f86fdd6bc41333dcd872060e50345b8e5e805f4 Mon Sep 17 00:00:00 2001
From: MMEXA
Date: Mon, 13 Jul 2026 21:45:53 +0800
Subject: [PATCH 07/12] =?UTF-8?q?feat(usage):=20=E5=B1=95=E7=A4=BA?=
=?UTF-8?q?=E5=8E=8B=E7=BC=A9=E6=93=8D=E4=BD=9C=E4=B8=8E=E8=BF=9B=E8=A1=8C?=
=?UTF-8?q?=E6=80=81=E8=AF=B7=E6=B1=82=E8=AF=AD=E4=B9=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/src/api/endpoints/types/provider.ts | 1 +
frontend/src/api/me.ts | 2 +
frontend/src/api/usage.ts | 2 +
.../components/ModelMappingDialog.vue | 76 ++++++++++++++++++-
.../provider-tabs/ModelAliasesTab.vue | 36 +++++++--
.../provider-tabs/ModelMappingTab.vue | 35 +++++++--
.../usage/components/UsageRecordsTable.vue | 31 +++++++-
.../__tests__/UsageRecordsTable.spec.ts | 9 +++
.../__tests__/useUsageData.spec.ts | 3 +
.../usage/composables/useUsageData.ts | 1 +
frontend/src/features/usage/types.ts | 1 +
frontend/src/views/shared/Usage.vue | 5 ++
12 files changed, 188 insertions(+), 14 deletions(-)
diff --git a/frontend/src/api/endpoints/types/provider.ts b/frontend/src/api/endpoints/types/provider.ts
index d0580dcea..75c081704 100644
--- a/frontend/src/api/endpoints/types/provider.ts
+++ b/frontend/src/api/endpoints/types/provider.ts
@@ -940,6 +940,7 @@ export interface ProviderModelMapping {
priority: number // 优先级(数字越小优先级越高)
api_formats?: string[] // 作用域(适用的 API 格式),为空表示对所有格式生效
endpoint_ids?: string[] // 作用域(适用的端点 ID),为空表示对所有端点生效
+ operations?: string[] // 作用域(适用的请求操作),为空表示对该格式的全部操作生效
}
// 保留别名以保持向后兼容
diff --git a/frontend/src/api/me.ts b/frontend/src/api/me.ts
index 39efc1a28..9ec11c9d2 100644
--- a/frontend/src/api/me.ts
+++ b/frontend/src/api/me.ts
@@ -54,6 +54,7 @@ export interface UsageRecordDetail {
id: string
provider?: string // 仅管理员可见
model: string
+ request_type?: string | null
reasoning_effort?: string | null
service_tier?: string | null
actual_service_tier?: string | null
@@ -368,6 +369,7 @@ export const meApi = {
has_format_conversion?: boolean | null
has_fallback?: boolean | null
target_model?: string | null
+ request_type?: string | null
reasoning_effort?: string | null
service_tier?: string | null
actual_service_tier?: string | null
diff --git a/frontend/src/api/usage.ts b/frontend/src/api/usage.ts
index 38a55f294..35385eb3c 100644
--- a/frontend/src/api/usage.ts
+++ b/frontend/src/api/usage.ts
@@ -14,6 +14,7 @@ export interface UsageRecord {
provider_id?: string // UUID
provider_name?: string
model: string
+ request_type?: string | null
reasoning_effort?: string | null
service_tier?: string | null
actual_service_tier?: string | null
@@ -566,6 +567,7 @@ export const usageApi = {
has_format_conversion?: boolean | null
has_fallback?: boolean | null
target_model?: string | null
+ request_type?: string | null
reasoning_effort?: string | null
service_tier?: string | null
actual_service_tier?: string | null
diff --git a/frontend/src/features/providers/components/ModelMappingDialog.vue b/frontend/src/features/providers/components/ModelMappingDialog.vue
index 1584e0925..80c32246f 100644
--- a/frontend/src/features/providers/components/ModelMappingDialog.vue
+++ b/frontend/src/features/providers/components/ModelMappingDialog.vue
@@ -58,6 +58,21 @@
+
+
+
+ {{ operationScopeSummary }}
+
+
+
+
@@ -302,6 +317,8 @@ export interface AliasGroup {
apiFormats: string[]
endpointIdsKey: string
endpointIds: string[]
+ operationsKey: string
+ operations: string[]
aliases: ProviderModelAlias[]
}
@@ -330,6 +347,11 @@ type EndpointOption = {
label: string
}
+type OperationOption = {
+ value: string
+ label: string
+}
+
// 状态
const submitting = ref(false)
const loadingModels = ref(false)
@@ -358,6 +380,12 @@ const selectedNames = ref([])
// 选中的端点 ID;空数组表示全部端点
const selectedEndpointIds = ref([])
+const selectedOperations = ref([])
+
+const operationOptions: OperationOption[] = [
+ { value: 'compact', label: '线程压缩' }
+]
+
// 自定义名称列表(手动添加的)
const allCustomNames = ref([])
@@ -391,6 +419,19 @@ const endpointScopeSummary = computed(() => {
return `${selected.length} 个端点`
})
+const normalizedSelectedOperations = computed(() => {
+ const selected = normalizeStringList(selectedOperations.value)
+ return selected.length > 0 ? selected : undefined
+})
+
+const operationScopeSummary = computed(() => {
+ const selected = normalizedSelectedOperations.value
+ if (!selected) return '全部操作'
+ return selected.length === 1 && selected[0] === 'compact'
+ ? '线程压缩'
+ : `${selected.length} 项操作`
+})
+
// 所有已知名称集合
const allKnownNames = computed(() => {
const set = new Set()
@@ -523,6 +564,7 @@ function findDuplicateNames(
names: string[],
endpointIds: string[] | undefined,
apiFormats: string[] | undefined = undefined,
+ operations: string[] | undefined = undefined,
): string[] {
const duplicates = new Set()
for (const rawName of names) {
@@ -532,6 +574,7 @@ function findDuplicateNames(
return alias.name === name
&& scopesOverlap(alias.endpoint_ids, endpointIds)
&& scopesOverlap(alias.api_formats, apiFormats)
+ && scopesOverlap(alias.operations, operations)
})
if (duplicate) duplicates.add(name)
}
@@ -597,6 +640,7 @@ function initForm() {
const existingNames = props.editingGroup.aliases.map(a => a.name)
selectedNames.value = [...existingNames]
selectedEndpointIds.value = normalizeStringList(props.editingGroup.endpointIds)
+ selectedOperations.value = normalizeStringList(props.editingGroup.operations)
allCustomNames.value = [...existingNames]
} else {
formData.value = {
@@ -604,6 +648,7 @@ function initForm() {
}
selectedNames.value = []
selectedEndpointIds.value = []
+ selectedOperations.value = []
allCustomNames.value = []
}
searchQuery.value = ''
@@ -626,6 +671,10 @@ function getEndpointIdsKey(endpointIds: string[] | undefined): string {
return getScopeKey(endpointIds)
}
+function getOperationsKey(operations: string[] | undefined): string {
+ return getScopeKey(operations)
+}
+
// 提交表单
async function handleSubmit() {
if (submitting.value) return
@@ -642,6 +691,7 @@ async function handleSubmit() {
const currentAliases = targetModel.provider_model_mappings || []
let newAliases: ProviderModelAlias[]
const nextEndpointIds = normalizedSelectedEndpointIds.value
+ const nextOperations = normalizedSelectedOperations.value
const buildAliases = (names: string[]): ProviderModelAlias[] => {
return names.map((name) => {
@@ -652,6 +702,9 @@ async function handleSubmit() {
if (nextEndpointIds && nextEndpointIds.length > 0) {
alias.endpoint_ids = nextEndpointIds
}
+ if (nextOperations && nextOperations.length > 0) {
+ alias.operations = nextOperations
+ }
return alias
})
}
@@ -659,15 +712,26 @@ async function handleSubmit() {
if (props.editingGroup) {
const oldApiFormatsKey = props.editingGroup.apiFormatsKey
const oldEndpointIdsKey = props.editingGroup.endpointIdsKey
+ const oldOperationsKey = props.editingGroup.operationsKey
const oldAliasNames = new Set(props.editingGroup.aliases.map(a => a.name))
const filteredAliases = currentAliases.filter((a: ProviderModelAlias) => {
const currentKey = getApiFormatsKey(a.api_formats)
const currentEndpointIdsKey = getEndpointIdsKey(a.endpoint_ids)
- return !(currentKey === oldApiFormatsKey && currentEndpointIdsKey === oldEndpointIdsKey && oldAliasNames.has(a.name))
+ const currentOperationsKey = getOperationsKey(a.operations)
+ return !(currentKey === oldApiFormatsKey
+ && currentEndpointIdsKey === oldEndpointIdsKey
+ && currentOperationsKey === oldOperationsKey
+ && oldAliasNames.has(a.name))
})
- const duplicates = findDuplicateNames(filteredAliases, selectedNames.value, nextEndpointIds)
+ const duplicates = findDuplicateNames(
+ filteredAliases,
+ selectedNames.value,
+ nextEndpointIds,
+ undefined,
+ nextOperations,
+ )
if (duplicates.length > 0) {
showError(`以下映射名称已存在:${duplicates.join(', ')}`, '错误')
return
@@ -678,7 +742,13 @@ async function handleSubmit() {
...buildAliases(selectedNames.value)
]
} else {
- const duplicates = findDuplicateNames(currentAliases, selectedNames.value, nextEndpointIds)
+ const duplicates = findDuplicateNames(
+ currentAliases,
+ selectedNames.value,
+ nextEndpointIds,
+ undefined,
+ nextOperations,
+ )
if (duplicates.length > 0) {
showError(`以下映射名称已存在:${duplicates.join(', ')}`, '错误')
return
diff --git a/frontend/src/features/providers/components/provider-tabs/ModelAliasesTab.vue b/frontend/src/features/providers/components/provider-tabs/ModelAliasesTab.vue
index 839f22272..75dcfb907 100644
--- a/frontend/src/features/providers/components/provider-tabs/ModelAliasesTab.vue
+++ b/frontend/src/features/providers/components/provider-tabs/ModelAliasesTab.vue
@@ -75,6 +75,13 @@
>
{{ getEndpointScopeLabel(group) }}
+
+ {{ getOperationScopeLabel(group) }}
+
@@ -130,6 +137,7 @@
@@ -298,6 +323,7 @@ import {
} from '@/components/ui'
import MultiSelect from '@/components/common/MultiSelect.vue'
import { useToast } from '@/composables/useToast'
+import { useI18n } from '@/i18n'
import { parseApiError } from '@/utils/errorParser'
import {
type Model,
@@ -306,8 +332,18 @@ import {
type UpstreamModel,
} from '@/api/endpoints'
import { updateModel } from '@/api/endpoints/models'
-import { formatApiFormat } from '@/api/endpoints/types/api-format'
import { useUpstreamModelsCache } from '../composables/useUpstreamModelsCache'
+import {
+ ALL_REQUESTS_SCOPE_VALUE,
+ COMPACT_REQUEST_SCOPE_VALUE,
+ formatModelMappingEndpointLabel,
+ formatModelMappingRequestScope,
+ modelMappingOperationsKey,
+ modelMappingOperationsFromScopeValue,
+ modelMappingRequestScopeOptions,
+ modelMappingRequestScopeValue,
+ normalizeModelMappingOperations,
+} from '../utils/modelMappingScope'
export interface AliasGroup {
model: Model
@@ -340,6 +376,7 @@ const emit = defineEmits<{
}>()
const { error: showError, success: showSuccess, warning: showWarning } = useToast()
+const { t } = useI18n()
const { fetchModels: fetchCachedModels } = useUpstreamModelsCache()
type EndpointOption = {
@@ -347,11 +384,6 @@ type EndpointOption = {
label: string
}
-type OperationOption = {
- value: string
- label: string
-}
-
// 状态
const submitting = ref(false)
const loadingModels = ref(false)
@@ -382,54 +414,77 @@ const selectedEndpointIds = ref([])
const selectedOperations = ref([])
-const operationOptions: OperationOption[] = [
- { value: 'compact', label: '线程压缩' }
-]
-
// 自定义名称列表(手动添加的)
const allCustomNames = ref([])
const endpointOptions = computed(() => {
- return (props.endpoints ?? []).map((endpoint) => {
- const status = endpoint.is_active ? '' : '(停用)'
- return {
- value: endpoint.id,
- label: `${formatApiFormat(endpoint.api_format)}${status}`,
- }
- })
+ const endpoints = props.endpoints ?? []
+ return endpoints.map(endpoint => ({
+ value: endpoint.id,
+ label: formatModelMappingEndpointLabel(endpoint, endpoints),
+ }))
})
const normalizedSelectedEndpointIds = computed(() => {
- const validIds = new Set(endpointOptions.value.map(option => option.value))
const selected = normalizeStringList(selectedEndpointIds.value)
- if (selected.length === 0) {
- return undefined
- }
- const invalidSelected = selected.filter(endpointId => !validIds.has(endpointId))
- const selectedValidCount = selected.filter(endpointId => validIds.has(endpointId)).length
- if (validIds.size > 0 && invalidSelected.length === 0 && selectedValidCount === validIds.size) {
- return undefined
- }
- return selected
+ return selected.length > 0 ? selected : undefined
})
const endpointScopeSummary = computed(() => {
const selected = normalizedSelectedEndpointIds.value
- if (!selected || selected.length === 0) return '全部端点'
- return `${selected.length} 个端点`
+ if (!selected || selected.length === 0) {
+ return t('providers.modelMapping.scope.allEndpoints')
+ }
+ if (selected.length === 1) {
+ return endpointOptions.value.find(option => option.value === selected[0])?.label
+ ?? t('providers.modelMapping.scope.endpointCount', { count: 1 })
+ }
+ return t('providers.modelMapping.scope.endpointCount', { count: selected.length })
})
+const requestScopeLabels = computed(() => ({
+ allRequests: t('providers.modelMapping.scope.allRequests'),
+ sessionCompactionOnly: t('providers.modelMapping.scope.sessionCompactionOnly'),
+ customOperations: (operations: string[]) => t(
+ 'providers.modelMapping.scope.customOperations',
+ { operations: operations.join(', ') },
+ ),
+}))
+
const normalizedSelectedOperations = computed(() => {
- const selected = normalizeStringList(selectedOperations.value)
+ const selected = normalizeModelMappingOperations(selectedOperations.value)
return selected.length > 0 ? selected : undefined
})
const operationScopeSummary = computed(() => {
- const selected = normalizedSelectedOperations.value
- if (!selected) return '全部操作'
- return selected.length === 1 && selected[0] === 'compact'
- ? '线程压缩'
- : `${selected.length} 项操作`
+ return formatModelMappingRequestScope(
+ normalizedSelectedOperations.value,
+ requestScopeLabels.value,
+ )
+})
+
+const mappingScopeSummary = computed(() => {
+ return `${endpointScopeSummary.value} · ${operationScopeSummary.value}`
+})
+
+const requestScopeValue = computed(() => {
+ return modelMappingRequestScopeValue(selectedOperations.value)
+})
+
+const requestScopeOptions = computed(() => {
+ return modelMappingRequestScopeOptions(selectedOperations.value, requestScopeLabels.value)
+})
+
+const requestScopeDescription = computed(() => {
+ if (requestScopeValue.value === ALL_REQUESTS_SCOPE_VALUE) {
+ return t('providers.modelMapping.scope.allRequestsDescription')
+ }
+ if (requestScopeValue.value === COMPACT_REQUEST_SCOPE_VALUE) {
+ return t('providers.modelMapping.scope.sessionCompactionDescription')
+ }
+ return t('providers.modelMapping.scope.customOperationsDescription', {
+ operations: normalizeModelMappingOperations(selectedOperations.value).join(', '),
+ })
})
// 所有已知名称集合
@@ -559,6 +614,17 @@ function scopesOverlap(left: string[] | undefined, right: string[] | undefined):
return leftValues.some(value => rightSet.has(value))
}
+function operationScopesOverlap(
+ left: string[] | undefined,
+ right: string[] | undefined,
+): boolean {
+ const leftValues = normalizeModelMappingOperations(left)
+ const rightValues = normalizeModelMappingOperations(right)
+ if (leftValues.length === 0 || rightValues.length === 0) return true
+ const rightSet = new Set(rightValues)
+ return leftValues.some(value => rightSet.has(value))
+}
+
function findDuplicateNames(
existingAliases: ProviderModelAlias[],
names: string[],
@@ -574,7 +640,7 @@ function findDuplicateNames(
return alias.name === name
&& scopesOverlap(alias.endpoint_ids, endpointIds)
&& scopesOverlap(alias.api_formats, apiFormats)
- && scopesOverlap(alias.operations, operations)
+ && operationScopesOverlap(alias.operations, operations)
})
if (duplicate) duplicates.add(name)
}
@@ -640,7 +706,7 @@ function initForm() {
const existingNames = props.editingGroup.aliases.map(a => a.name)
selectedNames.value = [...existingNames]
selectedEndpointIds.value = normalizeStringList(props.editingGroup.endpointIds)
- selectedOperations.value = normalizeStringList(props.editingGroup.operations)
+ selectedOperations.value = normalizeModelMappingOperations(props.editingGroup.operations)
allCustomNames.value = [...existingNames]
} else {
formData.value = {
@@ -662,6 +728,10 @@ function handleModelChange(value: string) {
formData.value.modelId = value
}
+function handleRequestScopeChange(value: string) {
+ selectedOperations.value = modelMappingOperationsFromScopeValue(value) ?? []
+}
+
// 生成作用域唯一键
function getApiFormatsKey(formats: string[] | undefined): string {
return getScopeKey(formats)
@@ -672,7 +742,7 @@ function getEndpointIdsKey(endpointIds: string[] | undefined): string {
}
function getOperationsKey(operations: string[] | undefined): string {
- return getScopeKey(operations)
+ return modelMappingOperationsKey(operations)
}
// 提交表单
@@ -712,7 +782,7 @@ async function handleSubmit() {
if (props.editingGroup) {
const oldApiFormatsKey = props.editingGroup.apiFormatsKey
const oldEndpointIdsKey = props.editingGroup.endpointIdsKey
- const oldOperationsKey = props.editingGroup.operationsKey
+ const oldOperationsKey = modelMappingOperationsKey(props.editingGroup.operations)
const oldAliasNames = new Set(props.editingGroup.aliases.map(a => a.name))
const filteredAliases = currentAliases.filter((a: ProviderModelAlias) => {
diff --git a/frontend/src/features/providers/components/__tests__/ModelMappingDialog.spec.ts b/frontend/src/features/providers/components/__tests__/ModelMappingDialog.spec.ts
new file mode 100644
index 000000000..4885a125c
--- /dev/null
+++ b/frontend/src/features/providers/components/__tests__/ModelMappingDialog.spec.ts
@@ -0,0 +1,182 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { createApp, defineComponent, h, nextTick, ref, type App } from 'vue'
+
+import ModelMappingDialog, { type AliasGroup } from '../ModelMappingDialog.vue'
+import type { Model, ProviderEndpoint } from '@/api/endpoints'
+import { updateModel } from '@/api/endpoints/models'
+
+vi.mock('@/components/ui', async () => {
+ const { defineComponent, h } = await import('vue')
+
+ const passthrough = (name: string, tag = 'div') => defineComponent({
+ name,
+ setup(_, { slots }) {
+ return () => h(tag, [slots.default?.(), slots.footer?.()])
+ },
+ })
+
+ return {
+ Button: defineComponent({
+ name: 'ButtonStub',
+ setup(_, { attrs, slots }) {
+ return () => h('button', { ...attrs, type: 'button' }, slots.default?.())
+ },
+ }),
+ Dialog: passthrough('DialogStub'),
+ Input: defineComponent({
+ name: 'InputStub',
+ props: { modelValue: String },
+ emits: ['update:modelValue'],
+ setup(props, { attrs, emit }) {
+ return () => h('input', {
+ ...attrs,
+ value: props.modelValue ?? '',
+ onInput: (event: Event) => emit(
+ 'update:modelValue',
+ (event.target as HTMLInputElement).value,
+ ),
+ })
+ },
+ }),
+ Label: passthrough('LabelStub', 'label'),
+ Select: passthrough('SelectStub'),
+ SelectContent: passthrough('SelectContentStub'),
+ SelectItem: passthrough('SelectItemStub'),
+ SelectTrigger: passthrough('SelectTriggerStub'),
+ SelectValue: passthrough('SelectValueStub', 'span'),
+ }
+})
+
+vi.mock('@/components/common/MultiSelect.vue', async () => {
+ const { defineComponent, h } = await import('vue')
+ return {
+ default: defineComponent({
+ name: 'MultiSelectStub',
+ setup() {
+ return () => h('div')
+ },
+ }),
+ }
+})
+
+vi.mock('lucide-vue-next', async () => {
+ const { defineComponent, h } = await import('vue')
+ const Icon = defineComponent({
+ name: 'IconStub',
+ setup() {
+ return () => h('span')
+ },
+ })
+ return {
+ Check: Icon,
+ ChevronDown: Icon,
+ Loader2: Icon,
+ Plus: Icon,
+ RefreshCw: Icon,
+ Search: Icon,
+ Tag: Icon,
+ Zap: Icon,
+ }
+})
+
+vi.mock('@/api/endpoints/models', () => ({
+ updateModel: vi.fn().mockResolvedValue(undefined),
+}))
+
+vi.mock('@/composables/useToast', () => ({
+ useToast: () => ({
+ error: vi.fn(),
+ success: vi.fn(),
+ warning: vi.fn(),
+ }),
+}))
+
+vi.mock('../../composables/useUpstreamModelsCache', () => ({
+ useUpstreamModelsCache: () => ({
+ fetchModels: vi.fn(),
+ }),
+}))
+
+const mountedApps: Array<{ app: App, root: HTMLElement }> = []
+
+afterEach(() => {
+ vi.mocked(updateModel).mockClear()
+ for (const { app, root } of mountedApps.splice(0)) {
+ app.unmount()
+ root.remove()
+ }
+})
+
+describe('ModelMappingDialog', () => {
+ it('normalizes and replaces an edited compact operation scope', async () => {
+ const endpoint = {
+ id: 'endpoint-responses',
+ api_format: 'openai:responses',
+ base_url: 'https://api.example.com/v1',
+ is_active: true,
+ } as ProviderEndpoint
+ const model = {
+ id: 'model-sol',
+ provider_model_name: 'gpt-5.6-sol',
+ global_model_display_name: 'GPT-5.6 Sol',
+ provider_model_mappings: [{
+ name: 'gpt-5.6-luna',
+ priority: 1,
+ endpoint_ids: [endpoint.id],
+ operations: ['Compact'],
+ }],
+ } as Model
+ const editingGroup: AliasGroup = {
+ model,
+ apiFormatsKey: '',
+ apiFormats: [],
+ endpointIdsKey: endpoint.id,
+ endpointIds: [endpoint.id],
+ operationsKey: 'Compact',
+ operations: ['Compact'],
+ aliases: model.provider_model_mappings ?? [],
+ }
+ const open = ref(false)
+ const root = document.createElement('div')
+ document.body.appendChild(root)
+ const app = createApp(defineComponent({
+ setup() {
+ return () => h(ModelMappingDialog, {
+ open: open.value,
+ providerId: 'provider-1',
+ endpoints: [endpoint],
+ models: [model],
+ editingGroup,
+ 'onUpdate:open': (value: boolean) => { open.value = value },
+ })
+ },
+ }))
+ app.mount(root)
+ mountedApps.push({ app, root })
+
+ open.value = true
+ await nextTick()
+ await nextTick()
+ expect(root.textContent).toContain('仅会话压缩')
+
+ const scopeButtons = [...root.querySelectorAll('button')]
+ scopeButtons.find(button => button.textContent?.includes('所有请求'))?.click()
+ await nextTick()
+ scopeButtons.find(button => button.textContent?.includes('仅会话压缩'))?.click()
+ await nextTick()
+ const saveButton = [...root.querySelectorAll('button')]
+ .find(button => button.textContent?.includes('保存映射'))
+ expect(saveButton).toBeDefined()
+ saveButton?.click()
+ await vi.waitFor(() => expect(updateModel).toHaveBeenCalledTimes(1))
+
+ expect(updateModel).toHaveBeenCalledWith('provider-1', 'model-sol', {
+ provider_model_mappings: [{
+ name: 'gpt-5.6-luna',
+ priority: 1,
+ endpoint_ids: [endpoint.id],
+ operations: ['compact'],
+ }],
+ })
+ })
+})
diff --git a/frontend/src/features/providers/components/provider-tabs/ModelAliasesTab.vue b/frontend/src/features/providers/components/provider-tabs/ModelAliasesTab.vue
index 75dcfb907..2e350b53d 100644
--- a/frontend/src/features/providers/components/provider-tabs/ModelAliasesTab.vue
+++ b/frontend/src/features/providers/components/provider-tabs/ModelAliasesTab.vue
@@ -38,21 +38,21 @@
>
-
+
-
+
{{ group.model.global_model_display_name || group.model.provider_model_name }}
-
+
- {{ getOperationScopeLabel(group) }}
+ {{ getOperationScopeLabel(group) }}
@@ -214,10 +213,16 @@ import {
type ProviderModelAlias
} from '@/api/endpoints'
import { updateModel } from '@/api/endpoints/models'
+import { useI18n } from '@/i18n'
import { parseApiError, parseTestModelError } from '@/utils/errorParser'
import { formatApiFormat } from '@/api/endpoints/types/api-format'
import { buildExactModelMappingTestRequest } from './model-test-request'
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
+import {
+ formatModelMappingRequestScope,
+ modelMappingOperationsKey,
+ normalizeModelMappingOperations,
+} from '../../utils/modelMappingScope'
const props = defineProps<{
provider: ProviderWithEndpointsSummary
@@ -228,6 +233,7 @@ const emit = defineEmits<{
}>()
const { error: showError, success: showSuccess } = useToast()
+const { t } = useI18n()
// 状态
const loading = ref(false)
@@ -278,21 +284,31 @@ function getEndpointIdsKey(endpointIds: string[] | undefined): string {
}
function getOperationsKey(operations: string[] | undefined): string {
- return getScopeKey(operations)
+ return modelMappingOperationsKey(operations)
}
+const requestScopeLabels = computed(() => ({
+ allRequests: t('providers.modelMapping.scope.allRequests'),
+ sessionCompactionOnly: t('providers.modelMapping.scope.sessionCompactionOnly'),
+ customOperations: (operations: string[]) => t(
+ 'providers.modelMapping.scope.customOperations',
+ { operations: operations.join(', ') },
+ ),
+}))
+
function getAliasGroupKey(group: AliasGroup): string {
return `${group.model.id}-${group.apiFormatsKey}-${group.endpointIdsKey}-${group.operationsKey}`
}
function getEndpointScopeLabel(group: AliasGroup): string {
- if (!group.endpointIds || group.endpointIds.length === 0) return '全部端点'
- return `${group.endpointIds.length} 端点`
+ if (!group.endpointIds || group.endpointIds.length === 0) {
+ return t('providers.modelMapping.scope.allEndpoints')
+ }
+ return t('providers.modelMapping.scope.endpointCount', { count: group.endpointIds.length })
}
function getOperationScopeLabel(group: AliasGroup): string {
- if (group.operations.length === 1 && group.operations[0] === 'compact') return '压缩'
- return `${group.operations.length} 项操作`
+ return formatModelMappingRequestScope(group.operations, requestScopeLabels.value)
}
// 按"模型+作用域"分组的映射列表
@@ -317,7 +333,7 @@ const aliasGroups = computed
(() => {
endpointIdsKey,
endpointIds: normalizeStringList(alias.endpoint_ids),
operationsKey,
- operations: normalizeStringList(alias.operations),
+ operations: normalizeModelMappingOperations(alias.operations),
aliases: []
}
groupMap.set(groupKey, group)
diff --git a/frontend/src/features/providers/components/provider-tabs/ModelMappingTab.vue b/frontend/src/features/providers/components/provider-tabs/ModelMappingTab.vue
index d589d62cf..3371221ff 100644
--- a/frontend/src/features/providers/components/provider-tabs/ModelMappingTab.vue
+++ b/frontend/src/features/providers/components/provider-tabs/ModelMappingTab.vue
@@ -39,10 +39,10 @@
>
-
+
-
+
{{ item.targetModelName }}
@@ -75,21 +75,23 @@
- {{ getGroupEndpointScopeLabel(item.group) }}
+ {{ getGroupEndpointScopeLabel(item.group) }}
- {{ getGroupOperationScopeLabel(item.group) }}
+ {{ getGroupOperationScopeLabel(item.group) }}
-
+
{{ item.targetModelName }}
@@ -372,8 +374,15 @@ import {
} from '@/api/endpoints'
import { type EndpointAPIKey } from '@/api/endpoints/keys'
import { updateModel } from '@/api/endpoints/models'
+import { useI18n } from '@/i18n'
import { parseApiError } from '@/utils/errorParser'
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
+import {
+ formatModelMappingEndpointLabel,
+ formatModelMappingRequestScope,
+ modelMappingOperationsKey,
+ normalizeModelMappingOperations,
+} from '../../utils/modelMappingScope'
import {
buildDefaultModelTestRequestHeaders,
buildDefaultModelTestRequestBody,
@@ -424,6 +433,7 @@ const emit = defineEmits<{
}>()
const { error: showError, success: showSuccess } = useToast()
+const { t } = useI18n()
// 模型测试 composable
const modelTest = useModelTest({ providerId: () => props.provider.id })
@@ -501,17 +511,46 @@ function getEndpointIdsKey(endpointIds: string[] | undefined): string {
}
function getOperationsKey(operations: string[] | undefined): string {
- return getScopeKey(operations)
+ return modelMappingOperationsKey(operations)
}
+const requestScopeLabels = computed(() => ({
+ allRequests: t('providers.modelMapping.scope.allRequests'),
+ sessionCompactionOnly: t('providers.modelMapping.scope.sessionCompactionOnly'),
+ customOperations: (operations: string[]) => t(
+ 'providers.modelMapping.scope.customOperations',
+ { operations: operations.join(', ') },
+ ),
+}))
+
function getGroupEndpointScopeLabel(group: AliasGroup): string {
- if (!group.endpointIds || group.endpointIds.length === 0) return '全部端点'
- return `${group.endpointIds.length} 端点`
+ if (!group.endpointIds || group.endpointIds.length === 0) {
+ return t('providers.modelMapping.scope.allEndpoints')
+ }
+ const labels = getGroupEndpointScopeLabels(group)
+ return labels.length === 1
+ ? labels[0]
+ : t('providers.modelMapping.scope.endpointCount', { count: labels.length })
+}
+
+function getGroupEndpointScopeTitle(group: AliasGroup): string {
+ if (!group.endpointIds || group.endpointIds.length === 0) {
+ return t('providers.modelMapping.scope.allEndpoints')
+ }
+ return getGroupEndpointScopeLabels(group).join('、')
+}
+
+function getGroupEndpointScopeLabels(group: AliasGroup): string[] {
+ const endpoints = props.endpoints ?? []
+ return group.endpointIds.map((endpointId) => {
+ const endpoint = endpoints.find(item => item.id === endpointId)
+ if (!endpoint) return endpointId
+ return formatModelMappingEndpointLabel(endpoint, endpoints)
+ })
}
function getGroupOperationScopeLabel(group: AliasGroup): string {
- if (group.operations.length === 1 && group.operations[0] === 'compact') return '压缩'
- return `${group.operations.length} 项操作`
+ return formatModelMappingRequestScope(group.operations, requestScopeLabels.value)
}
// 精确映射分组(来自 provider_model_mappings)
@@ -536,7 +575,7 @@ const exactMappingGroups = computed
(() => {
endpointIdsKey,
endpointIds: normalizeStringList(alias.endpoint_ids),
operationsKey,
- operations: normalizeStringList(alias.operations),
+ operations: normalizeModelMappingOperations(alias.operations),
aliases: []
}
groupMap.set(groupKey, group)
diff --git a/frontend/src/features/providers/utils/__tests__/modelMappingScope.spec.ts b/frontend/src/features/providers/utils/__tests__/modelMappingScope.spec.ts
new file mode 100644
index 000000000..bc452acd8
--- /dev/null
+++ b/frontend/src/features/providers/utils/__tests__/modelMappingScope.spec.ts
@@ -0,0 +1,74 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ ALL_REQUESTS_SCOPE_VALUE,
+ COMPACT_REQUEST_SCOPE_VALUE,
+ formatModelMappingEndpointLabel,
+ formatModelMappingRequestScope,
+ modelMappingOperationsKey,
+ modelMappingOperationsFromScopeValue,
+ modelMappingRequestScopeOptions,
+ modelMappingRequestScopeValue,
+ normalizeModelMappingOperations,
+} from '../modelMappingScope'
+
+describe('model mapping request scope', () => {
+ it('represents an omitted operation filter as all requests', () => {
+ expect(modelMappingRequestScopeValue(undefined)).toBe(ALL_REQUESTS_SCOPE_VALUE)
+ expect(modelMappingOperationsFromScopeValue(ALL_REQUESTS_SCOPE_VALUE)).toBeUndefined()
+ expect(formatModelMappingRequestScope(undefined)).toBe('所有请求')
+ })
+
+ it('round-trips the compact operation as a dedicated request scope', () => {
+ expect(modelMappingRequestScopeValue(['compact'])).toBe(COMPACT_REQUEST_SCOPE_VALUE)
+ expect(modelMappingOperationsFromScopeValue(COMPACT_REQUEST_SCOPE_VALUE)).toEqual(['compact'])
+ expect(formatModelMappingRequestScope(['compact'])).toBe('仅会话压缩')
+ })
+
+ it('normalizes operation values using the backend matching semantics', () => {
+ expect(normalizeModelMappingOperations([' Compact ', 'compact', '', 'SEARCH'])).toEqual([
+ 'compact',
+ 'search',
+ ])
+ expect(modelMappingOperationsKey(['SEARCH', ' compact ', 'Compact'])).toBe('compact,search')
+ })
+
+ it('preserves an unknown operation scope while editing', () => {
+ const operations = ['future_operation', 'compact']
+ const value = modelMappingRequestScopeValue(operations)
+ const options = modelMappingRequestScopeOptions(operations)
+
+ expect(modelMappingOperationsFromScopeValue(value)).toEqual(operations)
+ expect(options).toContainEqual({ value, label: '仅匹配:future_operation, compact' })
+ })
+
+ it('rejects malformed scope values without constructing operations', () => {
+ expect(modelMappingOperationsFromScopeValue('compact')).toBeUndefined()
+ expect(modelMappingOperationsFromScopeValue('{"compact":true}')).toBeUndefined()
+ })
+
+ it('disambiguates endpoints that share an API format', () => {
+ const endpoints = [
+ {
+ id: 'endpoint-1',
+ api_format: 'openai:responses',
+ base_url: 'https://api.example.com/v1',
+ is_active: true,
+ },
+ {
+ id: 'endpoint-2',
+ api_format: 'openai:responses',
+ base_url: 'https://backup.example.com/v1',
+ custom_path: '/backend-api/codex/responses',
+ is_active: false,
+ },
+ ]
+
+ expect(formatModelMappingEndpointLabel(endpoints[0], endpoints)).toBe(
+ 'OpenAI Responses · api.example.com/v1',
+ )
+ expect(formatModelMappingEndpointLabel(endpoints[1], endpoints)).toBe(
+ 'OpenAI Responses · backup.example.com/backend-api/codex/responses(停用)',
+ )
+ })
+})
diff --git a/frontend/src/features/providers/utils/modelMappingScope.ts b/frontend/src/features/providers/utils/modelMappingScope.ts
new file mode 100644
index 000000000..248256f31
--- /dev/null
+++ b/frontend/src/features/providers/utils/modelMappingScope.ts
@@ -0,0 +1,132 @@
+import { formatApiFormat } from '@/api/endpoints/types/api-format'
+
+export const MODEL_MAPPING_OPERATION_COMPACT = 'compact'
+
+export const ALL_REQUESTS_SCOPE_VALUE = '[]'
+export const COMPACT_REQUEST_SCOPE_VALUE = JSON.stringify([
+ MODEL_MAPPING_OPERATION_COMPACT,
+])
+
+export interface ModelMappingRequestScopeOption {
+ value: string
+ label: string
+}
+
+export interface ModelMappingRequestScopeLabels {
+ allRequests: string
+ sessionCompactionOnly: string
+ customOperations: (operations: string[]) => string
+}
+
+export interface ModelMappingEndpoint {
+ id: string
+ api_format: string
+ base_url: string
+ custom_path?: string
+ is_active: boolean
+}
+
+const DEFAULT_REQUEST_SCOPE_LABELS: ModelMappingRequestScopeLabels = {
+ allRequests: '所有请求',
+ sessionCompactionOnly: '仅会话压缩',
+ customOperations: operations => `仅匹配:${operations.join(', ')}`,
+}
+
+export function normalizeModelMappingOperations(
+ operations: string[] | undefined,
+): string[] {
+ const seen = new Set()
+ const normalized: string[] = []
+ for (const operation of operations ?? []) {
+ const value = operation.trim().toLowerCase()
+ if (!value || seen.has(value)) continue
+ seen.add(value)
+ normalized.push(value)
+ }
+ return normalized
+}
+
+export function modelMappingRequestScopeValue(
+ operations: string[] | undefined,
+): string {
+ return JSON.stringify(normalizeModelMappingOperations(operations))
+}
+
+export function modelMappingOperationsKey(
+ operations: string[] | undefined,
+): string {
+ return normalizeModelMappingOperations(operations).sort().join(',')
+}
+
+export function modelMappingOperationsFromScopeValue(
+ value: string,
+): string[] | undefined {
+ try {
+ const parsed = JSON.parse(value)
+ if (!Array.isArray(parsed) || parsed.some(item => typeof item !== 'string')) {
+ return undefined
+ }
+ const operations = normalizeModelMappingOperations(parsed)
+ return operations.length > 0 ? operations : undefined
+ } catch {
+ return undefined
+ }
+}
+
+export function formatModelMappingRequestScope(
+ operations: string[] | undefined,
+ labels: ModelMappingRequestScopeLabels = DEFAULT_REQUEST_SCOPE_LABELS,
+): string {
+ const normalized = normalizeModelMappingOperations(operations)
+ if (normalized.length === 0) return labels.allRequests
+ if (
+ normalized.length === 1
+ && normalized[0] === MODEL_MAPPING_OPERATION_COMPACT
+ ) {
+ return labels.sessionCompactionOnly
+ }
+ return labels.customOperations(normalized)
+}
+
+export function modelMappingRequestScopeOptions(
+ operations: string[] | undefined,
+ labels: ModelMappingRequestScopeLabels = DEFAULT_REQUEST_SCOPE_LABELS,
+): ModelMappingRequestScopeOption[] {
+ const options: ModelMappingRequestScopeOption[] = [
+ { value: ALL_REQUESTS_SCOPE_VALUE, label: labels.allRequests },
+ { value: COMPACT_REQUEST_SCOPE_VALUE, label: labels.sessionCompactionOnly },
+ ]
+ const currentValue = modelMappingRequestScopeValue(operations)
+ if (!options.some(option => option.value === currentValue)) {
+ options.push({
+ value: currentValue,
+ label: formatModelMappingRequestScope(operations, labels),
+ })
+ }
+ return options
+}
+
+export function formatModelMappingEndpointLabel(
+ endpoint: ModelMappingEndpoint,
+ endpoints: ModelMappingEndpoint[],
+): string {
+ const sameFormatCount = endpoints.filter(item => item.api_format === endpoint.api_format).length
+ const format = formatApiFormat(endpoint.api_format)
+ const discriminator = sameFormatCount > 1
+ ? formatModelMappingEndpointDiscriminator(endpoint)
+ : ''
+ const status = endpoint.is_active ? '' : '(停用)'
+ return `${format}${discriminator ? ` · ${discriminator}` : ''}${status}`
+}
+
+function formatModelMappingEndpointDiscriminator(endpoint: ModelMappingEndpoint): string {
+ const baseUrl = endpoint.base_url.trim()
+ try {
+ const parsed = new URL(baseUrl)
+ const customPath = endpoint.custom_path?.trim()
+ const path = (customPath || parsed.pathname).replace(/\/$/, '')
+ return `${parsed.host}${path && path !== '/' ? path : ''}`
+ } catch {
+ return baseUrl || endpoint.id.slice(0, 8)
+ }
+}
diff --git a/frontend/src/features/usage/components/UsageRecordsTable.vue b/frontend/src/features/usage/components/UsageRecordsTable.vue
index 83a0a8b37..9426bffa9 100644
--- a/frontend/src/features/usage/components/UsageRecordsTable.vue
+++ b/frontend/src/features/usage/components/UsageRecordsTable.vue
@@ -248,7 +248,7 @@
v-if="getRequestTypeLabel(record)"
variant="outline"
class="h-4 rounded-full border-sky-500/30 bg-sky-500/5 px-1.5 text-[10px] leading-4 text-sky-700 dark:text-sky-300 flex-shrink-0"
- title="线程压缩"
+ title="会话压缩"
>
{{ getRequestTypeLabel(record) }}
@@ -765,7 +765,7 @@
v-if="getRequestTypeLabel(record)"
variant="outline"
class="h-4 rounded-full border-sky-500/30 bg-sky-500/5 px-1.5 text-[10px] leading-4 text-sky-700 dark:text-sky-300 flex-shrink-0"
- title="线程压缩"
+ title="会话压缩"
>
{{ getRequestTypeLabel(record) }}
@@ -797,7 +797,7 @@
v-if="getRequestTypeLabel(record)"
variant="outline"
class="h-4 rounded-full border-sky-500/30 bg-sky-500/5 px-1.5 text-[10px] leading-4 text-sky-700 dark:text-sky-300 flex-shrink-0"
- title="线程压缩"
+ title="会话压缩"
>
{{ getRequestTypeLabel(record) }}
@@ -1630,7 +1630,7 @@ function getReasoningEffort(record: UsageRecord): string | null {
}
function getRequestTypeLabel(record: UsageRecord): string | null {
- return record.request_type?.trim().toLowerCase() === 'compact' ? '压缩' : null
+ return record.request_type?.trim().toLowerCase() === 'compact' ? '会话压缩' : null
}
function getReasoningEffortTitle(record: UsageRecord): string {
diff --git a/frontend/src/features/usage/components/__tests__/UsageRecordsTable.spec.ts b/frontend/src/features/usage/components/__tests__/UsageRecordsTable.spec.ts
index 27dd0dbee..470935c7e 100644
--- a/frontend/src/features/usage/components/__tests__/UsageRecordsTable.spec.ts
+++ b/frontend/src/features/usage/components/__tests__/UsageRecordsTable.spec.ts
@@ -303,7 +303,7 @@ describe('UsageRecordsTable', () => {
request_type: 'compact',
})])
- expect(root.textContent).toContain('压缩')
+ expect(root.textContent).toContain('会话压缩')
})
it('shows fast badge for priority service tier', () => {
diff --git a/frontend/src/i18n/messages.ts b/frontend/src/i18n/messages.ts
index 156fe8c21..bdedc8b9b 100644
--- a/frontend/src/i18n/messages.ts
+++ b/frontend/src/i18n/messages.ts
@@ -295,6 +295,16 @@ export const messages = {
'nav.cacheMonitoring': '缓存监控',
'nav.moduleManagement': '模块管理',
'nav.systemSettings': '系统设置',
+ 'providers.modelMapping.scope.allRequests': '所有请求',
+ 'providers.modelMapping.scope.sessionCompactionOnly': '仅会话压缩',
+ 'providers.modelMapping.scope.customOperations': '仅匹配:{operations}',
+ 'providers.modelMapping.scope.allRequestsDescription': '普通请求和会话压缩都可使用此映射。',
+ 'providers.modelMapping.scope.sessionCompactionDescription': '只在会话压缩时使用此映射,包括 Responses 输入中的压缩触发和 Responses Compact 端点。',
+ 'providers.modelMapping.scope.customOperationsDescription': '只匹配请求操作:{operations}。',
+ 'providers.modelMapping.scope.allEndpoints': '全部端点',
+ 'providers.modelMapping.scope.endpointCount': '{count} 个端点',
+ 'providers.modelMapping.scope.endpointHelp': '留空时不限制端点;选择后仅匹配指定端点。',
+ 'providers.modelMapping.scope.matchHelp': '端点范围和请求范围必须同时匹配。',
'breadcrumb.personalSettings': '个人设置',
'breadcrumb.routingCreate': '新建调度策略',
'breadcrumb.routingConfig': '调度策略配置',
@@ -595,6 +605,16 @@ export const messages = {
'nav.cacheMonitoring': 'Cache monitoring',
'nav.moduleManagement': 'Modules',
'nav.systemSettings': 'System settings',
+ 'providers.modelMapping.scope.allRequests': 'All requests',
+ 'providers.modelMapping.scope.sessionCompactionOnly': 'Session compaction only',
+ 'providers.modelMapping.scope.customOperations': 'Match only: {operations}',
+ 'providers.modelMapping.scope.allRequestsDescription': 'Regular requests and session compaction can both use this mapping.',
+ 'providers.modelMapping.scope.sessionCompactionDescription': 'Use this mapping only for session compaction, including compaction triggers in Responses input and the Responses Compact endpoint.',
+ 'providers.modelMapping.scope.customOperationsDescription': 'Match only these request operations: {operations}.',
+ 'providers.modelMapping.scope.allEndpoints': 'All endpoints',
+ 'providers.modelMapping.scope.endpointCount': '{count} endpoints',
+ 'providers.modelMapping.scope.endpointHelp': 'Leave empty for no endpoint restriction. Selections match only those endpoints.',
+ 'providers.modelMapping.scope.matchHelp': 'Both the endpoint scope and request scope must match.',
'breadcrumb.personalSettings': 'Profile settings',
'breadcrumb.routingCreate': 'Create routing profile',
'breadcrumb.routingConfig': 'Routing profile',
@@ -673,6 +693,15 @@ const legacyExactEnglishMessages: Record = {
'全部分组': 'All groups',
'全部归属': 'All ownership',
'全部方式': 'All methods',
+ '适用范围': 'Applies to',
+ '适用端点': 'Applicable endpoints',
+ '适用请求': 'Applicable requests',
+ '全部端点': 'All endpoints',
+ '所有请求': 'All requests',
+ '仅会话压缩': 'Session compaction only',
+ '会话压缩': 'Session compaction',
+ '保存映射': 'Save mapping',
+ '添加映射': 'Add mapping',
'未设置': 'Not set',
'已设置': 'Configured',
'默认': 'Default',
From 93e2f95c47942986a52ae56447eb3292fc9eb710 Mon Sep 17 00:00:00 2001
From: MMEXA
Date: Tue, 14 Jul 2026 02:05:10 +0800
Subject: [PATCH 10/12] =?UTF-8?q?fix(frontend):=20=E6=8C=89=E7=AB=AF?=
=?UTF-8?q?=E7=82=B9=E8=83=BD=E5=8A=9B=E7=BA=A6=E6=9D=9F=E4=BC=9A=E8=AF=9D?=
=?UTF-8?q?=E5=8E=8B=E7=BC=A9=E6=98=A0=E5=B0=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../components/ModelMappingDialog.vue | 39 ++++-
.../__tests__/ModelMappingDialog.spec.ts | 147 +++++++++++++++++-
.../utils/__tests__/modelMappingScope.spec.ts | 41 ++++-
.../providers/utils/modelMappingScope.ts | 39 ++++-
frontend/src/i18n/messages.ts | 6 +-
5 files changed, 262 insertions(+), 10 deletions(-)
diff --git a/frontend/src/features/providers/components/ModelMappingDialog.vue b/frontend/src/features/providers/components/ModelMappingDialog.vue
index 533039941..c224cd361 100644
--- a/frontend/src/features/providers/components/ModelMappingDialog.vue
+++ b/frontend/src/features/providers/components/ModelMappingDialog.vue
@@ -338,6 +338,7 @@ import {
COMPACT_REQUEST_SCOPE_VALUE,
formatModelMappingEndpointLabel,
formatModelMappingRequestScope,
+ modelMappingEndpointScopeSupportsSessionCompaction,
modelMappingOperationsKey,
modelMappingOperationsFromScopeValue,
modelMappingRequestScopeOptions,
@@ -430,6 +431,13 @@ const normalizedSelectedEndpointIds = computed(() => {
return selected.length > 0 ? selected : undefined
})
+const sessionCompactionScopeAvailable = computed(() => {
+ return modelMappingEndpointScopeSupportsSessionCompaction(
+ normalizedSelectedEndpointIds.value,
+ props.endpoints ?? [],
+ )
+})
+
const endpointScopeSummary = computed(() => {
const selected = normalizedSelectedEndpointIds.value
if (!selected || selected.length === 0) {
@@ -472,12 +480,18 @@ const requestScopeValue = computed(() => {
})
const requestScopeOptions = computed(() => {
- return modelMappingRequestScopeOptions(selectedOperations.value, requestScopeLabels.value)
+ return modelMappingRequestScopeOptions(
+ selectedOperations.value,
+ { sessionCompaction: sessionCompactionScopeAvailable.value },
+ requestScopeLabels.value,
+ )
})
const requestScopeDescription = computed(() => {
if (requestScopeValue.value === ALL_REQUESTS_SCOPE_VALUE) {
- return t('providers.modelMapping.scope.allRequestsDescription')
+ return sessionCompactionScopeAvailable.value
+ ? t('providers.modelMapping.scope.allRequestsDescription')
+ : t('providers.modelMapping.scope.allRequestsDefaultDescription')
}
if (requestScopeValue.value === COMPACT_REQUEST_SCOPE_VALUE) {
return t('providers.modelMapping.scope.sessionCompactionDescription')
@@ -487,6 +501,12 @@ const requestScopeDescription = computed(() => {
})
})
+watch(
+ [() => props.endpoints, () => selectedEndpointIds.value],
+ () => normalizeUnavailableSessionCompactionScope(),
+ { deep: true },
+)
+
// 所有已知名称集合
const allKnownNames = computed(() => {
const set = new Set()
@@ -721,6 +741,7 @@ function initForm() {
upstreamModels.value = []
upstreamModelsLoaded.value = false
collapsedGroups.value = new Set()
+ normalizeUnavailableSessionCompactionScope()
}
// 处理模型选择变更
@@ -729,9 +750,23 @@ function handleModelChange(value: string) {
}
function handleRequestScopeChange(value: string) {
+ if (
+ value === COMPACT_REQUEST_SCOPE_VALUE
+ && !sessionCompactionScopeAvailable.value
+ ) return
selectedOperations.value = modelMappingOperationsFromScopeValue(value) ?? []
}
+function normalizeUnavailableSessionCompactionScope() {
+ if (props.endpoints === undefined) return
+ if (
+ requestScopeValue.value === COMPACT_REQUEST_SCOPE_VALUE
+ && !sessionCompactionScopeAvailable.value
+ ) {
+ selectedOperations.value = []
+ }
+}
+
// 生成作用域唯一键
function getApiFormatsKey(formats: string[] | undefined): string {
return getScopeKey(formats)
diff --git a/frontend/src/features/providers/components/__tests__/ModelMappingDialog.spec.ts b/frontend/src/features/providers/components/__tests__/ModelMappingDialog.spec.ts
index 4885a125c..de095440c 100644
--- a/frontend/src/features/providers/components/__tests__/ModelMappingDialog.spec.ts
+++ b/frontend/src/features/providers/components/__tests__/ModelMappingDialog.spec.ts
@@ -52,8 +52,21 @@ vi.mock('@/components/common/MultiSelect.vue', async () => {
return {
default: defineComponent({
name: 'MultiSelectStub',
- setup() {
- return () => h('div')
+ props: {
+ modelValue: { type: Array, default: () => [] },
+ options: { type: Array, default: () => [] },
+ },
+ emits: ['update:modelValue'],
+ setup(props, { emit }) {
+ return () => h('div', (props.options as Array<{ value: string, label: string }>).map(option => h(
+ 'button',
+ {
+ type: 'button',
+ 'data-endpoint-id': option.value,
+ onClick: () => emit('update:modelValue', [option.value]),
+ },
+ option.label,
+ )))
},
}),
}
@@ -108,6 +121,136 @@ afterEach(() => {
})
describe('ModelMappingDialog', () => {
+ it('offers session compaction only for an explicitly selected Responses endpoint', async () => {
+ const chatEndpoint = {
+ id: 'endpoint-chat',
+ api_format: 'openai:chat',
+ base_url: 'https://api.example.com/v1',
+ is_active: true,
+ } as ProviderEndpoint
+ const responsesEndpoint = {
+ id: 'endpoint-responses',
+ api_format: 'openai:responses',
+ base_url: 'https://api.example.com/v1',
+ is_active: true,
+ } as ProviderEndpoint
+ const model = {
+ id: 'model-sol',
+ provider_model_name: 'gpt-5.6-sol',
+ global_model_display_name: 'GPT-5.6 Sol',
+ provider_model_mappings: [],
+ } as Model
+ const open = ref(false)
+ const root = document.createElement('div')
+ document.body.appendChild(root)
+ const app = createApp(defineComponent({
+ setup() {
+ return () => h(ModelMappingDialog, {
+ open: open.value,
+ providerId: 'provider-1',
+ endpoints: [chatEndpoint, responsesEndpoint],
+ models: [model],
+ preselectedModelId: model.id,
+ 'onUpdate:open': (value: boolean) => { open.value = value },
+ })
+ },
+ }))
+ app.mount(root)
+ mountedApps.push({ app, root })
+
+ open.value = true
+ await nextTick()
+ await nextTick()
+
+ expect(root.textContent).toContain('所有请求')
+ expect(root.textContent).not.toContain('仅会话压缩')
+
+ root.querySelector('[data-endpoint-id="endpoint-chat"]')?.click()
+ await nextTick()
+ expect(root.textContent).not.toContain('仅会话压缩')
+
+ root.querySelector('[data-endpoint-id="endpoint-responses"]')?.click()
+ await nextTick()
+ expect(root.textContent).toContain('仅会话压缩')
+ })
+
+ it('returns to all requests when a compact mapping switches away from Responses', async () => {
+ const responsesEndpoint = {
+ id: 'endpoint-responses',
+ api_format: 'openai:responses',
+ base_url: 'https://api.example.com/v1',
+ is_active: true,
+ } as ProviderEndpoint
+ const chatEndpoint = {
+ id: 'endpoint-chat',
+ api_format: 'openai:chat',
+ base_url: 'https://api.example.com/v1',
+ is_active: true,
+ } as ProviderEndpoint
+ const model = {
+ id: 'model-sol',
+ provider_model_name: 'gpt-5.6-sol',
+ global_model_display_name: 'GPT-5.6 Sol',
+ provider_model_mappings: [{
+ name: 'gpt-5.6-luna',
+ priority: 1,
+ endpoint_ids: [responsesEndpoint.id],
+ operations: ['compact'],
+ }],
+ } as Model
+ const editingGroup: AliasGroup = {
+ model,
+ apiFormatsKey: '',
+ apiFormats: [],
+ endpointIdsKey: responsesEndpoint.id,
+ endpointIds: [responsesEndpoint.id],
+ operationsKey: 'compact',
+ operations: ['compact'],
+ aliases: model.provider_model_mappings ?? [],
+ }
+ const open = ref(false)
+ const root = document.createElement('div')
+ document.body.appendChild(root)
+ const app = createApp(defineComponent({
+ setup() {
+ return () => h(ModelMappingDialog, {
+ open: open.value,
+ providerId: 'provider-1',
+ endpoints: [responsesEndpoint, chatEndpoint],
+ models: [model],
+ editingGroup,
+ 'onUpdate:open': (value: boolean) => { open.value = value },
+ })
+ },
+ }))
+ app.mount(root)
+ mountedApps.push({ app, root })
+
+ open.value = true
+ await nextTick()
+ await nextTick()
+ expect(root.textContent).toContain('仅会话压缩')
+
+ root.querySelector('[data-endpoint-id="endpoint-chat"]')?.click()
+ await nextTick()
+ expect(root.textContent).not.toContain('仅会话压缩')
+ expect(root.querySelector('[role="radio"][aria-checked="true"]')?.textContent)
+ .toContain('所有请求')
+
+ const saveButton = [...root.querySelectorAll('button')]
+ .find(button => button.textContent?.includes('保存映射'))
+ saveButton?.click()
+ await vi.waitFor(() => expect(updateModel).toHaveBeenCalledTimes(1))
+
+ expect(updateModel).toHaveBeenCalledWith('provider-1', 'model-sol', {
+ provider_model_mappings: [{
+ name: 'gpt-5.6-luna',
+ priority: 1,
+ endpoint_ids: [chatEndpoint.id],
+ }],
+ })
+ })
+
it('normalizes and replaces an edited compact operation scope', async () => {
const endpoint = {
id: 'endpoint-responses',
diff --git a/frontend/src/features/providers/utils/__tests__/modelMappingScope.spec.ts b/frontend/src/features/providers/utils/__tests__/modelMappingScope.spec.ts
index bc452acd8..d43a37f5e 100644
--- a/frontend/src/features/providers/utils/__tests__/modelMappingScope.spec.ts
+++ b/frontend/src/features/providers/utils/__tests__/modelMappingScope.spec.ts
@@ -5,6 +5,7 @@ import {
COMPACT_REQUEST_SCOPE_VALUE,
formatModelMappingEndpointLabel,
formatModelMappingRequestScope,
+ modelMappingEndpointScopeSupportsSessionCompaction,
modelMappingOperationsKey,
modelMappingOperationsFromScopeValue,
modelMappingRequestScopeOptions,
@@ -36,12 +37,50 @@ describe('model mapping request scope', () => {
it('preserves an unknown operation scope while editing', () => {
const operations = ['future_operation', 'compact']
const value = modelMappingRequestScopeValue(operations)
- const options = modelMappingRequestScopeOptions(operations)
+ const options = modelMappingRequestScopeOptions(
+ operations,
+ { sessionCompaction: true },
+ )
expect(modelMappingOperationsFromScopeValue(value)).toEqual(operations)
expect(options).toContainEqual({ value, label: '仅匹配:future_operation, compact' })
})
+ it('offers compact scope only when the selected endpoint scope supports it', () => {
+ expect(modelMappingRequestScopeOptions(undefined, { sessionCompaction: false }))
+ .toEqual([{ value: ALL_REQUESTS_SCOPE_VALUE, label: '所有请求' }])
+ expect(modelMappingRequestScopeOptions(undefined, { sessionCompaction: true }))
+ .toContainEqual({ value: COMPACT_REQUEST_SCOPE_VALUE, label: '仅会话压缩' })
+ })
+
+ it('requires every explicitly selected endpoint to use OpenAI Responses', () => {
+ const responsesEndpoint = {
+ id: 'responses',
+ api_format: 'OPENAI_RESPONSES',
+ base_url: 'https://api.example.com/v1',
+ is_active: true,
+ }
+ const chatEndpoint = {
+ id: 'chat',
+ api_format: 'openai:chat',
+ base_url: 'https://api.example.com/v1',
+ is_active: true,
+ }
+
+ expect(modelMappingEndpointScopeSupportsSessionCompaction(
+ undefined,
+ [responsesEndpoint, chatEndpoint],
+ )).toBe(false)
+ expect(modelMappingEndpointScopeSupportsSessionCompaction(
+ [responsesEndpoint.id],
+ [responsesEndpoint, chatEndpoint],
+ )).toBe(true)
+ expect(modelMappingEndpointScopeSupportsSessionCompaction(
+ [responsesEndpoint.id, chatEndpoint.id],
+ [responsesEndpoint, chatEndpoint],
+ )).toBe(false)
+ })
+
it('rejects malformed scope values without constructing operations', () => {
expect(modelMappingOperationsFromScopeValue('compact')).toBeUndefined()
expect(modelMappingOperationsFromScopeValue('{"compact":true}')).toBeUndefined()
diff --git a/frontend/src/features/providers/utils/modelMappingScope.ts b/frontend/src/features/providers/utils/modelMappingScope.ts
index 248256f31..fdac71118 100644
--- a/frontend/src/features/providers/utils/modelMappingScope.ts
+++ b/frontend/src/features/providers/utils/modelMappingScope.ts
@@ -1,4 +1,8 @@
-import { formatApiFormat } from '@/api/endpoints/types/api-format'
+import {
+ API_FORMATS,
+ formatApiFormat,
+ normalizeApiFormatAlias,
+} from '@/api/endpoints/types/api-format'
export const MODEL_MAPPING_OPERATION_COMPACT = 'compact'
@@ -12,6 +16,10 @@ export interface ModelMappingRequestScopeOption {
label: string
}
+export interface ModelMappingRequestScopeCapabilities {
+ sessionCompaction: boolean
+}
+
export interface ModelMappingRequestScopeLabels {
allRequests: string
sessionCompactionOnly: string
@@ -90,14 +98,22 @@ export function formatModelMappingRequestScope(
export function modelMappingRequestScopeOptions(
operations: string[] | undefined,
+ capabilities: ModelMappingRequestScopeCapabilities,
labels: ModelMappingRequestScopeLabels = DEFAULT_REQUEST_SCOPE_LABELS,
): ModelMappingRequestScopeOption[] {
const options: ModelMappingRequestScopeOption[] = [
{ value: ALL_REQUESTS_SCOPE_VALUE, label: labels.allRequests },
- { value: COMPACT_REQUEST_SCOPE_VALUE, label: labels.sessionCompactionOnly },
]
+ if (capabilities.sessionCompaction) {
+ options.push({
+ value: COMPACT_REQUEST_SCOPE_VALUE,
+ label: labels.sessionCompactionOnly,
+ })
+ }
const currentValue = modelMappingRequestScopeValue(operations)
- if (!options.some(option => option.value === currentValue)) {
+ const compactScopeUnavailable = currentValue === COMPACT_REQUEST_SCOPE_VALUE
+ && !capabilities.sessionCompaction
+ if (!compactScopeUnavailable && !options.some(option => option.value === currentValue)) {
options.push({
value: currentValue,
label: formatModelMappingRequestScope(operations, labels),
@@ -106,6 +122,23 @@ export function modelMappingRequestScopeOptions(
return options
}
+export function modelMappingEndpointScopeSupportsSessionCompaction(
+ endpointIds: string[] | undefined,
+ endpoints: ModelMappingEndpoint[],
+): boolean {
+ const selectedIds = [...new Set(
+ (endpointIds ?? []).map(id => id.trim()).filter(Boolean),
+ )]
+ if (selectedIds.length === 0) return false
+
+ const endpointsById = new Map(endpoints.map(endpoint => [endpoint.id, endpoint]))
+ return selectedIds.every((endpointId) => {
+ const endpoint = endpointsById.get(endpointId)
+ return endpoint !== undefined
+ && normalizeApiFormatAlias(endpoint.api_format) === API_FORMATS.OPENAI_RESPONSES
+ })
+}
+
export function formatModelMappingEndpointLabel(
endpoint: ModelMappingEndpoint,
endpoints: ModelMappingEndpoint[],
diff --git a/frontend/src/i18n/messages.ts b/frontend/src/i18n/messages.ts
index bdedc8b9b..725fefa76 100644
--- a/frontend/src/i18n/messages.ts
+++ b/frontend/src/i18n/messages.ts
@@ -299,7 +299,8 @@ export const messages = {
'providers.modelMapping.scope.sessionCompactionOnly': '仅会话压缩',
'providers.modelMapping.scope.customOperations': '仅匹配:{operations}',
'providers.modelMapping.scope.allRequestsDescription': '普通请求和会话压缩都可使用此映射。',
- 'providers.modelMapping.scope.sessionCompactionDescription': '只在会话压缩时使用此映射,包括 Responses 输入中的压缩触发和 Responses Compact 端点。',
+ 'providers.modelMapping.scope.allRequestsDefaultDescription': '所选端点的所有请求都可使用此映射。仅当适用端点已明确选择且全部为 OpenAI Responses 时,可限定为会话压缩。',
+ 'providers.modelMapping.scope.sessionCompactionDescription': '仅匹配所选 OpenAI Responses 端点中的会话压缩请求。',
'providers.modelMapping.scope.customOperationsDescription': '只匹配请求操作:{operations}。',
'providers.modelMapping.scope.allEndpoints': '全部端点',
'providers.modelMapping.scope.endpointCount': '{count} 个端点',
@@ -609,7 +610,8 @@ export const messages = {
'providers.modelMapping.scope.sessionCompactionOnly': 'Session compaction only',
'providers.modelMapping.scope.customOperations': 'Match only: {operations}',
'providers.modelMapping.scope.allRequestsDescription': 'Regular requests and session compaction can both use this mapping.',
- 'providers.modelMapping.scope.sessionCompactionDescription': 'Use this mapping only for session compaction, including compaction triggers in Responses input and the Responses Compact endpoint.',
+ 'providers.modelMapping.scope.allRequestsDefaultDescription': 'This mapping applies to all requests on the selected endpoints. Session compaction can be selected only when every explicitly selected endpoint uses OpenAI Responses.',
+ 'providers.modelMapping.scope.sessionCompactionDescription': 'Match only session compaction requests on the selected OpenAI Responses endpoints.',
'providers.modelMapping.scope.customOperationsDescription': 'Match only these request operations: {operations}.',
'providers.modelMapping.scope.allEndpoints': 'All endpoints',
'providers.modelMapping.scope.endpointCount': '{count} endpoints',
From f9c343eb07472ca7928376d1bb4aa19c65c0d178 Mon Sep 17 00:00:00 2001
From: MMEXA
Date: Sat, 18 Jul 2026 05:55:06 +0800
Subject: [PATCH 11/12] =?UTF-8?q?fix(gateway):=20=E9=80=82=E9=85=8D=20Rust?=
=?UTF-8?q?=201.95=20=E6=95=B4=E9=99=A4=E6=A3=80=E6=9F=A5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
apps/aether-gateway/src/handlers/shared/catalog.rs | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/apps/aether-gateway/src/handlers/shared/catalog.rs b/apps/aether-gateway/src/handlers/shared/catalog.rs
index 94fbcea51..98b098c1f 100644
--- a/apps/aether-gateway/src/handlers/shared/catalog.rs
+++ b/apps/aether-gateway/src/handlers/shared/catalog.rs
@@ -821,11 +821,11 @@ fn codex_quota_period_identity(window_minutes: u64) -> (String, String) {
return ("monthly".to_string(), "月".to_string());
}
- let label = if window_minutes % MINUTES_PER_WEEK == 0 {
+ let label = if window_minutes.is_multiple_of(MINUTES_PER_WEEK) {
format!("{}周", window_minutes / MINUTES_PER_WEEK)
- } else if window_minutes % MINUTES_PER_DAY == 0 {
+ } else if window_minutes.is_multiple_of(MINUTES_PER_DAY) {
format!("{}天", window_minutes / MINUTES_PER_DAY)
- } else if window_minutes % MINUTES_PER_HOUR == 0 {
+ } else if window_minutes.is_multiple_of(MINUTES_PER_HOUR) {
format!("{}H", window_minutes / MINUTES_PER_HOUR)
} else {
format!("{window_minutes}分钟")
From ac3796af84526b8aa12846f513dcab946a801961 Mon Sep 17 00:00:00 2001
From: MMEXA
Date: Sat, 18 Jul 2026 06:31:40 +0800
Subject: [PATCH 12/12] =?UTF-8?q?fix(gateway):=20=E6=81=A2=E5=A4=8D?=
=?UTF-8?q?=E5=93=8D=E5=BA=94=E8=BE=B9=E7=95=8C=E5=B9=B6=E7=BB=9F=E4=B8=80?=
=?UTF-8?q?=E6=A0=BC=E5=BC=8F=E5=85=A5=E5=8F=A3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../stream/execution_failures.rs | 2 +-
.../src/execution_runtime/transport.rs | 18 +++++++++---------
2 files changed, 10 insertions(+), 10 deletions(-)
diff --git a/apps/aether-gateway/src/execution_runtime/stream/execution_failures.rs b/apps/aether-gateway/src/execution_runtime/stream/execution_failures.rs
index 97885eb86..5207d705d 100644
--- a/apps/aether-gateway/src/execution_runtime/stream/execution_failures.rs
+++ b/apps/aether-gateway/src/execution_runtime/stream/execution_failures.rs
@@ -522,7 +522,7 @@ mod tests {
"data: {\"type\":\"response.failed\",\"response\":{\"status\":\"failed\",\"error\":{\"type\":\"invalid_request\",\"message\":\"This content was flagged for possible cybersecurity risk.\",\"code\":\"cyber_policy_violation\",\"param\":\"input\",\"details\":{\"policy_category\":\"cybersecurity\",\"appeal_allowed\":true}}}}\n\n",
)
.as_bytes();
- let terminal_error = aether_ai_formats::api::extract_provider_private_stream_error_body(
+ let terminal_error = crate::ai_serving::api::extract_provider_private_stream_error_body(
None,
provider_buffered_body,
)
diff --git a/apps/aether-gateway/src/execution_runtime/transport.rs b/apps/aether-gateway/src/execution_runtime/transport.rs
index 231e33923..4cf035b11 100644
--- a/apps/aether-gateway/src/execution_runtime/transport.rs
+++ b/apps/aether-gateway/src/execution_runtime/transport.rs
@@ -3760,6 +3760,15 @@ pub(crate) fn build_execution_response_body(
return Ok(None);
}
+ if !stream && response_body_is_json(headers, decoded_body_bytes) {
+ let body_json: Value = serde_json::from_slice(decoded_body_bytes)
+ .map_err(ExecutionRuntimeTransportError::InvalidJson)?;
+ return Ok(Some(ResponseBody {
+ json_body: Some(body_json),
+ body_bytes_b64: None,
+ }));
+ }
+
if let Some(body_json) = extract_provider_private_stream_error_body(None, decoded_body_bytes)
.or_else(|| extract_provider_private_stream_error_body(None, body_bytes))
{
@@ -3776,15 +3785,6 @@ pub(crate) fn build_execution_response_body(
}));
}
- if response_body_is_json(headers, decoded_body_bytes) {
- let body_json: Value = serde_json::from_slice(decoded_body_bytes)
- .map_err(ExecutionRuntimeTransportError::InvalidJson)?;
- return Ok(Some(ResponseBody {
- json_body: Some(body_json),
- body_bytes_b64: None,
- }));
- }
-
Ok(Some(ResponseBody {
json_body: None,
body_bytes_b64: Some(base64::engine::general_purpose::STANDARD.encode(body_bytes)),