mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
feat: 扩展 cache creation token 细分统计与 effective_input_tokens 计费逻辑
- 新增 cache_creation_ephemeral_5m/1h_input_tokens 字段,区分不同 TTL 的缓存写入 token - 引入 effective_input_tokens(扣除 cache read 后的有效输入 token),暴露给 usage 接口 - billing 规则生成器支持 5m/1h ephemeral cache 独立定价与分级计费 - usage_mapper 增加 Claude/Anthropic 格式映射,修复 OpenAI responses 格式字段兼容性 - 迁移逻辑增强:支持 checksum 容错、applied/pending 数量日志、逐步执行信息输出 - executor 抽离 LocalExecutionRequestOutcome 类型,统一 sync/stream 路径返回语义 - provider-transport auth 层新增 complete passthrough headers 构建逻辑 - 前端 usage 类型全面补充 effective_input_tokens、cache_creation_tokens、total_input_context 字段
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -23,6 +23,7 @@ dependencies = [
|
||||
name = "aether-admin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aether-billing",
|
||||
"aether-contracts",
|
||||
"aether-data",
|
||||
"aether-data-contracts",
|
||||
@@ -113,6 +114,7 @@ dependencies = [
|
||||
"sqlx",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -7,7 +7,9 @@ use crate::ai_pipeline::transport::antigravity::{
|
||||
classify_local_antigravity_request_support, AntigravityEnvelopeRequestType,
|
||||
AntigravityRequestEnvelopeSupport, AntigravityRequestSideSupport,
|
||||
};
|
||||
use crate::ai_pipeline::transport::auth::build_openai_passthrough_headers;
|
||||
use crate::ai_pipeline::transport::auth::{
|
||||
build_complete_passthrough_headers, build_complete_passthrough_headers_with_auth,
|
||||
};
|
||||
use crate::ai_pipeline::transport::claude_code::build_claude_code_passthrough_headers;
|
||||
use crate::ai_pipeline::transport::kiro::{
|
||||
build_kiro_provider_headers, KiroProviderHeadersInput, KIRO_ENVELOPE_NAME,
|
||||
@@ -198,13 +200,13 @@ pub(crate) async fn maybe_build_local_same_format_provider_decision_payload_for_
|
||||
transport.key.fingerprint.as_ref(),
|
||||
)
|
||||
} else if is_vertex {
|
||||
crate::ai_pipeline::transport::build_passthrough_headers(
|
||||
build_complete_passthrough_headers(
|
||||
&parts.headers,
|
||||
&extra_headers,
|
||||
Some("application/json"),
|
||||
)
|
||||
} else {
|
||||
build_openai_passthrough_headers(
|
||||
build_complete_passthrough_headers_with_auth(
|
||||
&parts.headers,
|
||||
auth_header.as_deref().unwrap_or_default(),
|
||||
auth_value.as_deref().unwrap_or_default(),
|
||||
|
||||
@@ -6,7 +6,7 @@ use tracing::warn;
|
||||
use crate::ai_pipeline::planner::common::force_upstream_streaming_for_provider;
|
||||
use crate::ai_pipeline::planner::standard::apply_codex_openai_cli_special_headers;
|
||||
use crate::ai_pipeline::transport::auth::{
|
||||
build_openai_passthrough_headers, ensure_upstream_auth_header,
|
||||
build_claude_passthrough_headers, build_openai_passthrough_headers, ensure_upstream_auth_header,
|
||||
};
|
||||
use crate::ai_pipeline::transport::{
|
||||
apply_local_header_rules, resolve_transport_execution_timeouts,
|
||||
@@ -245,13 +245,23 @@ pub(super) async fn maybe_build_local_standard_decision_payload_for_candidate(
|
||||
}
|
||||
};
|
||||
|
||||
let mut provider_request_headers = build_openai_passthrough_headers(
|
||||
let mut provider_request_headers = if provider_api_format.starts_with("claude:") {
|
||||
build_claude_passthrough_headers(
|
||||
&parts.headers,
|
||||
&auth_header,
|
||||
&auth_value,
|
||||
&BTreeMap::new(),
|
||||
Some("application/json"),
|
||||
);
|
||||
)
|
||||
} else {
|
||||
build_openai_passthrough_headers(
|
||||
&parts.headers,
|
||||
&auth_header,
|
||||
&auth_value,
|
||||
&BTreeMap::new(),
|
||||
Some("application/json"),
|
||||
)
|
||||
};
|
||||
if !apply_local_header_rules(
|
||||
&mut provider_request_headers,
|
||||
transport.endpoint.header_rules.as_ref(),
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
use serde_json::json;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::build_cross_format_openai_cli_request_body;
|
||||
use super::{build_cross_format_openai_cli_request_body, build_local_openai_cli_request_body};
|
||||
|
||||
fn object_keys(value: &Value) -> Vec<&str> {
|
||||
value
|
||||
.as_object()
|
||||
.expect("json object")
|
||||
.keys()
|
||||
.map(String::as_str)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_openai_chat_cross_format_request_body_from_openai_cli_source() {
|
||||
@@ -26,6 +35,52 @@ fn builds_openai_chat_cross_format_request_body_from_openai_cli_source() {
|
||||
assert_eq!(provider_request_body["messages"][0]["content"], "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_openai_cli_wrapper_preserves_body_order_after_edits() {
|
||||
let body_json: Value = serde_json::from_str(
|
||||
r#"{
|
||||
"text": {"format": {"type": "text"}},
|
||||
"input": [],
|
||||
"model": "gpt-5.4",
|
||||
"store": false,
|
||||
"tools": [],
|
||||
"stream": true,
|
||||
"include": ["reasoning.encrypted_content"],
|
||||
"reasoning": {"effort": "high"},
|
||||
"tool_choice": "auto"
|
||||
}"#,
|
||||
)
|
||||
.expect("request body should parse");
|
||||
|
||||
let provider_request_body = build_local_openai_cli_request_body(
|
||||
&body_json,
|
||||
"gpt-5.4",
|
||||
true,
|
||||
"codex",
|
||||
"openai:cli",
|
||||
None,
|
||||
Some("key-123"),
|
||||
)
|
||||
.expect("local openai cli body should build");
|
||||
|
||||
assert_eq!(
|
||||
object_keys(&provider_request_body),
|
||||
vec![
|
||||
"text",
|
||||
"input",
|
||||
"model",
|
||||
"store",
|
||||
"tools",
|
||||
"stream",
|
||||
"include",
|
||||
"reasoning",
|
||||
"tool_choice",
|
||||
"instructions",
|
||||
"prompt_cache_key",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_metadata_for_codex_openai_cli_requests() {
|
||||
let body_json = json!({
|
||||
|
||||
@@ -16,7 +16,7 @@ use crate::ai_pipeline::planner::standard::{
|
||||
build_cross_format_openai_chat_upstream_url,
|
||||
};
|
||||
use crate::ai_pipeline::transport::auth::{
|
||||
build_openai_passthrough_headers, ensure_upstream_auth_header,
|
||||
build_claude_passthrough_headers, build_openai_passthrough_headers, ensure_upstream_auth_header,
|
||||
};
|
||||
use crate::ai_pipeline::transport::{
|
||||
apply_local_header_rules, resolve_transport_execution_timeouts,
|
||||
@@ -182,13 +182,23 @@ pub(super) async fn build_cross_format_local_openai_chat_decision_payload_for_ca
|
||||
return None;
|
||||
};
|
||||
|
||||
let mut provider_request_headers = build_openai_passthrough_headers(
|
||||
let mut provider_request_headers = if provider_api_format.starts_with("claude:") {
|
||||
build_claude_passthrough_headers(
|
||||
&parts.headers,
|
||||
&auth_header,
|
||||
&auth_value,
|
||||
&BTreeMap::new(),
|
||||
Some("application/json"),
|
||||
);
|
||||
)
|
||||
} else {
|
||||
build_openai_passthrough_headers(
|
||||
&parts.headers,
|
||||
&auth_header,
|
||||
&auth_value,
|
||||
&BTreeMap::new(),
|
||||
Some("application/json"),
|
||||
)
|
||||
};
|
||||
if !apply_local_header_rules(
|
||||
&mut provider_request_headers,
|
||||
transport.endpoint.header_rules.as_ref(),
|
||||
|
||||
@@ -9,7 +9,8 @@ use crate::ai_pipeline::planner::standard::{
|
||||
build_local_openai_chat_upstream_url,
|
||||
};
|
||||
use crate::ai_pipeline::transport::auth::{
|
||||
build_openai_passthrough_headers, ensure_upstream_auth_header, resolve_local_openai_chat_auth,
|
||||
build_complete_passthrough_headers_with_auth, ensure_upstream_auth_header,
|
||||
resolve_local_openai_chat_auth,
|
||||
};
|
||||
use crate::ai_pipeline::transport::policy::supports_local_openai_chat_transport;
|
||||
use crate::ai_pipeline::transport::{
|
||||
@@ -140,7 +141,7 @@ pub(super) async fn build_same_format_local_openai_chat_decision_payload_for_can
|
||||
return None;
|
||||
};
|
||||
|
||||
let mut provider_request_headers = build_openai_passthrough_headers(
|
||||
let mut provider_request_headers = build_complete_passthrough_headers_with_auth(
|
||||
&parts.headers,
|
||||
&auth_header,
|
||||
&auth_value,
|
||||
|
||||
@@ -22,6 +22,7 @@ use crate::ai_pipeline::transport::antigravity::{
|
||||
};
|
||||
use crate::ai_pipeline::transport::apply_local_header_rules;
|
||||
use crate::ai_pipeline::transport::auth::{
|
||||
build_claude_passthrough_headers, build_complete_passthrough_headers_with_auth,
|
||||
build_openai_passthrough_headers, ensure_upstream_auth_header, resolve_local_gemini_auth,
|
||||
resolve_local_standard_auth,
|
||||
};
|
||||
@@ -354,16 +355,35 @@ pub(crate) async fn resolve_local_openai_cli_candidate_payload_parts(
|
||||
return None;
|
||||
};
|
||||
|
||||
let mut provider_request_headers = build_openai_passthrough_headers(
|
||||
let extra_headers = antigravity_auth
|
||||
.as_ref()
|
||||
.map(build_antigravity_static_identity_headers)
|
||||
.unwrap_or_default();
|
||||
let mut provider_request_headers = if same_format {
|
||||
build_complete_passthrough_headers_with_auth(
|
||||
&parts.headers,
|
||||
&auth_header,
|
||||
&auth_value,
|
||||
&antigravity_auth
|
||||
.as_ref()
|
||||
.map(build_antigravity_static_identity_headers)
|
||||
.unwrap_or_default(),
|
||||
&extra_headers,
|
||||
Some("application/json"),
|
||||
);
|
||||
)
|
||||
} else if provider_api_format.starts_with("claude:") {
|
||||
build_claude_passthrough_headers(
|
||||
&parts.headers,
|
||||
&auth_header,
|
||||
&auth_value,
|
||||
&extra_headers,
|
||||
Some("application/json"),
|
||||
)
|
||||
} else {
|
||||
build_openai_passthrough_headers(
|
||||
&parts.headers,
|
||||
&auth_header,
|
||||
&auth_value,
|
||||
&extra_headers,
|
||||
Some("application/json"),
|
||||
)
|
||||
};
|
||||
if !apply_local_header_rules(
|
||||
&mut provider_request_headers,
|
||||
transport.endpoint.header_rules.as_ref(),
|
||||
|
||||
@@ -6,6 +6,7 @@ use super::super::{
|
||||
};
|
||||
use crate::ai_pipeline::provider_adaptation_requires_eventstream_accept;
|
||||
use crate::ai_pipeline::transport::auth::{
|
||||
build_claude_passthrough_headers, build_complete_passthrough_headers_with_auth,
|
||||
build_openai_passthrough_headers, ensure_upstream_auth_header,
|
||||
};
|
||||
use crate::ai_pipeline::transport::url::{build_openai_chat_url, build_openai_cli_url};
|
||||
@@ -132,6 +133,23 @@ pub(crate) fn build_openai_chat_stream_plan_from_decision(
|
||||
};
|
||||
|
||||
let mut provider_request_headers = if payload.provider_request_headers.is_empty() {
|
||||
if provider_api_format == client_api_format {
|
||||
build_complete_passthrough_headers_with_auth(
|
||||
&parts.headers,
|
||||
&auth_header,
|
||||
&auth_value,
|
||||
&payload.extra_headers,
|
||||
payload.content_type.as_deref(),
|
||||
)
|
||||
} else if provider_api_format.starts_with("claude:") {
|
||||
build_claude_passthrough_headers(
|
||||
&parts.headers,
|
||||
&auth_header,
|
||||
&auth_value,
|
||||
&payload.extra_headers,
|
||||
payload.content_type.as_deref(),
|
||||
)
|
||||
} else {
|
||||
build_openai_passthrough_headers(
|
||||
&parts.headers,
|
||||
&auth_header,
|
||||
@@ -139,6 +157,7 @@ pub(crate) fn build_openai_chat_stream_plan_from_decision(
|
||||
&payload.extra_headers,
|
||||
payload.content_type.as_deref(),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
payload.provider_request_headers.clone()
|
||||
};
|
||||
@@ -281,6 +300,11 @@ pub(crate) fn build_openai_cli_stream_plan_from_decision(
|
||||
} else {
|
||||
provider_request_headers.insert("accept".to_string(), "text/event-stream".to_string());
|
||||
}
|
||||
let report_context = augment_sync_report_context(
|
||||
payload.report_context,
|
||||
&provider_request_headers,
|
||||
&provider_request_body_value,
|
||||
)?;
|
||||
let plan = ExecutionPlan {
|
||||
request_id,
|
||||
candidate_id: payload.candidate_id.clone(),
|
||||
@@ -306,15 +330,276 @@ pub(crate) fn build_openai_cli_stream_plan_from_decision(
|
||||
timeouts: payload.timeouts.clone(),
|
||||
};
|
||||
|
||||
let report_context = augment_sync_report_context(
|
||||
payload.report_context,
|
||||
&plan.headers,
|
||||
&provider_request_body_value,
|
||||
)?;
|
||||
|
||||
Ok(Some(LocalStreamPlanAndReport {
|
||||
plan,
|
||||
report_kind: payload.report_kind,
|
||||
report_context,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::{
|
||||
build_openai_chat_stream_plan_from_decision, build_openai_cli_stream_plan_from_decision,
|
||||
};
|
||||
use crate::GatewayControlSyncDecisionResponse;
|
||||
|
||||
fn object_keys(value: &Value) -> Vec<&str> {
|
||||
value
|
||||
.as_object()
|
||||
.expect("value should be an object")
|
||||
.keys()
|
||||
.map(String::as_str)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn sample_cli_payload() -> GatewayControlSyncDecisionResponse {
|
||||
GatewayControlSyncDecisionResponse {
|
||||
action: "stream".to_string(),
|
||||
decision_kind: Some("openai_cli_stream".to_string()),
|
||||
execution_strategy: None,
|
||||
conversion_mode: None,
|
||||
request_id: Some("req_123".to_string()),
|
||||
candidate_id: Some("cand_123".to_string()),
|
||||
provider_name: Some("Codex".to_string()),
|
||||
provider_id: Some("prov_123".to_string()),
|
||||
endpoint_id: Some("ep_123".to_string()),
|
||||
key_id: Some("key_123".to_string()),
|
||||
upstream_base_url: Some("https://example.com".to_string()),
|
||||
upstream_url: Some("https://example.com/v1/responses".to_string()),
|
||||
provider_request_method: None,
|
||||
auth_header: Some("authorization".to_string()),
|
||||
auth_value: Some("Bearer test".to_string()),
|
||||
provider_api_format: Some("openai:cli".to_string()),
|
||||
client_api_format: Some("openai:cli".to_string()),
|
||||
provider_contract: Some("openai:cli".to_string()),
|
||||
client_contract: Some("openai:cli".to_string()),
|
||||
model_name: Some("gpt-5.4".to_string()),
|
||||
mapped_model: Some("gpt-5.4".to_string()),
|
||||
prompt_cache_key: Some("cache-key".to_string()),
|
||||
extra_headers: BTreeMap::new(),
|
||||
provider_request_headers: BTreeMap::from([(
|
||||
"content-type".to_string(),
|
||||
"application/json".to_string(),
|
||||
)]),
|
||||
provider_request_body: Some(json!({
|
||||
"text": {"verbosity": "low"},
|
||||
"input": [],
|
||||
"model": "gpt-5.4",
|
||||
"store": false,
|
||||
"tools": [],
|
||||
"stream": true,
|
||||
"include": ["reasoning.encrypted_content"],
|
||||
"reasoning": {"effort": "high"},
|
||||
"tool_choice": "auto",
|
||||
"instructions": "You are Codex.",
|
||||
"prompt_cache_key": "cache-key"
|
||||
})),
|
||||
provider_request_body_base64: None,
|
||||
content_type: Some("application/json".to_string()),
|
||||
proxy: None,
|
||||
tls_profile: None,
|
||||
timeouts: None,
|
||||
upstream_is_stream: true,
|
||||
report_kind: Some("openai_cli_stream_success".to_string()),
|
||||
report_context: Some(json!({})),
|
||||
auth_context: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_openai_cli_stream_plan_preserves_provider_request_body_order_in_plan_and_report() {
|
||||
let parts = http::Request::builder()
|
||||
.uri("http://localhost/v1/responses")
|
||||
.body(())
|
||||
.expect("request should build")
|
||||
.into_parts()
|
||||
.0;
|
||||
let payload = sample_cli_payload();
|
||||
|
||||
let built = build_openai_cli_stream_plan_from_decision(&parts, &json!({}), payload, false)
|
||||
.expect("plan build should succeed")
|
||||
.expect("plan should be produced");
|
||||
let plan_body = built
|
||||
.plan
|
||||
.body
|
||||
.json_body
|
||||
.as_ref()
|
||||
.expect("plan json body should exist");
|
||||
assert_eq!(
|
||||
object_keys(plan_body),
|
||||
vec![
|
||||
"text",
|
||||
"input",
|
||||
"model",
|
||||
"store",
|
||||
"tools",
|
||||
"stream",
|
||||
"include",
|
||||
"reasoning",
|
||||
"tool_choice",
|
||||
"instructions",
|
||||
"prompt_cache_key",
|
||||
]
|
||||
);
|
||||
let report_context = built
|
||||
.report_context
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("provider_request_body"))
|
||||
.expect("report context should contain provider request body");
|
||||
assert_eq!(object_keys(report_context), object_keys(plan_body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_openai_chat_stream_plan_fallback_preserves_complete_same_format_headers() {
|
||||
let parts = http::Request::builder()
|
||||
.uri("http://localhost/v1/chat/completions")
|
||||
.header(http::header::AUTHORIZATION, "Bearer client-token")
|
||||
.header("x-stainless-runtime-version", "v24.0.0")
|
||||
.header("x-app", "codex")
|
||||
.body(())
|
||||
.expect("request should build")
|
||||
.into_parts()
|
||||
.0;
|
||||
let payload = GatewayControlSyncDecisionResponse {
|
||||
action: "stream".to_string(),
|
||||
decision_kind: Some("openai_chat_stream".to_string()),
|
||||
execution_strategy: None,
|
||||
conversion_mode: None,
|
||||
request_id: Some("req_stream_456".to_string()),
|
||||
candidate_id: Some("cand_stream_456".to_string()),
|
||||
provider_name: Some("OpenAI".to_string()),
|
||||
provider_id: Some("prov_stream_456".to_string()),
|
||||
endpoint_id: Some("ep_stream_456".to_string()),
|
||||
key_id: Some("key_stream_456".to_string()),
|
||||
upstream_base_url: Some("https://example.com".to_string()),
|
||||
upstream_url: Some("https://example.com/v1/chat/completions".to_string()),
|
||||
provider_request_method: None,
|
||||
auth_header: Some("authorization".to_string()),
|
||||
auth_value: Some("Bearer upstream-token".to_string()),
|
||||
provider_api_format: Some("openai:chat".to_string()),
|
||||
client_api_format: Some("openai:chat".to_string()),
|
||||
provider_contract: Some("openai:chat".to_string()),
|
||||
client_contract: Some("openai:chat".to_string()),
|
||||
model_name: Some("gpt-5.4".to_string()),
|
||||
mapped_model: Some("gpt-5.4".to_string()),
|
||||
prompt_cache_key: None,
|
||||
extra_headers: BTreeMap::new(),
|
||||
provider_request_headers: BTreeMap::new(),
|
||||
provider_request_body: Some(json!({"model":"gpt-5.4","messages":[],"stream":true})),
|
||||
provider_request_body_base64: None,
|
||||
content_type: Some("application/json".to_string()),
|
||||
proxy: None,
|
||||
tls_profile: None,
|
||||
timeouts: None,
|
||||
upstream_is_stream: true,
|
||||
report_kind: Some("openai_chat_stream_success".to_string()),
|
||||
report_context: Some(json!({})),
|
||||
auth_context: None,
|
||||
};
|
||||
|
||||
let built = build_openai_chat_stream_plan_from_decision(&parts, &json!({}), payload)
|
||||
.expect("plan build should succeed")
|
||||
.expect("plan should be produced");
|
||||
|
||||
assert_eq!(
|
||||
built.plan.headers.get("authorization").map(String::as_str),
|
||||
Some("Bearer upstream-token")
|
||||
);
|
||||
assert_eq!(
|
||||
built
|
||||
.plan
|
||||
.headers
|
||||
.get("x-stainless-runtime-version")
|
||||
.map(String::as_str),
|
||||
Some("v24.0.0")
|
||||
);
|
||||
assert_eq!(
|
||||
built.plan.headers.get("x-app").map(String::as_str),
|
||||
Some("codex")
|
||||
);
|
||||
assert_eq!(
|
||||
built.plan.headers.get("accept").map(String::as_str),
|
||||
Some("text/event-stream")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_openai_chat_stream_plan_fallback_restores_claude_headers_for_cross_format() {
|
||||
let parts = http::Request::builder()
|
||||
.uri("http://localhost/v1/chat/completions")
|
||||
.header("anthropic-beta", "prompt-caching-2024-07-31")
|
||||
.header("x-stainless-runtime-version", "v24.0.0")
|
||||
.body(())
|
||||
.expect("request should build")
|
||||
.into_parts()
|
||||
.0;
|
||||
let payload = GatewayControlSyncDecisionResponse {
|
||||
action: "stream".to_string(),
|
||||
decision_kind: Some("openai_chat_stream".to_string()),
|
||||
execution_strategy: None,
|
||||
conversion_mode: Some("format_conversion".to_string()),
|
||||
request_id: Some("req_stream_789".to_string()),
|
||||
candidate_id: Some("cand_stream_789".to_string()),
|
||||
provider_name: Some("Claude".to_string()),
|
||||
provider_id: Some("prov_stream_789".to_string()),
|
||||
endpoint_id: Some("ep_stream_789".to_string()),
|
||||
key_id: Some("key_stream_789".to_string()),
|
||||
upstream_base_url: Some("https://example.com".to_string()),
|
||||
upstream_url: Some("https://example.com/v1/messages".to_string()),
|
||||
provider_request_method: None,
|
||||
auth_header: Some("x-api-key".to_string()),
|
||||
auth_value: Some("sk-upstream-claude".to_string()),
|
||||
provider_api_format: Some("claude:chat".to_string()),
|
||||
client_api_format: Some("openai:chat".to_string()),
|
||||
provider_contract: Some("claude:chat".to_string()),
|
||||
client_contract: Some("openai:chat".to_string()),
|
||||
model_name: Some("claude-sonnet-4-5".to_string()),
|
||||
mapped_model: Some("claude-sonnet-4-5".to_string()),
|
||||
prompt_cache_key: None,
|
||||
extra_headers: BTreeMap::new(),
|
||||
provider_request_headers: BTreeMap::new(),
|
||||
provider_request_body: Some(
|
||||
json!({"model":"claude-sonnet-4-5","messages":[],"stream":true}),
|
||||
),
|
||||
provider_request_body_base64: None,
|
||||
content_type: Some("application/json".to_string()),
|
||||
proxy: None,
|
||||
tls_profile: None,
|
||||
timeouts: None,
|
||||
upstream_is_stream: true,
|
||||
report_kind: Some("openai_chat_stream_success".to_string()),
|
||||
report_context: Some(json!({})),
|
||||
auth_context: None,
|
||||
};
|
||||
|
||||
let built = build_openai_chat_stream_plan_from_decision(&parts, &json!({}), payload)
|
||||
.expect("plan build should succeed")
|
||||
.expect("plan should be produced");
|
||||
|
||||
assert_eq!(
|
||||
built.plan.headers.get("x-api-key").map(String::as_str),
|
||||
Some("sk-upstream-claude")
|
||||
);
|
||||
assert_eq!(
|
||||
built.plan.headers.get("anthropic-beta").map(String::as_str),
|
||||
Some("prompt-caching-2024-07-31")
|
||||
);
|
||||
assert_eq!(
|
||||
built
|
||||
.plan
|
||||
.headers
|
||||
.get("anthropic-version")
|
||||
.map(String::as_str),
|
||||
Some("2023-06-01")
|
||||
);
|
||||
assert_eq!(
|
||||
built.plan.headers.get("accept").map(String::as_str),
|
||||
Some("text/event-stream")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ use super::super::{
|
||||
LocalSyncPlanAndReport,
|
||||
};
|
||||
use crate::ai_pipeline::transport::auth::{
|
||||
build_claude_passthrough_headers, build_complete_passthrough_headers_with_auth,
|
||||
build_openai_passthrough_headers, ensure_upstream_auth_header,
|
||||
};
|
||||
use crate::ai_pipeline::transport::url::{build_openai_chat_url, build_openai_cli_url};
|
||||
@@ -132,6 +133,23 @@ pub(crate) fn build_openai_chat_sync_plan_from_decision(
|
||||
};
|
||||
|
||||
let mut provider_request_headers = if payload.provider_request_headers.is_empty() {
|
||||
if provider_api_format == client_api_format {
|
||||
build_complete_passthrough_headers_with_auth(
|
||||
&parts.headers,
|
||||
&auth_header,
|
||||
&auth_value,
|
||||
&payload.extra_headers,
|
||||
payload.content_type.as_deref(),
|
||||
)
|
||||
} else if provider_api_format.starts_with("claude:") {
|
||||
build_claude_passthrough_headers(
|
||||
&parts.headers,
|
||||
&auth_header,
|
||||
&auth_value,
|
||||
&payload.extra_headers,
|
||||
payload.content_type.as_deref(),
|
||||
)
|
||||
} else {
|
||||
build_openai_passthrough_headers(
|
||||
&parts.headers,
|
||||
&auth_header,
|
||||
@@ -139,6 +157,7 @@ pub(crate) fn build_openai_chat_sync_plan_from_decision(
|
||||
&payload.extra_headers,
|
||||
payload.content_type.as_deref(),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
payload.provider_request_headers.clone()
|
||||
};
|
||||
@@ -275,6 +294,11 @@ pub(crate) fn build_openai_cli_sync_plan_from_decision(
|
||||
if payload.upstream_is_stream && !provider_request_headers.contains_key("accept") {
|
||||
provider_request_headers.insert("accept".to_string(), "text/event-stream".to_string());
|
||||
}
|
||||
let report_context = augment_sync_report_context(
|
||||
payload.report_context,
|
||||
&provider_request_headers,
|
||||
&provider_request_body_value,
|
||||
)?;
|
||||
let plan = ExecutionPlan {
|
||||
request_id,
|
||||
candidate_id: payload.candidate_id.clone(),
|
||||
@@ -300,15 +324,268 @@ pub(crate) fn build_openai_cli_sync_plan_from_decision(
|
||||
timeouts: payload.timeouts.clone(),
|
||||
};
|
||||
|
||||
let report_context = augment_sync_report_context(
|
||||
payload.report_context,
|
||||
&plan.headers,
|
||||
&provider_request_body_value,
|
||||
)?;
|
||||
|
||||
Ok(Some(LocalSyncPlanAndReport {
|
||||
plan,
|
||||
report_kind: payload.report_kind,
|
||||
report_context,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::{
|
||||
build_openai_chat_sync_plan_from_decision, build_openai_cli_sync_plan_from_decision,
|
||||
};
|
||||
use crate::GatewayControlSyncDecisionResponse;
|
||||
|
||||
fn object_keys(value: &Value) -> Vec<&str> {
|
||||
value
|
||||
.as_object()
|
||||
.expect("value should be an object")
|
||||
.keys()
|
||||
.map(String::as_str)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn sample_cli_payload() -> GatewayControlSyncDecisionResponse {
|
||||
GatewayControlSyncDecisionResponse {
|
||||
action: "sync".to_string(),
|
||||
decision_kind: Some("openai_cli_sync".to_string()),
|
||||
execution_strategy: None,
|
||||
conversion_mode: None,
|
||||
request_id: Some("req_123".to_string()),
|
||||
candidate_id: Some("cand_123".to_string()),
|
||||
provider_name: Some("Codex".to_string()),
|
||||
provider_id: Some("prov_123".to_string()),
|
||||
endpoint_id: Some("ep_123".to_string()),
|
||||
key_id: Some("key_123".to_string()),
|
||||
upstream_base_url: Some("https://example.com".to_string()),
|
||||
upstream_url: Some("https://example.com/v1/responses".to_string()),
|
||||
provider_request_method: None,
|
||||
auth_header: Some("authorization".to_string()),
|
||||
auth_value: Some("Bearer test".to_string()),
|
||||
provider_api_format: Some("openai:cli".to_string()),
|
||||
client_api_format: Some("openai:cli".to_string()),
|
||||
provider_contract: Some("openai:cli".to_string()),
|
||||
client_contract: Some("openai:cli".to_string()),
|
||||
model_name: Some("gpt-5.4".to_string()),
|
||||
mapped_model: Some("gpt-5.4".to_string()),
|
||||
prompt_cache_key: Some("cache-key".to_string()),
|
||||
extra_headers: BTreeMap::new(),
|
||||
provider_request_headers: BTreeMap::from([(
|
||||
"content-type".to_string(),
|
||||
"application/json".to_string(),
|
||||
)]),
|
||||
provider_request_body: Some(json!({
|
||||
"text": {"verbosity": "low"},
|
||||
"input": [],
|
||||
"model": "gpt-5.4",
|
||||
"store": false,
|
||||
"tools": [],
|
||||
"stream": true,
|
||||
"include": ["reasoning.encrypted_content"],
|
||||
"reasoning": {"effort": "high"},
|
||||
"tool_choice": "auto",
|
||||
"instructions": "You are Codex.",
|
||||
"prompt_cache_key": "cache-key"
|
||||
})),
|
||||
provider_request_body_base64: None,
|
||||
content_type: Some("application/json".to_string()),
|
||||
proxy: None,
|
||||
tls_profile: None,
|
||||
timeouts: None,
|
||||
upstream_is_stream: true,
|
||||
report_kind: Some("openai_cli_sync_success".to_string()),
|
||||
report_context: Some(json!({})),
|
||||
auth_context: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_openai_cli_sync_plan_preserves_provider_request_body_order_in_plan_and_report() {
|
||||
let parts = http::Request::builder()
|
||||
.uri("http://localhost/v1/responses")
|
||||
.body(())
|
||||
.expect("request should build")
|
||||
.into_parts()
|
||||
.0;
|
||||
let payload = sample_cli_payload();
|
||||
|
||||
let built = build_openai_cli_sync_plan_from_decision(&parts, &json!({}), payload, false)
|
||||
.expect("plan build should succeed")
|
||||
.expect("plan should be produced");
|
||||
let plan_body = built
|
||||
.plan
|
||||
.body
|
||||
.json_body
|
||||
.as_ref()
|
||||
.expect("plan json body should exist");
|
||||
assert_eq!(
|
||||
object_keys(plan_body),
|
||||
vec![
|
||||
"text",
|
||||
"input",
|
||||
"model",
|
||||
"store",
|
||||
"tools",
|
||||
"stream",
|
||||
"include",
|
||||
"reasoning",
|
||||
"tool_choice",
|
||||
"instructions",
|
||||
"prompt_cache_key",
|
||||
]
|
||||
);
|
||||
let report_context = built
|
||||
.report_context
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("provider_request_body"))
|
||||
.expect("report context should contain provider request body");
|
||||
assert_eq!(object_keys(report_context), object_keys(plan_body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_openai_chat_sync_plan_fallback_preserves_complete_same_format_headers() {
|
||||
let parts = http::Request::builder()
|
||||
.uri("http://localhost/v1/chat/completions")
|
||||
.header(http::header::AUTHORIZATION, "Bearer client-token")
|
||||
.header("x-stainless-runtime-version", "v24.0.0")
|
||||
.header("x-app", "codex")
|
||||
.body(())
|
||||
.expect("request should build")
|
||||
.into_parts()
|
||||
.0;
|
||||
let payload = GatewayControlSyncDecisionResponse {
|
||||
action: "sync".to_string(),
|
||||
decision_kind: Some("openai_chat_sync".to_string()),
|
||||
execution_strategy: None,
|
||||
conversion_mode: None,
|
||||
request_id: Some("req_456".to_string()),
|
||||
candidate_id: Some("cand_456".to_string()),
|
||||
provider_name: Some("OpenAI".to_string()),
|
||||
provider_id: Some("prov_456".to_string()),
|
||||
endpoint_id: Some("ep_456".to_string()),
|
||||
key_id: Some("key_456".to_string()),
|
||||
upstream_base_url: Some("https://example.com".to_string()),
|
||||
upstream_url: Some("https://example.com/v1/chat/completions".to_string()),
|
||||
provider_request_method: None,
|
||||
auth_header: Some("authorization".to_string()),
|
||||
auth_value: Some("Bearer upstream-token".to_string()),
|
||||
provider_api_format: Some("openai:chat".to_string()),
|
||||
client_api_format: Some("openai:chat".to_string()),
|
||||
provider_contract: Some("openai:chat".to_string()),
|
||||
client_contract: Some("openai:chat".to_string()),
|
||||
model_name: Some("gpt-5.4".to_string()),
|
||||
mapped_model: Some("gpt-5.4".to_string()),
|
||||
prompt_cache_key: None,
|
||||
extra_headers: BTreeMap::new(),
|
||||
provider_request_headers: BTreeMap::new(),
|
||||
provider_request_body: Some(json!({"model":"gpt-5.4","messages":[],"stream":false})),
|
||||
provider_request_body_base64: None,
|
||||
content_type: Some("application/json".to_string()),
|
||||
proxy: None,
|
||||
tls_profile: None,
|
||||
timeouts: None,
|
||||
upstream_is_stream: false,
|
||||
report_kind: Some("openai_chat_sync_success".to_string()),
|
||||
report_context: Some(json!({})),
|
||||
auth_context: None,
|
||||
};
|
||||
|
||||
let built = build_openai_chat_sync_plan_from_decision(&parts, &json!({}), payload)
|
||||
.expect("plan build should succeed")
|
||||
.expect("plan should be produced");
|
||||
|
||||
assert_eq!(
|
||||
built.plan.headers.get("authorization").map(String::as_str),
|
||||
Some("Bearer upstream-token")
|
||||
);
|
||||
assert_eq!(
|
||||
built
|
||||
.plan
|
||||
.headers
|
||||
.get("x-stainless-runtime-version")
|
||||
.map(String::as_str),
|
||||
Some("v24.0.0")
|
||||
);
|
||||
assert_eq!(
|
||||
built.plan.headers.get("x-app").map(String::as_str),
|
||||
Some("codex")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_openai_chat_sync_plan_fallback_restores_claude_headers_for_cross_format() {
|
||||
let parts = http::Request::builder()
|
||||
.uri("http://localhost/v1/chat/completions")
|
||||
.header("anthropic-beta", "prompt-caching-2024-07-31")
|
||||
.header("x-stainless-runtime-version", "v24.0.0")
|
||||
.body(())
|
||||
.expect("request should build")
|
||||
.into_parts()
|
||||
.0;
|
||||
let payload = GatewayControlSyncDecisionResponse {
|
||||
action: "sync".to_string(),
|
||||
decision_kind: Some("openai_chat_sync".to_string()),
|
||||
execution_strategy: None,
|
||||
conversion_mode: Some("format_conversion".to_string()),
|
||||
request_id: Some("req_789".to_string()),
|
||||
candidate_id: Some("cand_789".to_string()),
|
||||
provider_name: Some("Claude".to_string()),
|
||||
provider_id: Some("prov_789".to_string()),
|
||||
endpoint_id: Some("ep_789".to_string()),
|
||||
key_id: Some("key_789".to_string()),
|
||||
upstream_base_url: Some("https://example.com".to_string()),
|
||||
upstream_url: Some("https://example.com/v1/messages".to_string()),
|
||||
provider_request_method: None,
|
||||
auth_header: Some("x-api-key".to_string()),
|
||||
auth_value: Some("sk-upstream-claude".to_string()),
|
||||
provider_api_format: Some("claude:chat".to_string()),
|
||||
client_api_format: Some("openai:chat".to_string()),
|
||||
provider_contract: Some("claude:chat".to_string()),
|
||||
client_contract: Some("openai:chat".to_string()),
|
||||
model_name: Some("claude-sonnet-4-5".to_string()),
|
||||
mapped_model: Some("claude-sonnet-4-5".to_string()),
|
||||
prompt_cache_key: None,
|
||||
extra_headers: BTreeMap::new(),
|
||||
provider_request_headers: BTreeMap::new(),
|
||||
provider_request_body: Some(
|
||||
json!({"model":"claude-sonnet-4-5","messages":[],"stream":false}),
|
||||
),
|
||||
provider_request_body_base64: None,
|
||||
content_type: Some("application/json".to_string()),
|
||||
proxy: None,
|
||||
tls_profile: None,
|
||||
timeouts: None,
|
||||
upstream_is_stream: false,
|
||||
report_kind: Some("openai_chat_sync_success".to_string()),
|
||||
report_context: Some(json!({})),
|
||||
auth_context: None,
|
||||
};
|
||||
|
||||
let built = build_openai_chat_sync_plan_from_decision(&parts, &json!({}), payload)
|
||||
.expect("plan build should succeed")
|
||||
.expect("plan should be produced");
|
||||
|
||||
assert_eq!(
|
||||
built.plan.headers.get("x-api-key").map(String::as_str),
|
||||
Some("sk-upstream-claude")
|
||||
);
|
||||
assert_eq!(
|
||||
built.plan.headers.get("anthropic-beta").map(String::as_str),
|
||||
Some("prompt-caching-2024-07-31")
|
||||
);
|
||||
assert_eq!(
|
||||
built
|
||||
.plan
|
||||
.headers
|
||||
.get("anthropic-version")
|
||||
.map(String::as_str),
|
||||
Some("2023-06-01")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ use axum::http::{HeaderName, HeaderValue, Response};
|
||||
|
||||
use crate::constants::CONTROL_EXECUTED_HEADER;
|
||||
use crate::control::GatewayControlDecision;
|
||||
use crate::executor::{maybe_execute_stream_request, maybe_execute_sync_request};
|
||||
use crate::executor::{
|
||||
maybe_execute_stream_request, maybe_execute_sync_request, LocalExecutionRequestOutcome,
|
||||
};
|
||||
use crate::{AppState, GatewayError};
|
||||
|
||||
use super::resolve_execution_runtime_auth_context;
|
||||
@@ -19,9 +21,9 @@ pub(crate) async fn maybe_execute_via_control(
|
||||
trace_id: &str,
|
||||
decision: Option<&GatewayControlDecision>,
|
||||
require_stream: bool,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
) -> Result<LocalExecutionRequestOutcome, GatewayError> {
|
||||
let Some(decision) = decision else {
|
||||
return Ok(None);
|
||||
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||
};
|
||||
|
||||
let mut local_decision = decision.clone();
|
||||
@@ -46,7 +48,15 @@ pub(crate) async fn maybe_execute_via_control(
|
||||
.await?
|
||||
};
|
||||
|
||||
Ok(response.map(mark_control_executed))
|
||||
Ok(match response {
|
||||
LocalExecutionRequestOutcome::Responded(response) => {
|
||||
LocalExecutionRequestOutcome::Responded(mark_control_executed(response))
|
||||
}
|
||||
LocalExecutionRequestOutcome::Exhausted(outcome) => {
|
||||
LocalExecutionRequestOutcome::Exhausted(outcome)
|
||||
}
|
||||
LocalExecutionRequestOutcome::NoPath => LocalExecutionRequestOutcome::NoPath,
|
||||
})
|
||||
}
|
||||
|
||||
fn mark_control_executed(mut response: Response<Body>) -> Response<Body> {
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
use axum::body::Body;
|
||||
use axum::http::Response;
|
||||
|
||||
use aether_data_contracts::repository::candidates::RequestCandidateStatus;
|
||||
use aether_scheduler_core::SchedulerRequestCandidateStatusUpdate;
|
||||
|
||||
use crate::ai_pipeline_api::{LocalStreamPlanAndReport, LocalSyncPlanAndReport};
|
||||
use crate::control::GatewayControlDecision;
|
||||
use crate::execution_runtime::{execute_execution_runtime_stream, execute_execution_runtime_sync};
|
||||
use crate::executor::{build_local_execution_exhaustion, LocalExecutionRequestOutcome};
|
||||
use crate::request_candidate_runtime::record_local_request_candidate_status;
|
||||
use crate::{AppState, GatewayError};
|
||||
|
||||
@@ -53,12 +51,17 @@ pub(crate) async fn execute_sync_plan_and_reports<T>(
|
||||
decision: &GatewayControlDecision,
|
||||
plan_kind: &str,
|
||||
plan_and_reports: Vec<T>,
|
||||
) -> Result<Option<Response<Body>>, GatewayError>
|
||||
) -> Result<LocalExecutionRequestOutcome, GatewayError>
|
||||
where
|
||||
T: LocalPlanAndReport,
|
||||
{
|
||||
let mut remaining = plan_and_reports.into_iter();
|
||||
let mut last_attempted = None;
|
||||
while let Some(plan_and_report) = remaining.next() {
|
||||
last_attempted = Some((
|
||||
plan_and_report.plan().clone(),
|
||||
plan_and_report.report_context(),
|
||||
));
|
||||
if let Some(response) = execute_execution_runtime_sync(
|
||||
state,
|
||||
parts.uri.path(),
|
||||
@@ -72,11 +75,16 @@ where
|
||||
.await?
|
||||
{
|
||||
mark_unused_local_candidates(state, remaining.collect()).await;
|
||||
return Ok(Some(response));
|
||||
return Ok(LocalExecutionRequestOutcome::responded(response));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
let Some((plan, report_context)) = last_attempted else {
|
||||
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||
};
|
||||
Ok(LocalExecutionRequestOutcome::Exhausted(
|
||||
build_local_execution_exhaustion(state, &plan, report_context.as_ref()).await,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) async fn execute_stream_plan_and_reports<T>(
|
||||
@@ -85,12 +93,17 @@ pub(crate) async fn execute_stream_plan_and_reports<T>(
|
||||
decision: &GatewayControlDecision,
|
||||
plan_kind: &str,
|
||||
plan_and_reports: Vec<T>,
|
||||
) -> Result<Option<Response<Body>>, GatewayError>
|
||||
) -> Result<LocalExecutionRequestOutcome, GatewayError>
|
||||
where
|
||||
T: LocalPlanAndReport,
|
||||
{
|
||||
let mut remaining = plan_and_reports.into_iter();
|
||||
let mut last_attempted = None;
|
||||
while let Some(plan_and_report) = remaining.next() {
|
||||
last_attempted = Some((
|
||||
plan_and_report.plan().clone(),
|
||||
plan_and_report.report_context(),
|
||||
));
|
||||
if let Some(response) = execute_execution_runtime_stream(
|
||||
state,
|
||||
plan_and_report.plan().clone(),
|
||||
@@ -103,11 +116,16 @@ where
|
||||
.await?
|
||||
{
|
||||
mark_unused_local_candidates(state, remaining.collect()).await;
|
||||
return Ok(Some(response));
|
||||
return Ok(LocalExecutionRequestOutcome::responded(response));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
let Some((plan, report_context)) = last_attempted else {
|
||||
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||
};
|
||||
Ok(LocalExecutionRequestOutcome::Exhausted(
|
||||
build_local_execution_exhaustion(state, &plan, report_context.as_ref()).await,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) async fn mark_unused_local_candidates<T>(state: &AppState, remaining: Vec<T>)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub(crate) mod candidate_loop;
|
||||
mod orchestration;
|
||||
mod outcome;
|
||||
mod plan_fallback;
|
||||
mod policy;
|
||||
mod remote;
|
||||
@@ -9,8 +10,15 @@ mod sync_path;
|
||||
pub(crate) use crate::request_candidate_runtime::{
|
||||
persist_available_local_candidate, persist_skipped_local_candidate,
|
||||
};
|
||||
pub(crate) use candidate_loop::mark_unused_local_candidate_items;
|
||||
pub(crate) use candidate_loop::{
|
||||
execute_stream_plan_and_reports, execute_sync_plan_and_reports,
|
||||
mark_unused_local_candidate_items,
|
||||
};
|
||||
pub(crate) use orchestration::*;
|
||||
pub(crate) use outcome::{
|
||||
build_local_execution_exhaustion, record_failed_usage_for_exhausted_request,
|
||||
LocalExecutionExhaustion, LocalExecutionRequestOutcome,
|
||||
};
|
||||
pub(crate) use plan_fallback::{
|
||||
maybe_execute_stream_via_plan_fallback, maybe_execute_sync_via_plan_fallback,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
use axum::body::Body;
|
||||
use axum::http::Response;
|
||||
|
||||
use crate::ai_pipeline_api::{
|
||||
build_local_gemini_files_stream_plan_and_reports_for_kind,
|
||||
build_local_gemini_files_sync_plan_and_reports_for_kind,
|
||||
@@ -21,6 +18,7 @@ use crate::control::GatewayControlDecision;
|
||||
use crate::executor::candidate_loop::{
|
||||
execute_stream_plan_and_reports, execute_sync_plan_and_reports,
|
||||
};
|
||||
use crate::executor::LocalExecutionRequestOutcome;
|
||||
use crate::{AppState, GatewayControlSyncDecisionResponse, GatewayError};
|
||||
|
||||
pub(crate) async fn maybe_execute_sync_local_path(
|
||||
@@ -29,7 +27,7 @@ pub(crate) async fn maybe_execute_sync_local_path(
|
||||
body_bytes: &axum::body::Bytes,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
) -> Result<LocalExecutionRequestOutcome, GatewayError> {
|
||||
super::maybe_execute_via_sync_decision_path(state, parts, body_bytes, trace_id, decision).await
|
||||
}
|
||||
|
||||
@@ -39,7 +37,7 @@ pub(crate) async fn maybe_execute_stream_local_path(
|
||||
body_bytes: &axum::body::Bytes,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
) -> Result<LocalExecutionRequestOutcome, GatewayError> {
|
||||
super::maybe_execute_via_stream_decision_path(state, parts, body_bytes, trace_id, decision)
|
||||
.await
|
||||
}
|
||||
@@ -51,17 +49,17 @@ pub(crate) async fn maybe_execute_sync_via_local_decision(
|
||||
decision: &GatewayControlDecision,
|
||||
body_json: &serde_json::Value,
|
||||
plan_kind: &str,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
) -> Result<LocalExecutionRequestOutcome, GatewayError> {
|
||||
let plan_and_reports = build_local_openai_chat_sync_plan_and_reports_for_kind(
|
||||
state, parts, trace_id, decision, body_json, plan_kind,
|
||||
)
|
||||
.await?;
|
||||
if plan_and_reports.is_empty() {
|
||||
return Ok(None);
|
||||
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||
}
|
||||
|
||||
let plan_count = plan_and_reports.len();
|
||||
if let Some(response) = execute_sync_plan_and_reports(
|
||||
let outcome = execute_sync_plan_and_reports(
|
||||
state,
|
||||
parts,
|
||||
trace_id,
|
||||
@@ -69,15 +67,15 @@ pub(crate) async fn maybe_execute_sync_via_local_decision(
|
||||
plan_kind,
|
||||
plan_and_reports,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(Some(response));
|
||||
}
|
||||
.await?;
|
||||
|
||||
if let LocalExecutionRequestOutcome::Exhausted(_) = &outcome {
|
||||
set_local_openai_chat_execution_exhausted_diagnostic(
|
||||
state, trace_id, decision, plan_kind, body_json, plan_count,
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_execute_stream_via_local_decision(
|
||||
@@ -87,27 +85,27 @@ pub(crate) async fn maybe_execute_stream_via_local_decision(
|
||||
decision: &GatewayControlDecision,
|
||||
body_json: &serde_json::Value,
|
||||
plan_kind: &str,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
) -> Result<LocalExecutionRequestOutcome, GatewayError> {
|
||||
let plan_and_reports = build_local_openai_chat_stream_plan_and_reports_for_kind(
|
||||
state, parts, trace_id, decision, body_json, plan_kind,
|
||||
)
|
||||
.await?;
|
||||
if plan_and_reports.is_empty() {
|
||||
return Ok(None);
|
||||
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||
}
|
||||
|
||||
let plan_count = plan_and_reports.len();
|
||||
if let Some(response) =
|
||||
let outcome =
|
||||
execute_stream_plan_and_reports(state, trace_id, decision, plan_kind, plan_and_reports)
|
||||
.await?
|
||||
{
|
||||
return Ok(Some(response));
|
||||
}
|
||||
.await?;
|
||||
|
||||
if let LocalExecutionRequestOutcome::Exhausted(_) = &outcome {
|
||||
set_local_openai_chat_execution_exhausted_diagnostic(
|
||||
state, trace_id, decision, plan_kind, body_json, plan_count,
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_execute_sync_via_local_openai_cli_decision(
|
||||
@@ -117,14 +115,14 @@ pub(crate) async fn maybe_execute_sync_via_local_openai_cli_decision(
|
||||
decision: &GatewayControlDecision,
|
||||
body_json: &serde_json::Value,
|
||||
plan_kind: &str,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
) -> Result<LocalExecutionRequestOutcome, GatewayError> {
|
||||
let plan_and_reports: Vec<LocalSyncPlanAndReport> =
|
||||
build_local_openai_cli_sync_plan_and_reports_for_kind(
|
||||
state, parts, trace_id, decision, body_json, plan_kind,
|
||||
)
|
||||
.await?;
|
||||
if plan_and_reports.is_empty() {
|
||||
return Ok(None);
|
||||
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||
}
|
||||
|
||||
execute_sync_plan_and_reports(
|
||||
@@ -145,14 +143,14 @@ pub(crate) async fn maybe_execute_stream_via_local_openai_cli_decision(
|
||||
decision: &GatewayControlDecision,
|
||||
body_json: &serde_json::Value,
|
||||
plan_kind: &str,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
) -> Result<LocalExecutionRequestOutcome, GatewayError> {
|
||||
let plan_and_reports: Vec<LocalStreamPlanAndReport> =
|
||||
build_local_openai_cli_stream_plan_and_reports_for_kind(
|
||||
state, parts, trace_id, decision, body_json, plan_kind,
|
||||
)
|
||||
.await?;
|
||||
if plan_and_reports.is_empty() {
|
||||
return Ok(None);
|
||||
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||
}
|
||||
|
||||
execute_stream_plan_and_reports(state, trace_id, decision, plan_kind, plan_and_reports).await
|
||||
@@ -166,9 +164,9 @@ pub(crate) async fn maybe_execute_sync_via_standard_family_decision(
|
||||
body_json: &serde_json::Value,
|
||||
plan_kind: &str,
|
||||
resolve_sync_spec: fn(&str) -> Option<LocalStandardSpec>,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
) -> Result<LocalExecutionRequestOutcome, GatewayError> {
|
||||
let Some(spec) = resolve_sync_spec(plan_kind) else {
|
||||
return Ok(None);
|
||||
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||
};
|
||||
|
||||
let plan_and_reports: Vec<LocalSyncPlanAndReport> =
|
||||
@@ -177,7 +175,7 @@ pub(crate) async fn maybe_execute_sync_via_standard_family_decision(
|
||||
)
|
||||
.await?;
|
||||
if plan_and_reports.is_empty() {
|
||||
return Ok(None);
|
||||
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||
}
|
||||
|
||||
execute_sync_plan_and_reports(
|
||||
@@ -199,9 +197,9 @@ pub(crate) async fn maybe_execute_stream_via_standard_family_decision(
|
||||
body_json: &serde_json::Value,
|
||||
plan_kind: &str,
|
||||
resolve_stream_spec: fn(&str) -> Option<LocalStandardSpec>,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
) -> Result<LocalExecutionRequestOutcome, GatewayError> {
|
||||
let Some(spec) = resolve_stream_spec(plan_kind) else {
|
||||
return Ok(None);
|
||||
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||
};
|
||||
|
||||
let plan_and_reports: Vec<LocalStreamPlanAndReport> =
|
||||
@@ -210,7 +208,7 @@ pub(crate) async fn maybe_execute_stream_via_standard_family_decision(
|
||||
)
|
||||
.await?;
|
||||
if plan_and_reports.is_empty() {
|
||||
return Ok(None);
|
||||
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||
}
|
||||
|
||||
execute_stream_plan_and_reports(state, trace_id, decision, plan_kind, plan_and_reports).await
|
||||
@@ -223,8 +221,10 @@ pub(crate) async fn maybe_execute_sync_via_local_standard_decision(
|
||||
decision: &GatewayControlDecision,
|
||||
body_json: &serde_json::Value,
|
||||
plan_kind: &str,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
if let Some(response) = maybe_execute_sync_via_standard_family_decision(
|
||||
) -> Result<LocalExecutionRequestOutcome, GatewayError> {
|
||||
let mut exhausted = None;
|
||||
|
||||
match maybe_execute_sync_via_standard_family_decision(
|
||||
state,
|
||||
parts,
|
||||
trace_id,
|
||||
@@ -235,10 +235,14 @@ pub(crate) async fn maybe_execute_sync_via_local_standard_decision(
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(Some(response));
|
||||
LocalExecutionRequestOutcome::Responded(response) => {
|
||||
return Ok(LocalExecutionRequestOutcome::Responded(response));
|
||||
}
|
||||
LocalExecutionRequestOutcome::Exhausted(outcome) => exhausted = Some(outcome),
|
||||
LocalExecutionRequestOutcome::NoPath => {}
|
||||
}
|
||||
|
||||
maybe_execute_sync_via_standard_family_decision(
|
||||
match maybe_execute_sync_via_standard_family_decision(
|
||||
state,
|
||||
parts,
|
||||
trace_id,
|
||||
@@ -247,7 +251,18 @@ pub(crate) async fn maybe_execute_sync_via_local_standard_decision(
|
||||
plan_kind,
|
||||
resolve_gemini_sync_spec,
|
||||
)
|
||||
.await
|
||||
.await?
|
||||
{
|
||||
LocalExecutionRequestOutcome::Responded(response) => {
|
||||
Ok(LocalExecutionRequestOutcome::Responded(response))
|
||||
}
|
||||
LocalExecutionRequestOutcome::Exhausted(outcome) => {
|
||||
Ok(LocalExecutionRequestOutcome::Exhausted(outcome))
|
||||
}
|
||||
LocalExecutionRequestOutcome::NoPath => Ok(exhausted
|
||||
.map(LocalExecutionRequestOutcome::Exhausted)
|
||||
.unwrap_or(LocalExecutionRequestOutcome::NoPath)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_execute_stream_via_local_standard_decision(
|
||||
@@ -257,8 +272,10 @@ pub(crate) async fn maybe_execute_stream_via_local_standard_decision(
|
||||
decision: &GatewayControlDecision,
|
||||
body_json: &serde_json::Value,
|
||||
plan_kind: &str,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
if let Some(response) = maybe_execute_stream_via_standard_family_decision(
|
||||
) -> Result<LocalExecutionRequestOutcome, GatewayError> {
|
||||
let mut exhausted = None;
|
||||
|
||||
match maybe_execute_stream_via_standard_family_decision(
|
||||
state,
|
||||
parts,
|
||||
trace_id,
|
||||
@@ -269,10 +286,14 @@ pub(crate) async fn maybe_execute_stream_via_local_standard_decision(
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(Some(response));
|
||||
LocalExecutionRequestOutcome::Responded(response) => {
|
||||
return Ok(LocalExecutionRequestOutcome::Responded(response));
|
||||
}
|
||||
LocalExecutionRequestOutcome::Exhausted(outcome) => exhausted = Some(outcome),
|
||||
LocalExecutionRequestOutcome::NoPath => {}
|
||||
}
|
||||
|
||||
maybe_execute_stream_via_standard_family_decision(
|
||||
match maybe_execute_stream_via_standard_family_decision(
|
||||
state,
|
||||
parts,
|
||||
trace_id,
|
||||
@@ -281,7 +302,18 @@ pub(crate) async fn maybe_execute_stream_via_local_standard_decision(
|
||||
plan_kind,
|
||||
resolve_gemini_stream_spec,
|
||||
)
|
||||
.await
|
||||
.await?
|
||||
{
|
||||
LocalExecutionRequestOutcome::Responded(response) => {
|
||||
Ok(LocalExecutionRequestOutcome::Responded(response))
|
||||
}
|
||||
LocalExecutionRequestOutcome::Exhausted(outcome) => {
|
||||
Ok(LocalExecutionRequestOutcome::Exhausted(outcome))
|
||||
}
|
||||
LocalExecutionRequestOutcome::NoPath => Ok(exhausted
|
||||
.map(LocalExecutionRequestOutcome::Exhausted)
|
||||
.unwrap_or(LocalExecutionRequestOutcome::NoPath)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_execute_sync_via_local_same_format_provider_decision(
|
||||
@@ -291,9 +323,9 @@ pub(crate) async fn maybe_execute_sync_via_local_same_format_provider_decision(
|
||||
decision: &GatewayControlDecision,
|
||||
body_json: &serde_json::Value,
|
||||
plan_kind: &str,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
) -> Result<LocalExecutionRequestOutcome, GatewayError> {
|
||||
let Some(spec) = resolve_local_same_format_sync_spec(plan_kind) else {
|
||||
return Ok(None);
|
||||
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||
};
|
||||
|
||||
let plan_and_reports: Vec<LocalSyncPlanAndReport> =
|
||||
@@ -302,7 +334,7 @@ pub(crate) async fn maybe_execute_sync_via_local_same_format_provider_decision(
|
||||
)
|
||||
.await?;
|
||||
if plan_and_reports.is_empty() {
|
||||
return Ok(None);
|
||||
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||
}
|
||||
|
||||
execute_sync_plan_and_reports(
|
||||
@@ -323,9 +355,9 @@ pub(crate) async fn maybe_execute_stream_via_local_same_format_provider_decision
|
||||
decision: &GatewayControlDecision,
|
||||
body_json: &serde_json::Value,
|
||||
plan_kind: &str,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
) -> Result<LocalExecutionRequestOutcome, GatewayError> {
|
||||
let Some(spec) = resolve_local_same_format_stream_spec(plan_kind) else {
|
||||
return Ok(None);
|
||||
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||
};
|
||||
|
||||
let plan_and_reports: Vec<LocalStreamPlanAndReport> =
|
||||
@@ -334,7 +366,7 @@ pub(crate) async fn maybe_execute_stream_via_local_same_format_provider_decision
|
||||
)
|
||||
.await?;
|
||||
if plan_and_reports.is_empty() {
|
||||
return Ok(None);
|
||||
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||
}
|
||||
|
||||
execute_stream_plan_and_reports(state, trace_id, decision, plan_kind, plan_and_reports).await
|
||||
@@ -349,7 +381,7 @@ pub(crate) async fn maybe_execute_sync_via_local_gemini_files_decision(
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
plan_kind: &str,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
) -> Result<LocalExecutionRequestOutcome, GatewayError> {
|
||||
let plan_and_reports: Vec<LocalSyncPlanAndReport> =
|
||||
build_local_gemini_files_sync_plan_and_reports_for_kind(
|
||||
state,
|
||||
@@ -363,7 +395,7 @@ pub(crate) async fn maybe_execute_sync_via_local_gemini_files_decision(
|
||||
)
|
||||
.await?;
|
||||
if plan_and_reports.is_empty() {
|
||||
return Ok(None);
|
||||
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||
}
|
||||
|
||||
execute_sync_plan_and_reports(
|
||||
@@ -383,14 +415,14 @@ pub(crate) async fn maybe_execute_stream_via_local_gemini_files_decision(
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
plan_kind: &str,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
) -> Result<LocalExecutionRequestOutcome, GatewayError> {
|
||||
let plan_and_reports: Vec<LocalStreamPlanAndReport> =
|
||||
build_local_gemini_files_stream_plan_and_reports_for_kind(
|
||||
state, parts, trace_id, decision, plan_kind,
|
||||
)
|
||||
.await?;
|
||||
if plan_and_reports.is_empty() {
|
||||
return Ok(None);
|
||||
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||
}
|
||||
|
||||
execute_stream_plan_and_reports(state, trace_id, decision, plan_kind, plan_and_reports).await
|
||||
@@ -403,14 +435,14 @@ pub(crate) async fn maybe_execute_sync_via_local_video_decision(
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
plan_kind: &str,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
) -> Result<LocalExecutionRequestOutcome, GatewayError> {
|
||||
let plan_and_reports: Vec<LocalSyncPlanAndReport> =
|
||||
build_local_video_sync_plan_and_reports_for_kind(
|
||||
state, parts, body_json, trace_id, decision, plan_kind,
|
||||
)
|
||||
.await?;
|
||||
if plan_and_reports.is_empty() {
|
||||
return Ok(None);
|
||||
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||
}
|
||||
|
||||
execute_sync_plan_and_reports(
|
||||
@@ -430,14 +462,14 @@ pub(crate) async fn maybe_execute_sync_request(
|
||||
body_bytes: &axum::body::Bytes,
|
||||
trace_id: &str,
|
||||
decision: Option<&GatewayControlDecision>,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
) -> Result<LocalExecutionRequestOutcome, GatewayError> {
|
||||
let Some(decision) = decision else {
|
||||
return Ok(None);
|
||||
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||
};
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
if parts.method != http::Method::POST {
|
||||
return Ok(None);
|
||||
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||
}
|
||||
return maybe_execute_sync_local_path(state, parts, body_bytes, trace_id, decision).await;
|
||||
}
|
||||
@@ -449,7 +481,7 @@ pub(crate) async fn maybe_execute_sync_request(
|
||||
.is_empty()
|
||||
&& parts.method != http::Method::POST
|
||||
{
|
||||
return Ok(None);
|
||||
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||
}
|
||||
maybe_execute_sync_local_path(state, parts, body_bytes, trace_id, decision).await
|
||||
}
|
||||
@@ -461,14 +493,14 @@ pub(crate) async fn maybe_execute_stream_request(
|
||||
body_bytes: &axum::body::Bytes,
|
||||
trace_id: &str,
|
||||
decision: Option<&GatewayControlDecision>,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
) -> Result<LocalExecutionRequestOutcome, GatewayError> {
|
||||
let Some(decision) = decision else {
|
||||
return Ok(None);
|
||||
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||
};
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
if parts.method != http::Method::POST {
|
||||
return Ok(None);
|
||||
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||
}
|
||||
return maybe_execute_stream_local_path(state, parts, body_bytes, trace_id, decision).await;
|
||||
}
|
||||
@@ -480,7 +512,7 @@ pub(crate) async fn maybe_execute_stream_request(
|
||||
.is_empty()
|
||||
&& parts.method != http::Method::POST
|
||||
{
|
||||
return Ok(None);
|
||||
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||
}
|
||||
maybe_execute_stream_local_path(state, parts, body_bytes, trace_id, decision).await
|
||||
}
|
||||
|
||||
232
apps/aether-gateway/src/executor/outcome.rs
Normal file
232
apps/aether-gateway/src/executor/outcome.rs
Normal file
@@ -0,0 +1,232 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use aether_contracts::ExecutionPlan;
|
||||
use aether_data_contracts::repository::candidates::{
|
||||
RequestCandidateStatus, StoredRequestCandidate,
|
||||
};
|
||||
use aether_usage_runtime::{
|
||||
build_usage_event_data_seed, UsageEvent, UsageEventData, UsageEventType,
|
||||
};
|
||||
use axum::body::Body;
|
||||
use axum::http::{self, Response};
|
||||
use serde_json::{json, Map, Value};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::constants::LOCAL_EXECUTION_RUNTIME_MISS_REASON_HEADER;
|
||||
use crate::state::LocalExecutionRuntimeMissDiagnostic;
|
||||
use crate::AppState;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum LocalExecutionRequestOutcome {
|
||||
Responded(Response<Body>),
|
||||
Exhausted(LocalExecutionExhaustion),
|
||||
NoPath,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct LocalExecutionExhaustion {
|
||||
request_id: String,
|
||||
data: UsageEventData,
|
||||
candidate_id: Option<String>,
|
||||
candidate_index: Option<u32>,
|
||||
upstream_status_code: Option<u16>,
|
||||
upstream_error_type: Option<String>,
|
||||
upstream_error_message: Option<String>,
|
||||
}
|
||||
|
||||
impl LocalExecutionRequestOutcome {
|
||||
pub(crate) fn responded(response: Response<Body>) -> Self {
|
||||
Self::Responded(response)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn build_local_execution_exhaustion(
|
||||
state: &AppState,
|
||||
plan: &ExecutionPlan,
|
||||
report_context: Option<&Value>,
|
||||
) -> LocalExecutionExhaustion {
|
||||
let mut data = build_usage_event_data_seed(plan, report_context);
|
||||
let last_failed_candidate = match state
|
||||
.read_request_candidates_by_request_id(plan.request_id.as_str())
|
||||
.await
|
||||
{
|
||||
Ok(candidates) => select_last_failed_request_candidate(&candidates).cloned(),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
request_id = %plan.request_id,
|
||||
error = ?err,
|
||||
"gateway failed to load request candidates for exhausted local execution"
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(candidate) = last_failed_candidate.as_ref() {
|
||||
data.user_id = data.user_id.or_else(|| candidate.user_id.clone());
|
||||
data.api_key_id = data.api_key_id.or_else(|| candidate.api_key_id.clone());
|
||||
data.username = data.username.or_else(|| candidate.username.clone());
|
||||
data.api_key_name = data.api_key_name.or_else(|| candidate.api_key_name.clone());
|
||||
data.provider_id = data.provider_id.or_else(|| candidate.provider_id.clone());
|
||||
data.provider_endpoint_id = data
|
||||
.provider_endpoint_id
|
||||
.or_else(|| candidate.endpoint_id.clone());
|
||||
data.provider_api_key_id = data
|
||||
.provider_api_key_id
|
||||
.or_else(|| candidate.key_id.clone());
|
||||
}
|
||||
|
||||
LocalExecutionExhaustion {
|
||||
request_id: plan.request_id.clone(),
|
||||
data,
|
||||
candidate_id: last_failed_candidate
|
||||
.as_ref()
|
||||
.map(|candidate| candidate.id.clone()),
|
||||
candidate_index: last_failed_candidate
|
||||
.as_ref()
|
||||
.map(|candidate| candidate.candidate_index),
|
||||
upstream_status_code: last_failed_candidate
|
||||
.as_ref()
|
||||
.and_then(|candidate| candidate.status_code),
|
||||
upstream_error_type: last_failed_candidate
|
||||
.as_ref()
|
||||
.and_then(|candidate| candidate.error_type.clone())
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty()),
|
||||
upstream_error_message: last_failed_candidate
|
||||
.as_ref()
|
||||
.and_then(|candidate| candidate.error_message.clone())
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn record_failed_usage_for_exhausted_request(
|
||||
state: &AppState,
|
||||
exhaustion: LocalExecutionExhaustion,
|
||||
started_at: &Instant,
|
||||
local_execution_runtime_miss_detail: &str,
|
||||
diagnostic: Option<&LocalExecutionRuntimeMissDiagnostic>,
|
||||
) {
|
||||
if !state.usage_runtime.is_enabled() {
|
||||
return;
|
||||
}
|
||||
|
||||
let LocalExecutionExhaustion {
|
||||
request_id,
|
||||
mut data,
|
||||
candidate_id,
|
||||
candidate_index,
|
||||
upstream_status_code,
|
||||
upstream_error_type,
|
||||
upstream_error_message,
|
||||
} = exhaustion;
|
||||
|
||||
let status_code = http::StatusCode::SERVICE_UNAVAILABLE.as_u16();
|
||||
let candidate_status_code = upstream_status_code.unwrap_or(status_code);
|
||||
data.status_code = Some(status_code);
|
||||
data.error_message = upstream_error_message
|
||||
.clone()
|
||||
.or_else(|| Some(local_execution_runtime_miss_detail.to_string()));
|
||||
data.error_category = error_category_for_failed_status(status_code);
|
||||
data.response_time_ms = Some(started_at.elapsed().as_millis() as u64);
|
||||
data.response_headers = Some(json_header_map());
|
||||
data.response_body = Some(json!({
|
||||
"error": {
|
||||
"type": upstream_error_type
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or("upstream_error"),
|
||||
"message": upstream_error_message
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or(local_execution_runtime_miss_detail),
|
||||
"code": candidate_status_code,
|
||||
}
|
||||
}));
|
||||
|
||||
let mut client_headers = Map::from_iter([(
|
||||
"content-type".to_string(),
|
||||
Value::String("application/json".to_string()),
|
||||
)]);
|
||||
if let Some(reason) = diagnostic
|
||||
.and_then(|value| Some(value.reason.trim()))
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
client_headers.insert(
|
||||
LOCAL_EXECUTION_RUNTIME_MISS_REASON_HEADER.to_string(),
|
||||
Value::String(reason.to_string()),
|
||||
);
|
||||
}
|
||||
data.client_response_headers = Some(Value::Object(client_headers));
|
||||
data.client_response_body = Some(json!({
|
||||
"error": {
|
||||
"type": "http_error",
|
||||
"message": local_execution_runtime_miss_detail,
|
||||
}
|
||||
}));
|
||||
|
||||
let mut request_metadata = match data.request_metadata.take() {
|
||||
Some(Value::Object(object)) => object,
|
||||
Some(other) => Map::from_iter([("seed".to_string(), other)]),
|
||||
None => Map::new(),
|
||||
};
|
||||
request_metadata.insert("trace_id".to_string(), Value::String(request_id.clone()));
|
||||
if let Some(candidate_id) = candidate_id {
|
||||
request_metadata.insert("candidate_id".to_string(), Value::String(candidate_id));
|
||||
}
|
||||
if let Some(candidate_index) = candidate_index {
|
||||
request_metadata.insert(
|
||||
"candidate_index".to_string(),
|
||||
Value::Number(candidate_index.into()),
|
||||
);
|
||||
}
|
||||
data.request_metadata = Some(Value::Object(request_metadata));
|
||||
|
||||
state
|
||||
.usage_runtime
|
||||
.record_terminal_event(
|
||||
state.data.as_ref(),
|
||||
UsageEvent::new(UsageEventType::Failed, request_id, data),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
fn select_last_failed_request_candidate(
|
||||
candidates: &[StoredRequestCandidate],
|
||||
) -> Option<&StoredRequestCandidate> {
|
||||
candidates
|
||||
.iter()
|
||||
.filter(|candidate| {
|
||||
matches!(
|
||||
candidate.status,
|
||||
RequestCandidateStatus::Failed | RequestCandidateStatus::Cancelled
|
||||
)
|
||||
})
|
||||
.max_by_key(|candidate| {
|
||||
(
|
||||
candidate.retry_index,
|
||||
candidate.candidate_index,
|
||||
candidate
|
||||
.finished_at_unix_ms
|
||||
.or(candidate.started_at_unix_ms)
|
||||
.unwrap_or(candidate.created_at_unix_ms),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn error_category_for_failed_status(status_code: u16) -> Option<String> {
|
||||
if status_code >= 500 {
|
||||
Some("server_error".to_string())
|
||||
} else if status_code >= 400 {
|
||||
Some("client_error".to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn json_header_map() -> Value {
|
||||
Value::Object(Map::from_iter([(
|
||||
"content-type".to_string(),
|
||||
Value::String("application/json".to_string()),
|
||||
)]))
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
use axum::body::Body;
|
||||
use axum::http::Response;
|
||||
|
||||
use crate::ai_pipeline_api::{maybe_build_stream_plan_payload, maybe_build_sync_plan_payload};
|
||||
use crate::ai_pipeline_api::{
|
||||
maybe_build_stream_plan_payload, maybe_build_sync_plan_payload, LocalStreamPlanAndReport,
|
||||
LocalSyncPlanAndReport,
|
||||
};
|
||||
use crate::control::GatewayControlDecision;
|
||||
use crate::execution_runtime::{execute_execution_runtime_stream, execute_execution_runtime_sync};
|
||||
use crate::executor::{
|
||||
execute_stream_plan_and_reports, execute_sync_plan_and_reports, LocalExecutionRequestOutcome,
|
||||
};
|
||||
use crate::{AppState, GatewayControlPlanResponse, GatewayError, GatewayFallbackReason};
|
||||
|
||||
pub(crate) async fn maybe_execute_sync_via_plan_fallback(
|
||||
@@ -16,7 +18,7 @@ pub(crate) async fn maybe_execute_sync_via_plan_fallback(
|
||||
_plan_kind: &str,
|
||||
_bypass_cache_key: String,
|
||||
_fallback_reason: GatewayFallbackReason,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
) -> Result<LocalExecutionRequestOutcome, GatewayError> {
|
||||
let body_is_empty =
|
||||
body_base64.is_none() && body_json.as_object().is_some_and(|value| value.is_empty());
|
||||
let Some(payload) = maybe_build_sync_plan_payload(
|
||||
@@ -30,7 +32,7 @@ pub(crate) async fn maybe_execute_sync_via_plan_fallback(
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||
};
|
||||
|
||||
let GatewayControlPlanResponse {
|
||||
@@ -43,18 +45,20 @@ pub(crate) async fn maybe_execute_sync_via_plan_fallback(
|
||||
} = payload;
|
||||
|
||||
let (Some(plan_kind), Some(plan)) = (plan_kind, plan) else {
|
||||
return Ok(None);
|
||||
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||
};
|
||||
|
||||
execute_execution_runtime_sync(
|
||||
execute_sync_plan_and_reports(
|
||||
state,
|
||||
parts.uri.path(),
|
||||
plan,
|
||||
parts,
|
||||
trace_id,
|
||||
decision,
|
||||
plan_kind.as_str(),
|
||||
vec![LocalSyncPlanAndReport {
|
||||
plan,
|
||||
report_kind,
|
||||
report_context,
|
||||
}],
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -69,11 +73,11 @@ pub(crate) async fn maybe_execute_stream_via_plan_fallback(
|
||||
_plan_kind: &str,
|
||||
_bypass_cache_key: String,
|
||||
_fallback_reason: GatewayFallbackReason,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
) -> Result<LocalExecutionRequestOutcome, GatewayError> {
|
||||
let Some(payload) =
|
||||
maybe_build_stream_plan_payload(state, parts, trace_id, decision, body_json).await?
|
||||
else {
|
||||
return Ok(None);
|
||||
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||
};
|
||||
|
||||
let GatewayControlPlanResponse {
|
||||
@@ -86,17 +90,19 @@ pub(crate) async fn maybe_execute_stream_via_plan_fallback(
|
||||
} = payload;
|
||||
|
||||
let (Some(plan_kind), Some(plan)) = (plan_kind, plan) else {
|
||||
return Ok(None);
|
||||
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||
};
|
||||
|
||||
execute_execution_runtime_stream(
|
||||
execute_stream_plan_and_reports(
|
||||
state,
|
||||
plan,
|
||||
trace_id,
|
||||
decision,
|
||||
plan_kind.as_str(),
|
||||
vec![LocalStreamPlanAndReport {
|
||||
plan,
|
||||
report_kind,
|
||||
report_context,
|
||||
}],
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -4,20 +4,21 @@ use std::collections::BTreeMap;
|
||||
|
||||
use crate::ai_pipeline_api::{
|
||||
is_matching_stream_request, resolve_execution_runtime_stream_plan_kind,
|
||||
supports_stream_scheduler_decision_kind, OPENAI_VIDEO_CONTENT_PLAN_KIND,
|
||||
supports_stream_scheduler_decision_kind, LocalStreamPlanAndReport,
|
||||
OPENAI_VIDEO_CONTENT_PLAN_KIND,
|
||||
};
|
||||
use crate::api::response::build_client_response_from_parts;
|
||||
use crate::control::GatewayControlDecision;
|
||||
use crate::execution_runtime::execute_execution_runtime_stream;
|
||||
use crate::{AppState, GatewayError, GatewayFallbackReason};
|
||||
|
||||
use super::{
|
||||
build_direct_plan_bypass_cache_key, maybe_execute_stream_via_local_decision,
|
||||
maybe_execute_stream_via_local_gemini_files_decision,
|
||||
build_direct_plan_bypass_cache_key, execute_stream_plan_and_reports,
|
||||
maybe_execute_stream_via_local_decision, maybe_execute_stream_via_local_gemini_files_decision,
|
||||
maybe_execute_stream_via_local_openai_cli_decision,
|
||||
maybe_execute_stream_via_local_same_format_provider_decision,
|
||||
maybe_execute_stream_via_local_standard_decision, maybe_execute_stream_via_plan_fallback,
|
||||
maybe_execute_stream_via_remote_decision, parse_local_request_body, should_skip_direct_plan,
|
||||
LocalExecutionRequestOutcome,
|
||||
};
|
||||
|
||||
pub(crate) async fn maybe_execute_via_stream_decision_path(
|
||||
@@ -26,71 +27,96 @@ pub(crate) async fn maybe_execute_via_stream_decision_path(
|
||||
body_bytes: &Bytes,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
) -> Result<LocalExecutionRequestOutcome, GatewayError> {
|
||||
let Some(plan_kind) = resolve_execution_runtime_stream_plan_kind(parts, decision) else {
|
||||
return Ok(None);
|
||||
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||
};
|
||||
|
||||
let Some((body_json, body_base64)) = parse_local_request_body(parts, body_bytes) else {
|
||||
return Ok(None);
|
||||
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||
};
|
||||
|
||||
if !is_matching_stream_request(plan_kind, parts, &body_json) {
|
||||
return Ok(None);
|
||||
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||
}
|
||||
|
||||
let bypass_cache_key =
|
||||
build_direct_plan_bypass_cache_key(plan_kind, parts, body_bytes, decision);
|
||||
if should_skip_direct_plan(state, &bypass_cache_key) {
|
||||
return Ok(None);
|
||||
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||
}
|
||||
|
||||
if let Some(response) =
|
||||
maybe_execute_local_video_task_content_stream(state, parts, trace_id, decision, plan_kind)
|
||||
let mut exhausted = None;
|
||||
|
||||
match maybe_execute_local_video_task_content_stream(state, parts, trace_id, decision, plan_kind)
|
||||
.await?
|
||||
{
|
||||
return Ok(Some(response));
|
||||
LocalExecutionRequestOutcome::Responded(response) => {
|
||||
return Ok(LocalExecutionRequestOutcome::Responded(response));
|
||||
}
|
||||
LocalExecutionRequestOutcome::Exhausted(outcome) => exhausted = Some(outcome),
|
||||
LocalExecutionRequestOutcome::NoPath => {}
|
||||
}
|
||||
|
||||
if supports_stream_scheduler_decision_kind(plan_kind) {
|
||||
if let Some(response) = maybe_execute_stream_via_local_decision(
|
||||
match maybe_execute_stream_via_local_decision(
|
||||
state, parts, trace_id, decision, &body_json, plan_kind,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(Some(response));
|
||||
LocalExecutionRequestOutcome::Responded(response) => {
|
||||
return Ok(LocalExecutionRequestOutcome::Responded(response));
|
||||
}
|
||||
LocalExecutionRequestOutcome::Exhausted(outcome) => exhausted = Some(outcome),
|
||||
LocalExecutionRequestOutcome::NoPath => {}
|
||||
}
|
||||
|
||||
if let Some(response) = maybe_execute_stream_via_local_openai_cli_decision(
|
||||
match maybe_execute_stream_via_local_openai_cli_decision(
|
||||
state, parts, trace_id, decision, &body_json, plan_kind,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(Some(response));
|
||||
LocalExecutionRequestOutcome::Responded(response) => {
|
||||
return Ok(LocalExecutionRequestOutcome::Responded(response));
|
||||
}
|
||||
LocalExecutionRequestOutcome::Exhausted(outcome) => exhausted = Some(outcome),
|
||||
LocalExecutionRequestOutcome::NoPath => {}
|
||||
}
|
||||
|
||||
if let Some(response) = maybe_execute_stream_via_local_standard_decision(
|
||||
match maybe_execute_stream_via_local_standard_decision(
|
||||
state, parts, trace_id, decision, &body_json, plan_kind,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(Some(response));
|
||||
LocalExecutionRequestOutcome::Responded(response) => {
|
||||
return Ok(LocalExecutionRequestOutcome::Responded(response));
|
||||
}
|
||||
LocalExecutionRequestOutcome::Exhausted(outcome) => exhausted = Some(outcome),
|
||||
LocalExecutionRequestOutcome::NoPath => {}
|
||||
}
|
||||
|
||||
if let Some(response) = maybe_execute_stream_via_local_same_format_provider_decision(
|
||||
match maybe_execute_stream_via_local_same_format_provider_decision(
|
||||
state, parts, trace_id, decision, &body_json, plan_kind,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(Some(response));
|
||||
LocalExecutionRequestOutcome::Responded(response) => {
|
||||
return Ok(LocalExecutionRequestOutcome::Responded(response));
|
||||
}
|
||||
LocalExecutionRequestOutcome::Exhausted(outcome) => exhausted = Some(outcome),
|
||||
LocalExecutionRequestOutcome::NoPath => {}
|
||||
}
|
||||
|
||||
if let Some(response) = maybe_execute_stream_via_local_gemini_files_decision(
|
||||
match maybe_execute_stream_via_local_gemini_files_decision(
|
||||
state, parts, trace_id, decision, plan_kind,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(Some(response));
|
||||
LocalExecutionRequestOutcome::Responded(response) => {
|
||||
return Ok(LocalExecutionRequestOutcome::Responded(response));
|
||||
}
|
||||
LocalExecutionRequestOutcome::Exhausted(outcome) => exhausted = Some(outcome),
|
||||
LocalExecutionRequestOutcome::NoPath => {}
|
||||
}
|
||||
|
||||
if let Some(response) = maybe_execute_stream_via_remote_decision(
|
||||
@@ -98,11 +124,11 @@ pub(crate) async fn maybe_execute_via_stream_decision_path(
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(Some(response));
|
||||
return Ok(LocalExecutionRequestOutcome::Responded(response));
|
||||
}
|
||||
}
|
||||
|
||||
maybe_execute_stream_via_plan_fallback(
|
||||
match maybe_execute_stream_via_plan_fallback(
|
||||
state,
|
||||
parts,
|
||||
trace_id,
|
||||
@@ -117,7 +143,18 @@ pub(crate) async fn maybe_execute_via_stream_decision_path(
|
||||
GatewayFallbackReason::SchedulerDecisionUnsupported
|
||||
},
|
||||
)
|
||||
.await
|
||||
.await?
|
||||
{
|
||||
LocalExecutionRequestOutcome::Responded(response) => {
|
||||
Ok(LocalExecutionRequestOutcome::Responded(response))
|
||||
}
|
||||
LocalExecutionRequestOutcome::Exhausted(outcome) => {
|
||||
Ok(LocalExecutionRequestOutcome::Exhausted(outcome))
|
||||
}
|
||||
LocalExecutionRequestOutcome::NoPath => Ok(exhausted
|
||||
.map(LocalExecutionRequestOutcome::Exhausted)
|
||||
.unwrap_or(LocalExecutionRequestOutcome::NoPath)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn maybe_execute_local_video_task_content_stream(
|
||||
@@ -126,11 +163,11 @@ async fn maybe_execute_local_video_task_content_stream(
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
plan_kind: &str,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
) -> Result<LocalExecutionRequestOutcome, GatewayError> {
|
||||
if plan_kind != OPENAI_VIDEO_CONTENT_PLAN_KIND
|
||||
|| decision.route_family.as_deref() != Some("openai")
|
||||
{
|
||||
return Ok(None);
|
||||
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||
}
|
||||
|
||||
let _ = state
|
||||
@@ -155,22 +192,28 @@ async fn maybe_execute_local_video_task_content_stream(
|
||||
parts.uri.query(),
|
||||
trace_id,
|
||||
) else {
|
||||
return Ok(None);
|
||||
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||
};
|
||||
|
||||
match action {
|
||||
crate::video_tasks::LocalVideoTaskContentAction::Immediate {
|
||||
status_code,
|
||||
body_json,
|
||||
} => Ok(Some(build_json_response(
|
||||
} => Ok(LocalExecutionRequestOutcome::Responded(
|
||||
build_json_response(trace_id, decision, status_code, &body_json)?,
|
||||
)),
|
||||
crate::video_tasks::LocalVideoTaskContentAction::StreamPlan(plan) => {
|
||||
let plan = *plan;
|
||||
execute_stream_plan_and_reports(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
status_code,
|
||||
&body_json,
|
||||
)?)),
|
||||
crate::video_tasks::LocalVideoTaskContentAction::StreamPlan(plan) => {
|
||||
execute_execution_runtime_stream(
|
||||
state, *plan, trace_id, decision, plan_kind, None, None,
|
||||
plan_kind,
|
||||
vec![LocalStreamPlanAndReport {
|
||||
plan,
|
||||
report_kind: None,
|
||||
report_context: None,
|
||||
}],
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -5,23 +5,22 @@ use std::collections::BTreeMap;
|
||||
use crate::ai_pipeline_api::{
|
||||
is_matching_stream_request, resolve_execution_runtime_stream_plan_kind,
|
||||
resolve_execution_runtime_sync_plan_kind, supports_sync_scheduler_decision_kind,
|
||||
GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND, OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND,
|
||||
LocalSyncPlanAndReport, GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND, OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND,
|
||||
OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND, OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND,
|
||||
};
|
||||
use crate::api::response::build_client_response_from_parts;
|
||||
use crate::control::resolve_execution_runtime_auth_context;
|
||||
use crate::control::GatewayControlDecision;
|
||||
use crate::execution_runtime::execute_execution_runtime_sync;
|
||||
use crate::{AppState, GatewayError, GatewayFallbackReason};
|
||||
|
||||
use super::{
|
||||
build_direct_plan_bypass_cache_key, maybe_execute_sync_via_local_decision,
|
||||
maybe_execute_sync_via_local_gemini_files_decision,
|
||||
build_direct_plan_bypass_cache_key, execute_sync_plan_and_reports,
|
||||
maybe_execute_sync_via_local_decision, maybe_execute_sync_via_local_gemini_files_decision,
|
||||
maybe_execute_sync_via_local_openai_cli_decision,
|
||||
maybe_execute_sync_via_local_same_format_provider_decision,
|
||||
maybe_execute_sync_via_local_standard_decision, maybe_execute_sync_via_local_video_decision,
|
||||
maybe_execute_sync_via_plan_fallback, maybe_execute_sync_via_remote_decision,
|
||||
parse_local_request_body, should_skip_direct_plan,
|
||||
parse_local_request_body, should_skip_direct_plan, LocalExecutionRequestOutcome,
|
||||
};
|
||||
|
||||
pub(crate) async fn maybe_execute_via_sync_decision_path(
|
||||
@@ -30,83 +29,109 @@ pub(crate) async fn maybe_execute_via_sync_decision_path(
|
||||
body_bytes: &Bytes,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
if let Some(response) =
|
||||
) -> Result<LocalExecutionRequestOutcome, GatewayError> {
|
||||
if let LocalExecutionRequestOutcome::Responded(response) =
|
||||
maybe_build_local_video_task_read_response(state, parts, trace_id, decision).await?
|
||||
{
|
||||
return Ok(Some(response));
|
||||
return Ok(LocalExecutionRequestOutcome::Responded(response));
|
||||
}
|
||||
|
||||
let Some(plan_kind) = resolve_execution_runtime_sync_plan_kind(parts, decision) else {
|
||||
return Ok(None);
|
||||
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||
};
|
||||
|
||||
let Some((body_json, body_base64)) = parse_local_request_body(parts, body_bytes) else {
|
||||
return Ok(None);
|
||||
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||
};
|
||||
|
||||
if let Some(stream_plan_kind) = resolve_execution_runtime_stream_plan_kind(parts, decision) {
|
||||
if is_matching_stream_request(stream_plan_kind, parts, &body_json) {
|
||||
return Ok(None);
|
||||
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||
}
|
||||
}
|
||||
|
||||
let bypass_cache_key =
|
||||
build_direct_plan_bypass_cache_key(plan_kind, parts, body_bytes, decision);
|
||||
if should_skip_direct_plan(state, &bypass_cache_key) {
|
||||
return Ok(None);
|
||||
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||
}
|
||||
|
||||
if let Some(response) = maybe_execute_local_video_task_follow_up_sync(
|
||||
let mut exhausted = None;
|
||||
|
||||
match maybe_execute_local_video_task_follow_up_sync(
|
||||
state, parts, &body_json, trace_id, decision, plan_kind,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(Some(response));
|
||||
LocalExecutionRequestOutcome::Responded(response) => {
|
||||
return Ok(LocalExecutionRequestOutcome::Responded(response));
|
||||
}
|
||||
LocalExecutionRequestOutcome::Exhausted(outcome) => exhausted = Some(outcome),
|
||||
LocalExecutionRequestOutcome::NoPath => {}
|
||||
}
|
||||
|
||||
if supports_sync_scheduler_decision_kind(plan_kind) {
|
||||
if let Some(response) = maybe_execute_sync_via_local_video_decision(
|
||||
match maybe_execute_sync_via_local_video_decision(
|
||||
state, parts, &body_json, trace_id, decision, plan_kind,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(Some(response));
|
||||
LocalExecutionRequestOutcome::Responded(response) => {
|
||||
return Ok(LocalExecutionRequestOutcome::Responded(response));
|
||||
}
|
||||
LocalExecutionRequestOutcome::Exhausted(outcome) => exhausted = Some(outcome),
|
||||
LocalExecutionRequestOutcome::NoPath => {}
|
||||
}
|
||||
|
||||
if let Some(response) = maybe_execute_sync_via_local_decision(
|
||||
match maybe_execute_sync_via_local_decision(
|
||||
state, parts, trace_id, decision, &body_json, plan_kind,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(Some(response));
|
||||
LocalExecutionRequestOutcome::Responded(response) => {
|
||||
return Ok(LocalExecutionRequestOutcome::Responded(response));
|
||||
}
|
||||
LocalExecutionRequestOutcome::Exhausted(outcome) => exhausted = Some(outcome),
|
||||
LocalExecutionRequestOutcome::NoPath => {}
|
||||
}
|
||||
|
||||
if let Some(response) = maybe_execute_sync_via_local_openai_cli_decision(
|
||||
match maybe_execute_sync_via_local_openai_cli_decision(
|
||||
state, parts, trace_id, decision, &body_json, plan_kind,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(Some(response));
|
||||
LocalExecutionRequestOutcome::Responded(response) => {
|
||||
return Ok(LocalExecutionRequestOutcome::Responded(response));
|
||||
}
|
||||
LocalExecutionRequestOutcome::Exhausted(outcome) => exhausted = Some(outcome),
|
||||
LocalExecutionRequestOutcome::NoPath => {}
|
||||
}
|
||||
|
||||
if let Some(response) = maybe_execute_sync_via_local_standard_decision(
|
||||
match maybe_execute_sync_via_local_standard_decision(
|
||||
state, parts, trace_id, decision, &body_json, plan_kind,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(Some(response));
|
||||
LocalExecutionRequestOutcome::Responded(response) => {
|
||||
return Ok(LocalExecutionRequestOutcome::Responded(response));
|
||||
}
|
||||
LocalExecutionRequestOutcome::Exhausted(outcome) => exhausted = Some(outcome),
|
||||
LocalExecutionRequestOutcome::NoPath => {}
|
||||
}
|
||||
|
||||
if let Some(response) = maybe_execute_sync_via_local_same_format_provider_decision(
|
||||
match maybe_execute_sync_via_local_same_format_provider_decision(
|
||||
state, parts, trace_id, decision, &body_json, plan_kind,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(Some(response));
|
||||
LocalExecutionRequestOutcome::Responded(response) => {
|
||||
return Ok(LocalExecutionRequestOutcome::Responded(response));
|
||||
}
|
||||
LocalExecutionRequestOutcome::Exhausted(outcome) => exhausted = Some(outcome),
|
||||
LocalExecutionRequestOutcome::NoPath => {}
|
||||
}
|
||||
|
||||
if let Some(response) = maybe_execute_sync_via_local_gemini_files_decision(
|
||||
match maybe_execute_sync_via_local_gemini_files_decision(
|
||||
state,
|
||||
parts,
|
||||
&body_json,
|
||||
@@ -118,7 +143,11 @@ pub(crate) async fn maybe_execute_via_sync_decision_path(
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(Some(response));
|
||||
LocalExecutionRequestOutcome::Responded(response) => {
|
||||
return Ok(LocalExecutionRequestOutcome::Responded(response));
|
||||
}
|
||||
LocalExecutionRequestOutcome::Exhausted(outcome) => exhausted = Some(outcome),
|
||||
LocalExecutionRequestOutcome::NoPath => {}
|
||||
}
|
||||
|
||||
if let Some(response) = maybe_execute_sync_via_remote_decision(
|
||||
@@ -126,11 +155,11 @@ pub(crate) async fn maybe_execute_via_sync_decision_path(
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(Some(response));
|
||||
return Ok(LocalExecutionRequestOutcome::Responded(response));
|
||||
}
|
||||
}
|
||||
|
||||
maybe_execute_sync_via_plan_fallback(
|
||||
match maybe_execute_sync_via_plan_fallback(
|
||||
state,
|
||||
parts,
|
||||
trace_id,
|
||||
@@ -145,7 +174,18 @@ pub(crate) async fn maybe_execute_via_sync_decision_path(
|
||||
GatewayFallbackReason::SchedulerDecisionUnsupported
|
||||
},
|
||||
)
|
||||
.await
|
||||
.await?
|
||||
{
|
||||
LocalExecutionRequestOutcome::Responded(response) => {
|
||||
Ok(LocalExecutionRequestOutcome::Responded(response))
|
||||
}
|
||||
LocalExecutionRequestOutcome::Exhausted(outcome) => {
|
||||
Ok(LocalExecutionRequestOutcome::Exhausted(outcome))
|
||||
}
|
||||
LocalExecutionRequestOutcome::NoPath => Ok(exhausted
|
||||
.map(LocalExecutionRequestOutcome::Exhausted)
|
||||
.unwrap_or(LocalExecutionRequestOutcome::NoPath)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn maybe_build_local_video_task_read_response(
|
||||
@@ -153,9 +193,9 @@ async fn maybe_build_local_video_task_read_response(
|
||||
parts: &http::request::Parts,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
) -> Result<LocalExecutionRequestOutcome, GatewayError> {
|
||||
if parts.method != http::Method::GET || decision.route_kind.as_deref() != Some("video") {
|
||||
return Ok(None);
|
||||
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||
}
|
||||
|
||||
let _ = state
|
||||
@@ -187,7 +227,7 @@ async fn maybe_build_local_video_task_read_response(
|
||||
}
|
||||
};
|
||||
let Some(read_response) = read_response else {
|
||||
return Ok(None);
|
||||
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||
};
|
||||
|
||||
let body_bytes = serde_json::to_vec(&read_response.body_json)
|
||||
@@ -196,13 +236,15 @@ async fn maybe_build_local_video_task_read_response(
|
||||
headers.insert("content-type".to_string(), "application/json".to_string());
|
||||
headers.insert("content-length".to_string(), body_bytes.len().to_string());
|
||||
|
||||
Ok(Some(build_client_response_from_parts(
|
||||
Ok(LocalExecutionRequestOutcome::Responded(
|
||||
build_client_response_from_parts(
|
||||
read_response.status_code,
|
||||
&headers,
|
||||
Body::from(body_bytes),
|
||||
trace_id,
|
||||
Some(decision),
|
||||
)?))
|
||||
)?,
|
||||
))
|
||||
}
|
||||
|
||||
async fn maybe_execute_local_video_task_follow_up_sync(
|
||||
@@ -212,7 +254,7 @@ async fn maybe_execute_local_video_task_follow_up_sync(
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
plan_kind: &str,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
) -> Result<LocalExecutionRequestOutcome, GatewayError> {
|
||||
if !matches!(
|
||||
plan_kind,
|
||||
OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND
|
||||
@@ -220,7 +262,7 @@ async fn maybe_execute_local_video_task_follow_up_sync(
|
||||
| OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND
|
||||
| GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND
|
||||
) {
|
||||
return Ok(None);
|
||||
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||
}
|
||||
|
||||
let _ = state
|
||||
@@ -242,18 +284,20 @@ async fn maybe_execute_local_video_task_follow_up_sync(
|
||||
auth_context.as_ref(),
|
||||
trace_id,
|
||||
) else {
|
||||
return Ok(None);
|
||||
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||
};
|
||||
|
||||
execute_execution_runtime_sync(
|
||||
execute_sync_plan_and_reports(
|
||||
state,
|
||||
parts.uri.path(),
|
||||
follow_up.plan,
|
||||
parts,
|
||||
trace_id,
|
||||
decision,
|
||||
plan_kind,
|
||||
follow_up.report_kind,
|
||||
follow_up.report_context,
|
||||
vec![LocalSyncPlanAndReport {
|
||||
plan: follow_up.plan,
|
||||
report_kind: follow_up.report_kind,
|
||||
report_context: follow_up.report_context,
|
||||
}],
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -29,7 +29,10 @@ use crate::control::{
|
||||
should_buffer_request_for_local_auth, trusted_auth_local_rejection, GatewayControlDecision,
|
||||
GatewayPublicRequestContext,
|
||||
};
|
||||
use crate::executor::{maybe_execute_stream_request, maybe_execute_sync_request};
|
||||
use crate::executor::{
|
||||
maybe_execute_stream_request, maybe_execute_sync_request,
|
||||
record_failed_usage_for_exhausted_request, LocalExecutionRequestOutcome,
|
||||
};
|
||||
use crate::handlers::shared::{
|
||||
build_admin_proxy_auth_required_response, build_unhandled_admin_proxy_response,
|
||||
local_proxy_route_requires_buffered_body, request_enables_control_execute,
|
||||
@@ -44,7 +47,6 @@ use crate::{
|
||||
use axum::body::{to_bytes, Body, Bytes};
|
||||
use axum::extract::{ConnectInfo, Request, State};
|
||||
use axum::http::{self, header::HeaderName, header::HeaderValue, Response};
|
||||
use chrono::Utc;
|
||||
use std::time::Instant;
|
||||
use tracing::{info, warn};
|
||||
|
||||
@@ -645,8 +647,9 @@ pub(crate) async fn proxy_request(
|
||||
.as_ref()
|
||||
.expect("execution runtime/control auth gate should have buffered request body");
|
||||
let stream_request = request_wants_stream(&request_context, buffered_body);
|
||||
let mut local_execution_exhaustion = None;
|
||||
if stream_request {
|
||||
if let Some(execution_runtime_response) = maybe_execute_stream_request(
|
||||
match maybe_execute_stream_request(
|
||||
&state,
|
||||
&parts,
|
||||
buffered_body,
|
||||
@@ -655,6 +658,7 @@ pub(crate) async fn proxy_request(
|
||||
)
|
||||
.await?
|
||||
{
|
||||
LocalExecutionRequestOutcome::Responded(execution_runtime_response) => {
|
||||
state.clear_local_execution_runtime_miss_diagnostic(&trace_id);
|
||||
return Ok(finalize_gateway_response_with_context(
|
||||
&state,
|
||||
@@ -666,11 +670,16 @@ pub(crate) async fn proxy_request(
|
||||
request_permit.take(),
|
||||
));
|
||||
}
|
||||
LocalExecutionRequestOutcome::Exhausted(outcome) => {
|
||||
local_execution_exhaustion = Some(outcome);
|
||||
}
|
||||
if let Some(execution_runtime_response) =
|
||||
maybe_execute_sync_request(&state, &parts, buffered_body, &trace_id, control_decision)
|
||||
LocalExecutionRequestOutcome::NoPath => {}
|
||||
}
|
||||
}
|
||||
match maybe_execute_sync_request(&state, &parts, buffered_body, &trace_id, control_decision)
|
||||
.await?
|
||||
{
|
||||
LocalExecutionRequestOutcome::Responded(execution_runtime_response) => {
|
||||
state.clear_local_execution_runtime_miss_diagnostic(&trace_id);
|
||||
return Ok(finalize_gateway_response_with_context(
|
||||
&state,
|
||||
@@ -682,8 +691,13 @@ pub(crate) async fn proxy_request(
|
||||
request_permit.take(),
|
||||
));
|
||||
}
|
||||
LocalExecutionRequestOutcome::Exhausted(outcome) => {
|
||||
local_execution_exhaustion = Some(outcome);
|
||||
}
|
||||
LocalExecutionRequestOutcome::NoPath => {}
|
||||
}
|
||||
if parts.method != http::Method::POST {
|
||||
if let Some(execution_runtime_response) = maybe_execute_stream_request(
|
||||
match maybe_execute_stream_request(
|
||||
&state,
|
||||
&parts,
|
||||
buffered_body,
|
||||
@@ -692,6 +706,7 @@ pub(crate) async fn proxy_request(
|
||||
)
|
||||
.await?
|
||||
{
|
||||
LocalExecutionRequestOutcome::Responded(execution_runtime_response) => {
|
||||
state.clear_local_execution_runtime_miss_diagnostic(&trace_id);
|
||||
return Ok(finalize_gateway_response_with_context(
|
||||
&state,
|
||||
@@ -703,9 +718,14 @@ pub(crate) async fn proxy_request(
|
||||
request_permit.take(),
|
||||
));
|
||||
}
|
||||
LocalExecutionRequestOutcome::Exhausted(outcome) => {
|
||||
local_execution_exhaustion = Some(outcome);
|
||||
}
|
||||
LocalExecutionRequestOutcome::NoPath => {}
|
||||
}
|
||||
}
|
||||
if allow_control_execute_fallback {
|
||||
if let Some(control_response) = maybe_execute_via_control(
|
||||
match maybe_execute_via_control(
|
||||
&state,
|
||||
&parts,
|
||||
buffered_body.clone(),
|
||||
@@ -715,6 +735,7 @@ pub(crate) async fn proxy_request(
|
||||
)
|
||||
.await?
|
||||
{
|
||||
LocalExecutionRequestOutcome::Responded(control_response) => {
|
||||
let reason = GatewayFallbackReason::ControlExecuteEmergency;
|
||||
let control_execution_path = if stream_request {
|
||||
EXECUTION_PATH_CONTROL_EXECUTE_STREAM
|
||||
@@ -751,6 +772,11 @@ pub(crate) async fn proxy_request(
|
||||
request_permit.take(),
|
||||
));
|
||||
}
|
||||
LocalExecutionRequestOutcome::Exhausted(outcome) => {
|
||||
local_execution_exhaustion = Some(outcome);
|
||||
}
|
||||
LocalExecutionRequestOutcome::NoPath => {}
|
||||
}
|
||||
}
|
||||
let local_execution_runtime_miss_detail =
|
||||
local_execution_runtime_miss_detail(control_decision)
|
||||
@@ -779,6 +805,16 @@ pub(crate) async fn proxy_request(
|
||||
"gateway local execution runtime miss"
|
||||
);
|
||||
}
|
||||
if let Some(exhaustion) = local_execution_exhaustion {
|
||||
record_failed_usage_for_exhausted_request(
|
||||
&state,
|
||||
exhaustion,
|
||||
&started_at,
|
||||
local_execution_runtime_miss_detail,
|
||||
local_execution_runtime_miss_diagnostic.as_ref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let mut response = build_local_http_error_response(
|
||||
&trace_id,
|
||||
control_decision,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use aether_billing::normalize_input_tokens_for_billing;
|
||||
use aether_data_contracts::repository::usage::{StoredRequestUsageAudit, UsageAuditListQuery};
|
||||
use axum::{
|
||||
body::Body,
|
||||
@@ -88,11 +89,33 @@ fn parse_users_me_usage_ids(query: Option<&str>) -> Option<BTreeSet<String>> {
|
||||
(!values.is_empty()).then_some(values)
|
||||
}
|
||||
|
||||
fn users_me_usage_cache_creation_tokens(item: &StoredRequestUsageAudit) -> u64 {
|
||||
let classified = item
|
||||
.cache_creation_ephemeral_5m_input_tokens
|
||||
.saturating_add(item.cache_creation_ephemeral_1h_input_tokens);
|
||||
if item.cache_creation_input_tokens == 0 && classified > 0 {
|
||||
classified
|
||||
} else {
|
||||
item.cache_creation_input_tokens
|
||||
}
|
||||
}
|
||||
|
||||
fn users_me_usage_total_input_context(item: &StoredRequestUsageAudit) -> u64 {
|
||||
item.input_tokens
|
||||
.saturating_add(users_me_usage_cache_creation_tokens(item))
|
||||
.saturating_add(item.cache_read_input_tokens)
|
||||
}
|
||||
|
||||
fn users_me_usage_effective_input_tokens(item: &StoredRequestUsageAudit) -> u64 {
|
||||
let api_format = item
|
||||
.endpoint_api_format
|
||||
.as_deref()
|
||||
.or(item.api_format.as_deref());
|
||||
let input_tokens = i64::try_from(item.input_tokens).unwrap_or(i64::MAX);
|
||||
let cache_read_tokens = i64::try_from(item.cache_read_input_tokens).unwrap_or(i64::MAX);
|
||||
normalize_input_tokens_for_billing(api_format, input_tokens, cache_read_tokens) as u64
|
||||
}
|
||||
|
||||
fn users_me_usage_effective_unix_secs(item: &StoredRequestUsageAudit) -> u64 {
|
||||
item.finalized_at_unix_secs
|
||||
.unwrap_or(item.created_at_unix_ms)
|
||||
@@ -152,6 +175,7 @@ fn build_users_me_usage_record_payload(
|
||||
"endpoint_api_format": item.endpoint_api_format,
|
||||
"has_format_conversion": item.has_format_conversion,
|
||||
"input_tokens": item.input_tokens,
|
||||
"effective_input_tokens": users_me_usage_effective_input_tokens(item),
|
||||
"output_tokens": item.output_tokens,
|
||||
"total_tokens": item.total_tokens,
|
||||
"cost": round_to(item.total_cost_usd, 6),
|
||||
@@ -161,6 +185,8 @@ fn build_users_me_usage_record_payload(
|
||||
"status": item.status,
|
||||
"created_at": unix_secs_to_rfc3339(item.created_at_unix_ms),
|
||||
"cache_creation_input_tokens": item.cache_creation_input_tokens,
|
||||
"cache_creation_ephemeral_5m_input_tokens": item.cache_creation_ephemeral_5m_input_tokens,
|
||||
"cache_creation_ephemeral_1h_input_tokens": item.cache_creation_ephemeral_1h_input_tokens,
|
||||
"cache_read_input_tokens": item.cache_read_input_tokens,
|
||||
"status_code": item.status_code,
|
||||
"error_message": item.error_message,
|
||||
@@ -186,8 +212,11 @@ fn build_users_me_usage_active_payload(item: &StoredRequestUsageAudit) -> serde_
|
||||
"id": item.id,
|
||||
"status": item.status,
|
||||
"input_tokens": item.input_tokens,
|
||||
"effective_input_tokens": users_me_usage_effective_input_tokens(item),
|
||||
"output_tokens": item.output_tokens,
|
||||
"cache_creation_input_tokens": item.cache_creation_input_tokens,
|
||||
"cache_creation_ephemeral_5m_input_tokens": item.cache_creation_ephemeral_5m_input_tokens,
|
||||
"cache_creation_ephemeral_1h_input_tokens": item.cache_creation_ephemeral_1h_input_tokens,
|
||||
"cache_read_input_tokens": item.cache_read_input_tokens,
|
||||
"cost": round_to(item.total_cost_usd, 6),
|
||||
"actual_cost": round_to(item.actual_total_cost_usd, 6),
|
||||
@@ -231,10 +260,13 @@ fn build_users_me_usage_summary_by_model(
|
||||
"model": item.model,
|
||||
"requests": 0_u64,
|
||||
"input_tokens": 0_u64,
|
||||
"effective_input_tokens": 0_u64,
|
||||
"output_tokens": 0_u64,
|
||||
"total_tokens": 0_u64,
|
||||
"cache_read_tokens": 0_u64,
|
||||
"cache_creation_tokens": 0_u64,
|
||||
"cache_creation_ephemeral_5m_tokens": 0_u64,
|
||||
"cache_creation_ephemeral_1h_tokens": 0_u64,
|
||||
"total_input_context": 0_u64,
|
||||
"cache_hit_rate": 0.0,
|
||||
"total_cost_usd": 0.0,
|
||||
@@ -245,6 +277,10 @@ fn build_users_me_usage_summary_by_model(
|
||||
.as_u64()
|
||||
.unwrap_or(0)
|
||||
.saturating_add(item.input_tokens));
|
||||
entry["effective_input_tokens"] = json!(entry["effective_input_tokens"]
|
||||
.as_u64()
|
||||
.unwrap_or(0)
|
||||
.saturating_add(users_me_usage_effective_input_tokens(item)));
|
||||
entry["output_tokens"] = json!(entry["output_tokens"]
|
||||
.as_u64()
|
||||
.unwrap_or(0)
|
||||
@@ -260,7 +296,17 @@ fn build_users_me_usage_summary_by_model(
|
||||
entry["cache_creation_tokens"] = json!(entry["cache_creation_tokens"]
|
||||
.as_u64()
|
||||
.unwrap_or(0)
|
||||
.saturating_add(item.cache_creation_input_tokens));
|
||||
.saturating_add(users_me_usage_cache_creation_tokens(item)));
|
||||
entry["cache_creation_ephemeral_5m_tokens"] = json!(entry
|
||||
["cache_creation_ephemeral_5m_tokens"]
|
||||
.as_u64()
|
||||
.unwrap_or(0)
|
||||
.saturating_add(item.cache_creation_ephemeral_5m_input_tokens));
|
||||
entry["cache_creation_ephemeral_1h_tokens"] = json!(entry
|
||||
["cache_creation_ephemeral_1h_tokens"]
|
||||
.as_u64()
|
||||
.unwrap_or(0)
|
||||
.saturating_add(item.cache_creation_ephemeral_1h_input_tokens));
|
||||
entry["total_input_context"] = json!(entry["total_input_context"]
|
||||
.as_u64()
|
||||
.unwrap_or(0)
|
||||
@@ -320,11 +366,14 @@ fn build_users_me_usage_summary_by_provider(
|
||||
json!({
|
||||
"provider": item.provider_name,
|
||||
"requests": 0_u64,
|
||||
"effective_input_tokens": 0_u64,
|
||||
"total_tokens": 0_u64,
|
||||
"total_input_context": 0_u64,
|
||||
"output_tokens": 0_u64,
|
||||
"cache_read_tokens": 0_u64,
|
||||
"cache_creation_tokens": 0_u64,
|
||||
"cache_creation_ephemeral_5m_tokens": 0_u64,
|
||||
"cache_creation_ephemeral_1h_tokens": 0_u64,
|
||||
"cache_hit_rate": 0.0,
|
||||
"total_cost_usd": 0.0,
|
||||
"success_rate": 0.0,
|
||||
@@ -343,6 +392,10 @@ fn build_users_me_usage_summary_by_provider(
|
||||
.as_u64()
|
||||
.unwrap_or(0)
|
||||
.saturating_add(users_me_usage_total_input_context(item)));
|
||||
entry["effective_input_tokens"] = json!(entry["effective_input_tokens"]
|
||||
.as_u64()
|
||||
.unwrap_or(0)
|
||||
.saturating_add(users_me_usage_effective_input_tokens(item)));
|
||||
entry["output_tokens"] = json!(entry["output_tokens"]
|
||||
.as_u64()
|
||||
.unwrap_or(0)
|
||||
@@ -354,7 +407,17 @@ fn build_users_me_usage_summary_by_provider(
|
||||
entry["cache_creation_tokens"] = json!(entry["cache_creation_tokens"]
|
||||
.as_u64()
|
||||
.unwrap_or(0)
|
||||
.saturating_add(item.cache_creation_input_tokens));
|
||||
.saturating_add(users_me_usage_cache_creation_tokens(item)));
|
||||
entry["cache_creation_ephemeral_5m_tokens"] = json!(entry
|
||||
["cache_creation_ephemeral_5m_tokens"]
|
||||
.as_u64()
|
||||
.unwrap_or(0)
|
||||
.saturating_add(item.cache_creation_ephemeral_5m_input_tokens));
|
||||
entry["cache_creation_ephemeral_1h_tokens"] = json!(entry
|
||||
["cache_creation_ephemeral_1h_tokens"]
|
||||
.as_u64()
|
||||
.unwrap_or(0)
|
||||
.saturating_add(item.cache_creation_ephemeral_1h_input_tokens));
|
||||
entry["total_cost_usd"] =
|
||||
json!(entry["total_cost_usd"].as_f64().unwrap_or(0.0) + item.total_cost_usd);
|
||||
|
||||
@@ -445,10 +508,13 @@ fn build_users_me_usage_summary_by_api_format(
|
||||
"api_format": api_format,
|
||||
"request_count": 0_u64,
|
||||
"total_tokens": 0_u64,
|
||||
"effective_input_tokens": 0_u64,
|
||||
"total_input_context": 0_u64,
|
||||
"output_tokens": 0_u64,
|
||||
"cache_read_tokens": 0_u64,
|
||||
"cache_creation_tokens": 0_u64,
|
||||
"cache_creation_ephemeral_5m_tokens": 0_u64,
|
||||
"cache_creation_ephemeral_1h_tokens": 0_u64,
|
||||
"cache_hit_rate": 0.0,
|
||||
"total_cost_usd": 0.0,
|
||||
"avg_response_time_ms": 0.0,
|
||||
@@ -468,6 +534,10 @@ fn build_users_me_usage_summary_by_api_format(
|
||||
.as_u64()
|
||||
.unwrap_or(0)
|
||||
.saturating_add(users_me_usage_total_input_context(item)));
|
||||
entry["effective_input_tokens"] = json!(entry["effective_input_tokens"]
|
||||
.as_u64()
|
||||
.unwrap_or(0)
|
||||
.saturating_add(users_me_usage_effective_input_tokens(item)));
|
||||
entry["output_tokens"] = json!(entry["output_tokens"]
|
||||
.as_u64()
|
||||
.unwrap_or(0)
|
||||
@@ -479,7 +549,17 @@ fn build_users_me_usage_summary_by_api_format(
|
||||
entry["cache_creation_tokens"] = json!(entry["cache_creation_tokens"]
|
||||
.as_u64()
|
||||
.unwrap_or(0)
|
||||
.saturating_add(item.cache_creation_input_tokens));
|
||||
.saturating_add(users_me_usage_cache_creation_tokens(item)));
|
||||
entry["cache_creation_ephemeral_5m_tokens"] = json!(entry
|
||||
["cache_creation_ephemeral_5m_tokens"]
|
||||
.as_u64()
|
||||
.unwrap_or(0)
|
||||
.saturating_add(item.cache_creation_ephemeral_5m_input_tokens));
|
||||
entry["cache_creation_ephemeral_1h_tokens"] = json!(entry
|
||||
["cache_creation_ephemeral_1h_tokens"]
|
||||
.as_u64()
|
||||
.unwrap_or(0)
|
||||
.saturating_add(item.cache_creation_ephemeral_1h_input_tokens));
|
||||
entry["total_cost_usd"] =
|
||||
json!(entry["total_cost_usd"].as_f64().unwrap_or(0.0) + item.total_cost_usd);
|
||||
if let Some(response_time_ms) = item.response_time_ms {
|
||||
|
||||
@@ -18,6 +18,8 @@ async fn gateway_executes_claude_chat_sync_via_local_decision_gate_with_local_sy
|
||||
url: String,
|
||||
model: String,
|
||||
auth_header_value: String,
|
||||
anthropic_version: String,
|
||||
anthropic_beta: String,
|
||||
endpoint_tag: String,
|
||||
metadata_mode: String,
|
||||
metadata_source: String,
|
||||
@@ -261,6 +263,18 @@ async fn gateway_executes_claude_chat_sync_via_local_decision_gate_with_local_sy
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
anthropic_version: payload
|
||||
.get("headers")
|
||||
.and_then(|value| value.get("anthropic-version"))
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
anthropic_beta: payload
|
||||
.get("headers")
|
||||
.and_then(|value| value.get("anthropic-beta"))
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
endpoint_tag: payload
|
||||
.get("headers")
|
||||
.and_then(|value| value.get("x-endpoint-tag"))
|
||||
@@ -357,6 +371,8 @@ async fn gateway_executes_claude_chat_sync_via_local_decision_gate_with_local_sy
|
||||
.post(format!("{gateway_url}/v1/messages"))
|
||||
.header(http::header::CONTENT_TYPE, "application/json")
|
||||
.header("x-api-key", "sk-client-claude-chat-local")
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.header("anthropic-beta", "prompt-caching-2024-07-31,context-1m-2025-08-07")
|
||||
.header(TRACE_ID_HEADER, "trace-claude-chat-local-123")
|
||||
.body(
|
||||
"{\"model\":\"claude-sonnet-4-5\",\"messages\":[],\"metadata\":{\"client\":\"desktop-claude\"}}",
|
||||
@@ -390,6 +406,14 @@ async fn gateway_executes_claude_chat_sync_via_local_decision_gate_with_local_sy
|
||||
seen_execution_runtime_request.auth_header_value,
|
||||
"sk-upstream-claude-chat"
|
||||
);
|
||||
assert_eq!(
|
||||
seen_execution_runtime_request.anthropic_version,
|
||||
"2023-06-01"
|
||||
);
|
||||
assert_eq!(
|
||||
seen_execution_runtime_request.anthropic_beta,
|
||||
"prompt-caching-2024-07-31,context-1m-2025-08-07"
|
||||
);
|
||||
assert_eq!(
|
||||
seen_execution_runtime_request.endpoint_tag,
|
||||
"claude-chat-local"
|
||||
|
||||
@@ -18,6 +18,8 @@ async fn gateway_executes_claude_cli_sync_via_local_decision_gate_with_local_syn
|
||||
url: String,
|
||||
model: String,
|
||||
authorization: String,
|
||||
anthropic_version: String,
|
||||
anthropic_beta: String,
|
||||
endpoint_tag: String,
|
||||
metadata_mode: String,
|
||||
metadata_source: String,
|
||||
@@ -261,6 +263,18 @@ async fn gateway_executes_claude_cli_sync_via_local_decision_gate_with_local_syn
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
anthropic_version: payload
|
||||
.get("headers")
|
||||
.and_then(|value| value.get("anthropic-version"))
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
anthropic_beta: payload
|
||||
.get("headers")
|
||||
.and_then(|value| value.get("anthropic-beta"))
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
endpoint_tag: payload
|
||||
.get("headers")
|
||||
.and_then(|value| value.get("x-endpoint-tag"))
|
||||
@@ -359,6 +373,8 @@ async fn gateway_executes_claude_cli_sync_via_local_decision_gate_with_local_syn
|
||||
http::header::AUTHORIZATION,
|
||||
"Bearer sk-client-claude-cli-local",
|
||||
)
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.header("anthropic-beta", "prompt-caching-2024-07-31")
|
||||
.header(TRACE_ID_HEADER, "trace-claude-cli-local-sync-123")
|
||||
.body(
|
||||
"{\"model\":\"claude-code\",\"messages\":[],\"metadata\":{\"client\":\"desktop-claude-cli\"}}",
|
||||
@@ -398,6 +414,14 @@ async fn gateway_executes_claude_cli_sync_via_local_decision_gate_with_local_syn
|
||||
seen_execution_runtime_request.authorization,
|
||||
"Bearer sk-upstream-claude-cli"
|
||||
);
|
||||
assert_eq!(
|
||||
seen_execution_runtime_request.anthropic_version,
|
||||
"2023-06-01"
|
||||
);
|
||||
assert_eq!(
|
||||
seen_execution_runtime_request.anthropic_beta,
|
||||
"prompt-caching-2024-07-31"
|
||||
);
|
||||
assert_eq!(
|
||||
seen_execution_runtime_request.endpoint_tag,
|
||||
"claude-cli-local"
|
||||
|
||||
@@ -151,7 +151,7 @@ fn sample_usage_row(
|
||||
actual_total_cost_usd: f64,
|
||||
created_at_unix_ms: i64,
|
||||
) -> StoredRequestUsageAudit {
|
||||
StoredRequestUsageAudit::new(
|
||||
let mut usage = StoredRequestUsageAudit::new(
|
||||
id.to_string(),
|
||||
request_id.to_string(),
|
||||
user_id.map(str::to_string),
|
||||
@@ -190,7 +190,10 @@ fn sample_usage_row(
|
||||
Some(created_at_unix_ms + 2),
|
||||
)
|
||||
.expect("usage row should build")
|
||||
.with_cache_input_tokens(15, 5)
|
||||
.with_cache_input_tokens(15, 5);
|
||||
usage.cache_creation_ephemeral_5m_input_tokens = 6;
|
||||
usage.cache_creation_ephemeral_1h_input_tokens = 9;
|
||||
usage
|
||||
}
|
||||
|
||||
fn sample_user_summary(id: &str, username: &str) -> StoredUserSummary {
|
||||
@@ -272,6 +275,14 @@ async fn gateway_handles_admin_usage_stats_locally_with_trusted_admin_principal(
|
||||
assert_eq!(payload["total_tokens"], 240);
|
||||
assert_eq!(payload["error_count"], 1);
|
||||
assert_eq!(payload["cache_stats"]["cache_creation_tokens"], 30);
|
||||
assert_eq!(
|
||||
payload["cache_stats"]["cache_creation_ephemeral_5m_tokens"],
|
||||
12
|
||||
);
|
||||
assert_eq!(
|
||||
payload["cache_stats"]["cache_creation_ephemeral_1h_tokens"],
|
||||
18
|
||||
);
|
||||
assert_eq!(payload["cache_stats"]["cache_read_tokens"], 10);
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
@@ -296,8 +307,7 @@ async fn gateway_handles_admin_usage_aggregation_stats_locally_with_trusted_admi
|
||||
let (upstream_url, upstream_hits, upstream_handle) =
|
||||
start_usage_upstream("/api/admin/usage/aggregation/stats").await;
|
||||
|
||||
let usage_repository = Arc::new(InMemoryUsageReadRepository::seed(vec![
|
||||
sample_usage_row(
|
||||
let mut usage_1 = sample_usage_row(
|
||||
"usage-1",
|
||||
"req-1",
|
||||
Some("user-1"),
|
||||
@@ -311,8 +321,11 @@ async fn gateway_handles_admin_usage_aggregation_stats_locally_with_trusted_admi
|
||||
0.3,
|
||||
0.36,
|
||||
DAY_1_UNIX_SECS,
|
||||
),
|
||||
sample_usage_row(
|
||||
);
|
||||
usage_1.provider_id = Some("provider-openai".to_string());
|
||||
usage_1.total_tokens = usage_1.input_tokens;
|
||||
|
||||
let mut usage_2 = sample_usage_row(
|
||||
"usage-2",
|
||||
"req-2",
|
||||
Some("user-2"),
|
||||
@@ -326,8 +339,11 @@ async fn gateway_handles_admin_usage_aggregation_stats_locally_with_trusted_admi
|
||||
0.1,
|
||||
0.12,
|
||||
DAY_2_UNIX_SECS,
|
||||
),
|
||||
sample_usage_row(
|
||||
);
|
||||
usage_2.provider_id = Some("provider-openai".to_string());
|
||||
usage_2.total_tokens = usage_2.input_tokens;
|
||||
|
||||
let mut usage_3 = sample_usage_row(
|
||||
"usage-3",
|
||||
"req-3",
|
||||
Some("user-2"),
|
||||
@@ -341,7 +357,16 @@ async fn gateway_handles_admin_usage_aggregation_stats_locally_with_trusted_admi
|
||||
0.2,
|
||||
0.24,
|
||||
DAY_2_UNIX_SECS,
|
||||
),
|
||||
);
|
||||
usage_3.provider_id = Some("provider-anthropic".to_string());
|
||||
usage_3.total_tokens = usage_3.input_tokens;
|
||||
usage_3.api_format = Some("claude:cli".to_string());
|
||||
usage_3.api_family = Some("claude".to_string());
|
||||
usage_3.endpoint_api_format = Some("claude:cli".to_string());
|
||||
usage_3.provider_api_family = Some("claude".to_string());
|
||||
|
||||
let usage_repository = Arc::new(InMemoryUsageReadRepository::seed(vec![
|
||||
usage_1, usage_2, usage_3,
|
||||
]));
|
||||
|
||||
let gateway = build_router_with_state(
|
||||
@@ -366,7 +391,53 @@ async fn gateway_handles_admin_usage_aggregation_stats_locally_with_trusted_admi
|
||||
assert_eq!(items.len(), 2);
|
||||
assert_eq!(items[0]["model"], "gpt-5");
|
||||
assert_eq!(items[0]["request_count"], 2);
|
||||
assert_eq!(items[0]["output_tokens"], 40);
|
||||
assert_eq!(items[0]["effective_input_tokens"], 150);
|
||||
assert_eq!(items[0]["total_input_context"], 200);
|
||||
assert_eq!(items[0]["cache_creation_tokens"], 30);
|
||||
assert_eq!(items[0]["cache_creation_ephemeral_5m_tokens"], 12);
|
||||
assert_eq!(items[0]["cache_creation_ephemeral_1h_tokens"], 18);
|
||||
assert_eq!(items[0]["cache_hit_rate"], 5.0);
|
||||
assert_eq!(items[1]["model"], "claude-3-7");
|
||||
assert_eq!(items[1]["output_tokens"], 20);
|
||||
|
||||
let provider_response = admin_request(reqwest::Client::new().get(format!(
|
||||
"{gateway_url}/api/admin/usage/aggregation/stats?group_by=provider&limit=10"
|
||||
)))
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(provider_response.status(), StatusCode::OK);
|
||||
let provider_payload: serde_json::Value = provider_response
|
||||
.json()
|
||||
.await
|
||||
.expect("json body should parse");
|
||||
let provider_items = provider_payload.as_array().expect("array response");
|
||||
assert_eq!(provider_items.len(), 2);
|
||||
assert_eq!(provider_items[0]["provider"], "OpenAI");
|
||||
assert_eq!(provider_items[0]["output_tokens"], 40);
|
||||
assert_eq!(provider_items[1]["provider"], "Anthropic");
|
||||
assert_eq!(provider_items[1]["output_tokens"], 20);
|
||||
|
||||
let api_format_response = admin_request(reqwest::Client::new().get(format!(
|
||||
"{gateway_url}/api/admin/usage/aggregation/stats?group_by=api_format&limit=10"
|
||||
)))
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(api_format_response.status(), StatusCode::OK);
|
||||
let api_format_payload: serde_json::Value = api_format_response
|
||||
.json()
|
||||
.await
|
||||
.expect("json body should parse");
|
||||
let api_format_items = api_format_payload.as_array().expect("array response");
|
||||
assert_eq!(api_format_items.len(), 2);
|
||||
assert_eq!(api_format_items[0]["api_format"], "openai:chat");
|
||||
assert_eq!(api_format_items[0]["output_tokens"], 40);
|
||||
assert_eq!(api_format_items[1]["api_format"], "claude:cli");
|
||||
assert_eq!(api_format_items[1]["output_tokens"], 20);
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
@@ -616,6 +687,7 @@ async fn gateway_handles_admin_usage_active_locally_with_trusted_admin_principal
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["requests"].as_array().expect("array").len(), 1);
|
||||
assert_eq!(payload["requests"][0]["id"], "usage-pending");
|
||||
assert_eq!(payload["requests"][0]["effective_input_tokens"], 5);
|
||||
assert_eq!(payload["requests"][0]["provider"], "OpenAI");
|
||||
assert_eq!(
|
||||
payload["requests"][0]["provider_key_name"],
|
||||
@@ -707,6 +779,7 @@ async fn gateway_handles_admin_usage_records_locally_with_trusted_admin_principa
|
||||
payload["records"][0]["provider_key_name"],
|
||||
"upstream-primary"
|
||||
);
|
||||
assert_eq!(payload["records"][0]["effective_input_tokens"], 35);
|
||||
assert_eq!(payload["records"][0]["first_byte_time_ms"], 120);
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
@@ -870,6 +943,7 @@ async fn gateway_handles_admin_usage_detail_locally_with_trusted_admin_principal
|
||||
assert_eq!(payload["api_key"]["name"], "primary");
|
||||
assert_eq!(payload["provider"], "OpenAI");
|
||||
assert_eq!(payload["model"], "gpt-5");
|
||||
assert_eq!(payload["effective_input_tokens"], 115);
|
||||
assert_eq!(payload["total_tokens"], 170);
|
||||
assert_eq!(payload["cache_creation_cost"], 0.0);
|
||||
assert_eq!(payload["cache_read_cost"], 0.0);
|
||||
|
||||
@@ -2387,6 +2387,8 @@ fn sample_user_usage_audit(
|
||||
)
|
||||
.expect("usage should build");
|
||||
usage.cache_creation_input_tokens = 10;
|
||||
usage.cache_creation_ephemeral_5m_input_tokens = 4;
|
||||
usage.cache_creation_ephemeral_1h_input_tokens = 6;
|
||||
usage.cache_read_input_tokens = 15;
|
||||
usage.cache_creation_cost_usd = 0.05;
|
||||
usage.cache_read_cost_usd = 0.02;
|
||||
@@ -4733,6 +4735,15 @@ async fn gateway_handles_users_me_usage_locally_without_proxying_upstream() {
|
||||
payload["records"].as_array().expect("records array").len(),
|
||||
3
|
||||
);
|
||||
assert_eq!(
|
||||
payload["records"][0]["cache_creation_ephemeral_5m_input_tokens"],
|
||||
4
|
||||
);
|
||||
assert_eq!(payload["records"][0]["effective_input_tokens"], 105);
|
||||
assert_eq!(
|
||||
payload["records"][0]["cache_creation_ephemeral_1h_input_tokens"],
|
||||
6
|
||||
);
|
||||
assert_eq!(
|
||||
payload["summary_by_model"]
|
||||
.as_array()
|
||||
@@ -4740,6 +4751,19 @@ async fn gateway_handles_users_me_usage_locally_without_proxying_upstream() {
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
payload["summary_by_model"][0]["cache_creation_ephemeral_5m_tokens"],
|
||||
4
|
||||
);
|
||||
assert_eq!(
|
||||
payload["summary_by_model"][0]["cache_creation_ephemeral_1h_tokens"],
|
||||
6
|
||||
);
|
||||
assert_eq!(
|
||||
payload["summary_by_model"][0]["effective_input_tokens"],
|
||||
105
|
||||
);
|
||||
assert_eq!(payload["summary_by_model"][0]["total_input_context"], 145);
|
||||
assert_eq!(payload["billing"]["id"], "wallet-auth-1");
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
@@ -4822,6 +4846,8 @@ async fn gateway_handles_users_me_usage_active_locally_without_proxying_upstream
|
||||
let requests = payload["requests"].as_array().expect("requests array");
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert_eq!(requests[0]["status"], "streaming");
|
||||
assert_eq!(requests[0]["cache_creation_ephemeral_5m_input_tokens"], 4);
|
||||
assert_eq!(requests[0]["cache_creation_ephemeral_1h_input_tokens"], 6);
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
|
||||
@@ -183,6 +183,212 @@ async fn gateway_handles_local_openai_chat_sync_report_with_local_reporting_when
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_records_failed_usage_when_all_local_openai_chat_candidates_exhaust_after_retryable_sync_failure(
|
||||
) {
|
||||
let usage_repository = Arc::new(InMemoryUsageReadRepository::default());
|
||||
let request_candidate_repository = Arc::new(InMemoryRequestCandidateRepository::default());
|
||||
let report_hits = Arc::new(Mutex::new(0usize));
|
||||
let report_hits_clone = Arc::clone(&report_hits);
|
||||
let decision_hits = Arc::new(Mutex::new(0usize));
|
||||
let decision_hits_clone = Arc::clone(&decision_hits);
|
||||
let plan_hits = Arc::new(Mutex::new(0usize));
|
||||
let plan_hits_clone = Arc::clone(&plan_hits);
|
||||
let public_hits = Arc::new(Mutex::new(0usize));
|
||||
let public_hits_clone = Arc::clone(&public_hits);
|
||||
|
||||
let upstream = Router::new()
|
||||
.route(
|
||||
"/api/internal/gateway/decision-sync",
|
||||
any(move |_request: Request| {
|
||||
let decision_hits_inner = Arc::clone(&decision_hits_clone);
|
||||
async move {
|
||||
*decision_hits_inner.lock().expect("mutex should lock") += 1;
|
||||
Json(json!({"action": "proxy_public"}))
|
||||
}
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/api/internal/gateway/plan-sync",
|
||||
any(move |_request: Request| {
|
||||
let plan_hits_inner = Arc::clone(&plan_hits_clone);
|
||||
async move {
|
||||
*plan_hits_inner.lock().expect("mutex should lock") += 1;
|
||||
Json(json!({"action": "proxy_public"}))
|
||||
}
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/api/internal/gateway/report-sync",
|
||||
any(move |_request: Request| {
|
||||
let report_hits_inner = Arc::clone(&report_hits_clone);
|
||||
async move {
|
||||
*report_hits_inner.lock().expect("mutex should lock") += 1;
|
||||
Json(json!({"ok": true}))
|
||||
}
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/v1/chat/completions",
|
||||
any(move |_request: Request| {
|
||||
let public_hits_inner = Arc::clone(&public_hits_clone);
|
||||
async move {
|
||||
*public_hits_inner.lock().expect("mutex should lock") += 1;
|
||||
(StatusCode::IM_A_TEAPOT, Body::from("public-route-hit"))
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let execution_runtime = Router::new().route(
|
||||
"/v1/execute/sync",
|
||||
any(|_request: Request| async move {
|
||||
Json(json!({
|
||||
"request_id": "trace-openai-chat-local-report-sync-failure-123",
|
||||
"status_code": 503,
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": {
|
||||
"json_body": {
|
||||
"error": {
|
||||
"message": "primary unavailable"
|
||||
}
|
||||
}
|
||||
},
|
||||
"telemetry": {
|
||||
"elapsed_ms": 25
|
||||
}
|
||||
}))
|
||||
}),
|
||||
);
|
||||
|
||||
let auth_repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![(
|
||||
Some(hash_api_key("sk-client-openai-local-report-sync-failure")),
|
||||
sample_local_openai_auth_snapshot(
|
||||
"api-key-openai-usage-local-failure-1",
|
||||
"user-openai-usage-local-failure-1",
|
||||
),
|
||||
)]));
|
||||
let candidate_selection_repository =
|
||||
Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(vec![
|
||||
sample_local_openai_candidate_row(),
|
||||
]));
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![sample_local_openai_provider()],
|
||||
vec![sample_local_openai_endpoint()],
|
||||
vec![sample_local_openai_key()],
|
||||
));
|
||||
|
||||
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
||||
let gateway_state =
|
||||
build_state_with_execution_runtime_override(execution_runtime_url)
|
||||
.with_data_state_for_tests(
|
||||
GatewayDataState::with_auth_candidate_selection_provider_catalog_request_candidates_and_usage_for_tests(
|
||||
auth_repository,
|
||||
candidate_selection_repository,
|
||||
provider_catalog_repository,
|
||||
Arc::clone(&request_candidate_repository),
|
||||
Arc::clone(&usage_repository),
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
),
|
||||
)
|
||||
.with_usage_runtime_for_tests(UsageRuntimeConfig {
|
||||
enabled: true,
|
||||
..UsageRuntimeConfig::default()
|
||||
});
|
||||
let gateway = build_router_with_state(gateway_state);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{gateway_url}/v1/chat/completions"))
|
||||
.header(http::header::CONTENT_TYPE, "application/json")
|
||||
.header(
|
||||
http::header::AUTHORIZATION,
|
||||
"Bearer sk-client-openai-local-report-sync-failure",
|
||||
)
|
||||
.header(
|
||||
TRACE_ID_HEADER,
|
||||
"trace-openai-chat-local-report-sync-failure-123",
|
||||
)
|
||||
.body("{\"model\":\"gpt-5\",\"messages\":[]}")
|
||||
.send()
|
||||
.await
|
||||
.expect("request should complete");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
let body_json: serde_json::Value = response.json().await.expect("body should parse");
|
||||
assert_eq!(body_json["error"]["type"], "http_error");
|
||||
|
||||
let mut stored_usage = None;
|
||||
for _ in 0..50 {
|
||||
stored_usage = usage_repository
|
||||
.find_by_request_id("trace-openai-chat-local-report-sync-failure-123")
|
||||
.await
|
||||
.expect("usage lookup should succeed");
|
||||
if stored_usage.is_some() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
let stored_usage = stored_usage.expect("failed usage should be recorded");
|
||||
assert_eq!(stored_usage.status, "failed");
|
||||
assert_eq!(stored_usage.billing_status, "void");
|
||||
assert_eq!(stored_usage.status_code, Some(503));
|
||||
assert_eq!(stored_usage.error_category.as_deref(), Some("server_error"));
|
||||
assert_eq!(
|
||||
stored_usage.user_id.as_deref(),
|
||||
Some("user-openai-usage-local-failure-1")
|
||||
);
|
||||
assert_eq!(stored_usage.provider_name, "openai");
|
||||
assert_eq!(stored_usage.model, "gpt-5");
|
||||
assert_eq!(stored_usage.api_format.as_deref(), Some("openai:chat"));
|
||||
assert_eq!(
|
||||
stored_usage
|
||||
.request_metadata
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("trace_id"))
|
||||
.and_then(|value| value.as_str()),
|
||||
Some("trace-openai-chat-local-report-sync-failure-123")
|
||||
);
|
||||
assert_eq!(
|
||||
stored_usage
|
||||
.response_body
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("error"))
|
||||
.and_then(|value| value.get("type"))
|
||||
.and_then(|value| value.as_str()),
|
||||
Some("upstream_error")
|
||||
);
|
||||
assert_eq!(
|
||||
stored_usage
|
||||
.client_response_body
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("error"))
|
||||
.and_then(|value| value.get("type"))
|
||||
.and_then(|value| value.as_str()),
|
||||
Some("http_error")
|
||||
);
|
||||
|
||||
let stored_candidates = request_candidate_repository
|
||||
.list_by_request_id("trace-openai-chat-local-report-sync-failure-123")
|
||||
.await
|
||||
.expect("request candidate trace should read");
|
||||
assert_eq!(stored_candidates.len(), 1);
|
||||
assert_eq!(stored_candidates[0].status, RequestCandidateStatus::Failed);
|
||||
assert_eq!(stored_candidates[0].status_code, Some(503));
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
assert_eq!(*report_hits.lock().expect("mutex should lock"), 0);
|
||||
assert_eq!(*decision_hits.lock().expect("mutex should lock"), 0);
|
||||
assert_eq!(*plan_hits.lock().expect("mutex should lock"), 0);
|
||||
assert_eq!(*public_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
execution_runtime_handle.abort();
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_local_openai_chat_stream_report_with_local_reporting_when_usage_runtime_enabled(
|
||||
) {
|
||||
|
||||
@@ -20,7 +20,7 @@ base64 = "0.22"
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
tracing = "0.1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_json.workspace = true
|
||||
thiserror = "2"
|
||||
bytes = "1"
|
||||
sha2 = "0.10"
|
||||
|
||||
@@ -7,6 +7,7 @@ repository.workspace = true
|
||||
description = "Shared admin contracts and pure helpers for Aether"
|
||||
|
||||
[dependencies]
|
||||
aether-billing.workspace = true
|
||||
aether-contracts.workspace = true
|
||||
aether-data.workspace = true
|
||||
aether-data-contracts.workspace = true
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::observability::stats::{aggregate_usage_stats, parse_bounded_u32, round_to};
|
||||
use aether_billing::normalize_input_tokens_for_billing;
|
||||
use aether_data::repository::users::StoredUserSummary;
|
||||
use aether_data_contracts::repository::{
|
||||
provider_catalog::{StoredProviderCatalogEndpoint, StoredProviderCatalogProvider},
|
||||
@@ -257,8 +258,11 @@ pub fn admin_usage_record_json(
|
||||
"model": item.model,
|
||||
"target_model": item.target_model,
|
||||
"input_tokens": item.input_tokens,
|
||||
"effective_input_tokens": admin_usage_effective_input_tokens(item),
|
||||
"output_tokens": item.output_tokens,
|
||||
"cache_creation_input_tokens": item.cache_creation_input_tokens,
|
||||
"cache_creation_ephemeral_5m_input_tokens": item.cache_creation_ephemeral_5m_input_tokens,
|
||||
"cache_creation_ephemeral_1h_input_tokens": item.cache_creation_ephemeral_1h_input_tokens,
|
||||
"cache_read_input_tokens": item.cache_read_input_tokens,
|
||||
"total_tokens": admin_usage_total_tokens(item),
|
||||
"cost": round_to(item.total_cost_usd, 6),
|
||||
@@ -290,12 +294,38 @@ pub fn admin_usage_record_json(
|
||||
pub fn admin_usage_total_tokens(item: &StoredRequestUsageAudit) -> u64 {
|
||||
item.input_tokens
|
||||
.saturating_add(item.output_tokens)
|
||||
.saturating_add(item.cache_creation_input_tokens)
|
||||
.saturating_add(admin_usage_cache_creation_tokens(item))
|
||||
.saturating_add(item.cache_read_input_tokens)
|
||||
}
|
||||
|
||||
pub fn admin_usage_token_cache_hit_rate(input_tokens: u64, cache_read_tokens: u64) -> f64 {
|
||||
let total_input_context = input_tokens.saturating_add(cache_read_tokens);
|
||||
pub fn admin_usage_cache_creation_tokens(item: &StoredRequestUsageAudit) -> u64 {
|
||||
let classified = item
|
||||
.cache_creation_ephemeral_5m_input_tokens
|
||||
.saturating_add(item.cache_creation_ephemeral_1h_input_tokens);
|
||||
if item.cache_creation_input_tokens == 0 && classified > 0 {
|
||||
classified
|
||||
} else {
|
||||
item.cache_creation_input_tokens
|
||||
}
|
||||
}
|
||||
|
||||
pub fn admin_usage_total_input_context(item: &StoredRequestUsageAudit) -> u64 {
|
||||
item.input_tokens
|
||||
.saturating_add(admin_usage_cache_creation_tokens(item))
|
||||
.saturating_add(item.cache_read_input_tokens)
|
||||
}
|
||||
|
||||
pub fn admin_usage_effective_input_tokens(item: &StoredRequestUsageAudit) -> u64 {
|
||||
let api_format = item
|
||||
.endpoint_api_format
|
||||
.as_deref()
|
||||
.or(item.api_format.as_deref());
|
||||
let input_tokens = i64::try_from(item.input_tokens).unwrap_or(i64::MAX);
|
||||
let cache_read_tokens = i64::try_from(item.cache_read_input_tokens).unwrap_or(i64::MAX);
|
||||
normalize_input_tokens_for_billing(api_format, input_tokens, cache_read_tokens) as u64
|
||||
}
|
||||
|
||||
pub fn admin_usage_token_cache_hit_rate(total_input_context: u64, cache_read_tokens: u64) -> f64 {
|
||||
if total_input_context == 0 {
|
||||
0.0
|
||||
} else {
|
||||
@@ -306,20 +336,46 @@ pub fn admin_usage_token_cache_hit_rate(input_tokens: u64, cache_read_tokens: u6
|
||||
}
|
||||
}
|
||||
|
||||
fn admin_usage_provider_display_name(item: &StoredRequestUsageAudit) -> Option<String> {
|
||||
let provider_name = item.provider_name.trim();
|
||||
if provider_name.is_empty() || matches!(provider_name, "unknown" | "pending") {
|
||||
None
|
||||
} else {
|
||||
Some(item.provider_name.clone())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn admin_usage_aggregation_by_model_json(
|
||||
usage: &[StoredRequestUsageAudit],
|
||||
limit: usize,
|
||||
) -> Value {
|
||||
let mut grouped: BTreeMap<String, (u64, u64, u64, u64, f64, f64)> = BTreeMap::new();
|
||||
#[allow(clippy::type_complexity)]
|
||||
let mut grouped: BTreeMap<String, (u64, u64, u64, u64, u64, u64, u64, u64, u64, f64, f64)> =
|
||||
BTreeMap::new();
|
||||
for item in usage {
|
||||
let key = item.model.clone();
|
||||
let entry = grouped.entry(key).or_insert((0, 0, 0, 0, 0.0, 0.0));
|
||||
let entry = grouped
|
||||
.entry(key)
|
||||
.or_insert((0, 0, 0, 0, 0, 0, 0, 0, 0, 0.0, 0.0));
|
||||
entry.0 = entry.0.saturating_add(1);
|
||||
entry.1 = entry.1.saturating_add(item.total_tokens);
|
||||
entry.2 = entry.2.saturating_add(item.input_tokens);
|
||||
entry.3 = entry.3.saturating_add(item.cache_read_input_tokens);
|
||||
entry.4 += item.total_cost_usd;
|
||||
entry.5 += item.actual_total_cost_usd;
|
||||
entry.3 = entry.3.saturating_add(item.output_tokens);
|
||||
entry.4 = entry
|
||||
.4
|
||||
.saturating_add(admin_usage_effective_input_tokens(item));
|
||||
entry.5 = entry
|
||||
.5
|
||||
.saturating_add(admin_usage_cache_creation_tokens(item));
|
||||
entry.6 = entry
|
||||
.6
|
||||
.saturating_add(item.cache_creation_ephemeral_5m_input_tokens);
|
||||
entry.7 = entry
|
||||
.7
|
||||
.saturating_add(item.cache_creation_ephemeral_1h_input_tokens);
|
||||
entry.8 = entry.8.saturating_add(item.cache_read_input_tokens);
|
||||
entry.9 += item.total_cost_usd;
|
||||
entry.10 += item.actual_total_cost_usd;
|
||||
}
|
||||
|
||||
let mut items: Vec<Value> = grouped
|
||||
@@ -331,6 +387,11 @@ pub fn admin_usage_aggregation_by_model_json(
|
||||
request_count,
|
||||
total_tokens,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
effective_input_tokens,
|
||||
cache_creation_tokens,
|
||||
cache_creation_ephemeral_5m_tokens,
|
||||
cache_creation_ephemeral_1h_tokens,
|
||||
cache_read_tokens,
|
||||
total_cost,
|
||||
actual_cost,
|
||||
@@ -340,13 +401,23 @@ pub fn admin_usage_aggregation_by_model_json(
|
||||
"model": model,
|
||||
"request_count": request_count,
|
||||
"total_tokens": total_tokens,
|
||||
"total_input_context": input_tokens.saturating_add(cache_read_tokens),
|
||||
"output_tokens": total_tokens.saturating_sub(input_tokens),
|
||||
"effective_input_tokens": effective_input_tokens,
|
||||
"total_input_context": input_tokens
|
||||
.saturating_add(cache_creation_tokens)
|
||||
.saturating_add(cache_read_tokens),
|
||||
"output_tokens": output_tokens,
|
||||
"total_cost": round_to(total_cost, 6),
|
||||
"actual_cost": round_to(actual_cost, 6),
|
||||
"cache_creation_tokens": cache_creation_tokens,
|
||||
"cache_creation_ephemeral_5m_tokens": cache_creation_ephemeral_5m_tokens,
|
||||
"cache_creation_ephemeral_1h_tokens": cache_creation_ephemeral_1h_tokens,
|
||||
"cache_read_tokens": cache_read_tokens,
|
||||
"cache_creation_tokens": 0,
|
||||
"cache_hit_rate": admin_usage_token_cache_hit_rate(input_tokens, cache_read_tokens),
|
||||
"cache_hit_rate": admin_usage_token_cache_hit_rate(
|
||||
input_tokens
|
||||
.saturating_add(cache_creation_tokens)
|
||||
.saturating_add(cache_read_tokens),
|
||||
cache_read_tokens,
|
||||
),
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -372,24 +443,75 @@ pub fn admin_usage_aggregation_by_provider_json(
|
||||
limit: usize,
|
||||
) -> Value {
|
||||
#[allow(clippy::type_complexity)]
|
||||
let mut grouped: BTreeMap<String, (u64, u64, u64, u64, f64, f64, u64, u64)> = BTreeMap::new();
|
||||
let mut grouped: BTreeMap<
|
||||
String,
|
||||
(
|
||||
String,
|
||||
u64,
|
||||
u64,
|
||||
u64,
|
||||
u64,
|
||||
u64,
|
||||
u64,
|
||||
u64,
|
||||
u64,
|
||||
u64,
|
||||
f64,
|
||||
f64,
|
||||
u64,
|
||||
u64,
|
||||
),
|
||||
> = BTreeMap::new();
|
||||
for item in usage {
|
||||
let key = item
|
||||
.provider_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let entry = grouped.entry(key).or_insert((0, 0, 0, 0, 0.0, 0.0, 0, 0));
|
||||
entry.0 = entry.0.saturating_add(1);
|
||||
entry.1 = entry.1.saturating_add(item.total_tokens);
|
||||
entry.2 = entry.2.saturating_add(item.input_tokens);
|
||||
entry.3 = entry.3.saturating_add(item.cache_read_input_tokens);
|
||||
entry.4 += item.total_cost_usd;
|
||||
entry.5 += item.actual_total_cost_usd;
|
||||
let provider_name =
|
||||
admin_usage_provider_display_name(item).unwrap_or_else(|| "Unknown".to_string());
|
||||
let entry = grouped.entry(key).or_insert((
|
||||
provider_name.clone(),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0.0,
|
||||
0.0,
|
||||
0,
|
||||
0,
|
||||
));
|
||||
if entry.0 == "Unknown" && provider_name != "Unknown" {
|
||||
entry.0 = provider_name;
|
||||
}
|
||||
entry.1 = entry.1.saturating_add(1);
|
||||
entry.2 = entry.2.saturating_add(item.total_tokens);
|
||||
entry.3 = entry.3.saturating_add(item.input_tokens);
|
||||
entry.4 = entry.4.saturating_add(item.output_tokens);
|
||||
entry.5 = entry
|
||||
.5
|
||||
.saturating_add(admin_usage_effective_input_tokens(item));
|
||||
entry.6 = entry
|
||||
.6
|
||||
.saturating_add(item.response_time_ms.unwrap_or_default());
|
||||
.saturating_add(admin_usage_cache_creation_tokens(item));
|
||||
entry.7 = entry
|
||||
.7
|
||||
.saturating_add(item.cache_creation_ephemeral_5m_input_tokens);
|
||||
entry.8 = entry
|
||||
.8
|
||||
.saturating_add(item.cache_creation_ephemeral_1h_input_tokens);
|
||||
entry.9 = entry.9.saturating_add(item.cache_read_input_tokens);
|
||||
entry.10 += item.total_cost_usd;
|
||||
entry.11 += item.actual_total_cost_usd;
|
||||
entry.12 = entry
|
||||
.12
|
||||
.saturating_add(item.response_time_ms.unwrap_or_default());
|
||||
entry.13 = entry
|
||||
.13
|
||||
.saturating_add(if admin_usage_is_success(item) { 1 } else { 0 });
|
||||
}
|
||||
|
||||
@@ -399,9 +521,15 @@ pub fn admin_usage_aggregation_by_provider_json(
|
||||
|(
|
||||
provider_id,
|
||||
(
|
||||
provider_name,
|
||||
request_count,
|
||||
total_tokens,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
effective_input_tokens,
|
||||
cache_creation_tokens,
|
||||
cache_creation_ephemeral_5m_tokens,
|
||||
cache_creation_ephemeral_1h_tokens,
|
||||
cache_read_tokens,
|
||||
total_cost,
|
||||
actual_cost,
|
||||
@@ -422,19 +550,29 @@ pub fn admin_usage_aggregation_by_provider_json(
|
||||
};
|
||||
json!({
|
||||
"provider_id": provider_id,
|
||||
"provider": Value::Null,
|
||||
"provider": provider_name,
|
||||
"request_count": request_count,
|
||||
"total_tokens": total_tokens,
|
||||
"total_input_context": input_tokens.saturating_add(cache_read_tokens),
|
||||
"output_tokens": total_tokens.saturating_sub(input_tokens),
|
||||
"effective_input_tokens": effective_input_tokens,
|
||||
"total_input_context": input_tokens
|
||||
.saturating_add(cache_creation_tokens)
|
||||
.saturating_add(cache_read_tokens),
|
||||
"output_tokens": output_tokens,
|
||||
"total_cost": round_to(total_cost, 6),
|
||||
"actual_cost": round_to(actual_cost, 6),
|
||||
"avg_response_time_ms": avg_response_time_ms,
|
||||
"success_rate": success_rate,
|
||||
"error_count": error_count,
|
||||
"cache_creation_tokens": cache_creation_tokens,
|
||||
"cache_creation_ephemeral_5m_tokens": cache_creation_ephemeral_5m_tokens,
|
||||
"cache_creation_ephemeral_1h_tokens": cache_creation_ephemeral_1h_tokens,
|
||||
"cache_read_tokens": cache_read_tokens,
|
||||
"cache_creation_tokens": 0,
|
||||
"cache_hit_rate": admin_usage_token_cache_hit_rate(input_tokens, cache_read_tokens),
|
||||
"cache_hit_rate": admin_usage_token_cache_hit_rate(
|
||||
input_tokens
|
||||
.saturating_add(cache_creation_tokens)
|
||||
.saturating_add(cache_read_tokens),
|
||||
cache_read_tokens,
|
||||
),
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -460,21 +598,39 @@ pub fn admin_usage_aggregation_by_api_format_json(
|
||||
limit: usize,
|
||||
) -> Value {
|
||||
#[allow(clippy::type_complexity)]
|
||||
let mut grouped: BTreeMap<String, (u64, u64, u64, u64, f64, f64, u64)> = BTreeMap::new();
|
||||
let mut grouped: BTreeMap<
|
||||
String,
|
||||
(u64, u64, u64, u64, u64, u64, u64, u64, u64, f64, f64, u64),
|
||||
> = BTreeMap::new();
|
||||
for item in usage {
|
||||
let key = item
|
||||
.api_format
|
||||
.clone()
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let entry = grouped.entry(key).or_insert((0, 0, 0, 0, 0.0, 0.0, 0));
|
||||
let entry = grouped
|
||||
.entry(key)
|
||||
.or_insert((0, 0, 0, 0, 0, 0, 0, 0, 0, 0.0, 0.0, 0));
|
||||
entry.0 = entry.0.saturating_add(1);
|
||||
entry.1 = entry.1.saturating_add(item.total_tokens);
|
||||
entry.2 = entry.2.saturating_add(item.input_tokens);
|
||||
entry.3 = entry.3.saturating_add(item.cache_read_input_tokens);
|
||||
entry.4 += item.total_cost_usd;
|
||||
entry.5 += item.actual_total_cost_usd;
|
||||
entry.3 = entry.3.saturating_add(item.output_tokens);
|
||||
entry.4 = entry
|
||||
.4
|
||||
.saturating_add(admin_usage_effective_input_tokens(item));
|
||||
entry.5 = entry
|
||||
.5
|
||||
.saturating_add(admin_usage_cache_creation_tokens(item));
|
||||
entry.6 = entry
|
||||
.6
|
||||
.saturating_add(item.cache_creation_ephemeral_5m_input_tokens);
|
||||
entry.7 = entry
|
||||
.7
|
||||
.saturating_add(item.cache_creation_ephemeral_1h_input_tokens);
|
||||
entry.8 = entry.8.saturating_add(item.cache_read_input_tokens);
|
||||
entry.9 += item.total_cost_usd;
|
||||
entry.10 += item.actual_total_cost_usd;
|
||||
entry.11 = entry
|
||||
.11
|
||||
.saturating_add(item.response_time_ms.unwrap_or_default());
|
||||
}
|
||||
|
||||
@@ -487,6 +643,11 @@ pub fn admin_usage_aggregation_by_api_format_json(
|
||||
request_count,
|
||||
total_tokens,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
effective_input_tokens,
|
||||
cache_creation_tokens,
|
||||
cache_creation_ephemeral_5m_tokens,
|
||||
cache_creation_ephemeral_1h_tokens,
|
||||
cache_read_tokens,
|
||||
total_cost,
|
||||
actual_cost,
|
||||
@@ -502,14 +663,24 @@ pub fn admin_usage_aggregation_by_api_format_json(
|
||||
"api_format": api_format,
|
||||
"request_count": request_count,
|
||||
"total_tokens": total_tokens,
|
||||
"total_input_context": input_tokens.saturating_add(cache_read_tokens),
|
||||
"output_tokens": total_tokens.saturating_sub(input_tokens),
|
||||
"effective_input_tokens": effective_input_tokens,
|
||||
"total_input_context": input_tokens
|
||||
.saturating_add(cache_creation_tokens)
|
||||
.saturating_add(cache_read_tokens),
|
||||
"output_tokens": output_tokens,
|
||||
"total_cost": round_to(total_cost, 6),
|
||||
"actual_cost": round_to(actual_cost, 6),
|
||||
"avg_response_time_ms": avg_response_time_ms,
|
||||
"cache_creation_tokens": cache_creation_tokens,
|
||||
"cache_creation_ephemeral_5m_tokens": cache_creation_ephemeral_5m_tokens,
|
||||
"cache_creation_ephemeral_1h_tokens": cache_creation_ephemeral_1h_tokens,
|
||||
"cache_read_tokens": cache_read_tokens,
|
||||
"cache_creation_tokens": 0,
|
||||
"cache_hit_rate": admin_usage_token_cache_hit_rate(input_tokens, cache_read_tokens),
|
||||
"cache_hit_rate": admin_usage_token_cache_hit_rate(
|
||||
input_tokens
|
||||
.saturating_add(cache_creation_tokens)
|
||||
.saturating_add(cache_read_tokens),
|
||||
cache_read_tokens,
|
||||
),
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -868,9 +1039,14 @@ pub fn build_admin_usage_summary_stats_response(
|
||||
usage: &[StoredRequestUsageAudit],
|
||||
) -> Response<Body> {
|
||||
let aggregate = aggregate_usage_stats(usage);
|
||||
let cache_creation_tokens: u64 = usage
|
||||
let cache_creation_tokens: u64 = usage.iter().map(admin_usage_cache_creation_tokens).sum();
|
||||
let cache_creation_ephemeral_5m_tokens: u64 = usage
|
||||
.iter()
|
||||
.map(|item| item.cache_creation_input_tokens)
|
||||
.map(|item| item.cache_creation_ephemeral_5m_input_tokens)
|
||||
.sum();
|
||||
let cache_creation_ephemeral_1h_tokens: u64 = usage
|
||||
.iter()
|
||||
.map(|item| item.cache_creation_ephemeral_1h_input_tokens)
|
||||
.sum();
|
||||
let cache_read_tokens: u64 = usage.iter().map(|item| item.cache_read_input_tokens).sum();
|
||||
let cache_creation_cost: f64 = usage.iter().map(|item| item.cache_creation_cost_usd).sum();
|
||||
@@ -896,6 +1072,8 @@ pub fn build_admin_usage_summary_stats_response(
|
||||
"error_rate": error_rate,
|
||||
"cache_stats": {
|
||||
"cache_creation_tokens": cache_creation_tokens,
|
||||
"cache_creation_ephemeral_5m_tokens": cache_creation_ephemeral_5m_tokens,
|
||||
"cache_creation_ephemeral_1h_tokens": cache_creation_ephemeral_1h_tokens,
|
||||
"cache_read_tokens": cache_read_tokens,
|
||||
"cache_creation_cost": round_to(cache_creation_cost, 6),
|
||||
"cache_read_cost": round_to(cache_read_cost, 6),
|
||||
@@ -916,8 +1094,11 @@ pub fn build_admin_usage_active_requests_response(
|
||||
"id": item.id,
|
||||
"status": item.status,
|
||||
"input_tokens": item.input_tokens,
|
||||
"effective_input_tokens": admin_usage_effective_input_tokens(item),
|
||||
"output_tokens": item.output_tokens,
|
||||
"cache_creation_input_tokens": item.cache_creation_input_tokens,
|
||||
"cache_creation_ephemeral_5m_input_tokens": item.cache_creation_ephemeral_5m_input_tokens,
|
||||
"cache_creation_ephemeral_1h_input_tokens": item.cache_creation_ephemeral_1h_input_tokens,
|
||||
"cache_read_input_tokens": item.cache_read_input_tokens,
|
||||
"cost": round_to(item.total_cost_usd, 6),
|
||||
"actual_cost": round_to(item.actual_total_cost_usd, 6),
|
||||
|
||||
@@ -184,6 +184,7 @@ pub fn apply_codex_openai_cli_special_headers(
|
||||
if let Some(account_id) = extract_codex_account_id(decrypted_auth_config_raw) {
|
||||
provider_request_headers.insert("chatgpt-account-id".to_string(), account_id);
|
||||
}
|
||||
|
||||
if !provider_request_headers
|
||||
.get("x-client-request-id")
|
||||
.map(|value| !value.trim().is_empty())
|
||||
@@ -200,6 +201,7 @@ pub fn apply_codex_openai_cli_special_headers(
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
|
||||
let Some(short_id) = prompt_cache_key.and_then(build_short_codex_header_id) else {
|
||||
return;
|
||||
};
|
||||
@@ -213,6 +215,7 @@ pub fn apply_codex_openai_cli_special_headers(
|
||||
.eq_ignore_ascii_case("openai:compact")
|
||||
&& !header_map_has_non_empty_value(original_headers, "conversation_id")
|
||||
{
|
||||
provider_request_headers.insert("conversation_id".to_string(), short_id);
|
||||
let session_id = provider_request_headers.get("session_id").unwrap();
|
||||
provider_request_headers.insert("conversation_id".to_string(), session_id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,8 +124,18 @@ pub fn build_cross_format_openai_cli_request_body(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::build_local_openai_cli_request_body;
|
||||
use super::{build_cross_format_openai_cli_request_body, build_local_openai_chat_request_body};
|
||||
use serde_json::json;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
fn object_keys(value: &Value) -> Vec<&str> {
|
||||
value
|
||||
.as_object()
|
||||
.expect("json object")
|
||||
.keys()
|
||||
.map(String::as_str)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_openai_chat_cross_format_request_body_from_openai_cli_source() {
|
||||
@@ -148,6 +158,29 @@ mod tests {
|
||||
assert_eq!(provider_request_body["messages"][0]["content"], "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_openai_cli_request_body_preserves_original_field_order() {
|
||||
let body_json: Value = serde_json::from_str(
|
||||
r#"{
|
||||
"model": "gpt-5",
|
||||
"include": ["reasoning.encrypted_content"],
|
||||
"input": [],
|
||||
"instructions": "Keep order"
|
||||
}"#,
|
||||
)
|
||||
.expect("request json should parse");
|
||||
|
||||
let provider_request_body =
|
||||
build_local_openai_cli_request_body(&body_json, "gpt-5-upstream", false)
|
||||
.expect("openai cli body should build");
|
||||
|
||||
assert_eq!(
|
||||
object_keys(&provider_request_body),
|
||||
vec!["model", "include", "input", "instructions"]
|
||||
);
|
||||
assert_eq!(provider_request_body["model"], "gpt-5-upstream");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_streaming_local_openai_chat_request_body_with_include_usage() {
|
||||
let body_json = json!({
|
||||
|
||||
@@ -50,6 +50,14 @@ impl DefaultBillingRuleGenerator {
|
||||
"cache_creation_price_per_1m".to_string(),
|
||||
json!(base_cache_creation_price),
|
||||
);
|
||||
variables.insert(
|
||||
"cache_creation_ephemeral_5m_price_per_1m".to_string(),
|
||||
json!(base_cache_creation_price),
|
||||
);
|
||||
variables.insert(
|
||||
"cache_creation_ephemeral_1h_price_per_1m".to_string(),
|
||||
json!(base_cache_creation_price),
|
||||
);
|
||||
variables.insert(
|
||||
"cache_read_price_per_1m".to_string(),
|
||||
json!(base_cache_read_price),
|
||||
@@ -61,6 +69,21 @@ impl DefaultBillingRuleGenerator {
|
||||
("input_tokens", "input_tokens", json!(0)),
|
||||
("output_tokens", "output_tokens", json!(0)),
|
||||
("cache_creation_tokens", "cache_creation_tokens", json!(0)),
|
||||
(
|
||||
"cache_creation_ephemeral_5m_tokens",
|
||||
"cache_creation_ephemeral_5m_tokens",
|
||||
json!(0),
|
||||
),
|
||||
(
|
||||
"cache_creation_ephemeral_1h_tokens",
|
||||
"cache_creation_ephemeral_1h_tokens",
|
||||
json!(0),
|
||||
),
|
||||
(
|
||||
"cache_creation_uncategorized_tokens",
|
||||
"cache_creation_uncategorized_tokens",
|
||||
json!(0),
|
||||
),
|
||||
("cache_read_tokens", "cache_read_tokens", json!(0)),
|
||||
("request_count", "request_count", json!(1)),
|
||||
] {
|
||||
@@ -83,8 +106,16 @@ impl DefaultBillingRuleGenerator {
|
||||
"output_tokens * output_price_per_1m / 1000000",
|
||||
),
|
||||
(
|
||||
"cache_creation_cost",
|
||||
"cache_creation_tokens * cache_creation_price_per_1m / 1000000",
|
||||
"cache_creation_uncategorized_cost",
|
||||
"cache_creation_uncategorized_tokens * cache_creation_price_per_1m / 1000000",
|
||||
),
|
||||
(
|
||||
"cache_creation_ephemeral_5m_cost",
|
||||
"cache_creation_ephemeral_5m_tokens * cache_creation_ephemeral_5m_price_per_1m / 1000000",
|
||||
),
|
||||
(
|
||||
"cache_creation_ephemeral_1h_cost",
|
||||
"cache_creation_ephemeral_1h_tokens * cache_creation_ephemeral_1h_price_per_1m / 1000000",
|
||||
),
|
||||
(
|
||||
"cache_read_cost",
|
||||
@@ -136,6 +167,30 @@ impl DefaultBillingRuleGenerator {
|
||||
"default": base_cache_creation_price,
|
||||
}),
|
||||
);
|
||||
dimension_mappings.insert(
|
||||
"cache_creation_ephemeral_5m_price_per_1m".to_string(),
|
||||
json!({
|
||||
"source": "tiered",
|
||||
"tier_key": "total_input_context",
|
||||
"allow_zero": true,
|
||||
"ttl_key": "cache_creation_ephemeral_5m_ttl_minutes",
|
||||
"ttl_value_key": "cache_creation_price_per_1m",
|
||||
"tiers": build_tier_entries(&tiers, "cache_creation_price_per_1m", Some(1.25), true),
|
||||
"default": base_cache_creation_price,
|
||||
}),
|
||||
);
|
||||
dimension_mappings.insert(
|
||||
"cache_creation_ephemeral_1h_price_per_1m".to_string(),
|
||||
json!({
|
||||
"source": "tiered",
|
||||
"tier_key": "total_input_context",
|
||||
"allow_zero": true,
|
||||
"ttl_key": "cache_creation_ephemeral_1h_ttl_minutes",
|
||||
"ttl_value_key": "cache_creation_price_per_1m",
|
||||
"tiers": build_tier_entries(&tiers, "cache_creation_price_per_1m", Some(1.25), true),
|
||||
"default": base_cache_creation_price,
|
||||
}),
|
||||
);
|
||||
dimension_mappings.insert(
|
||||
"cache_read_price_per_1m".to_string(),
|
||||
json!({
|
||||
@@ -154,9 +209,7 @@ impl DefaultBillingRuleGenerator {
|
||||
id: "__default__".to_string(),
|
||||
name: format!("Default rule for {}", pricing.global_model_name),
|
||||
task_type: normalize_task_type(task_type).to_string(),
|
||||
expression:
|
||||
"input_cost + output_cost + cache_creation_cost + cache_read_cost + request_cost"
|
||||
.to_string(),
|
||||
expression: "input_cost + output_cost + cache_creation_uncategorized_cost + cache_creation_ephemeral_5m_cost + cache_creation_ephemeral_1h_cost + cache_read_cost + request_cost".to_string(),
|
||||
variables,
|
||||
dimension_mappings,
|
||||
scope: "default".to_string(),
|
||||
|
||||
@@ -73,6 +73,14 @@ pub async fn enrich_usage_event_with_billing(
|
||||
input_tokens: event.data.input_tokens.unwrap_or_default() as i64,
|
||||
output_tokens: event.data.output_tokens.unwrap_or_default() as i64,
|
||||
cache_creation_tokens: event.data.cache_creation_input_tokens.unwrap_or_default() as i64,
|
||||
cache_creation_ephemeral_5m_tokens: event
|
||||
.data
|
||||
.cache_creation_ephemeral_5m_input_tokens
|
||||
.unwrap_or_default() as i64,
|
||||
cache_creation_ephemeral_1h_tokens: event
|
||||
.data
|
||||
.cache_creation_ephemeral_1h_input_tokens
|
||||
.unwrap_or_default() as i64,
|
||||
cache_read_tokens: event.data.cache_read_input_tokens.unwrap_or_default() as i64,
|
||||
cache_ttl_minutes: pricing.provider_api_key_cache_ttl_minutes,
|
||||
};
|
||||
|
||||
@@ -66,6 +66,8 @@ pub struct BillingUsageInput {
|
||||
pub input_tokens: i64,
|
||||
pub output_tokens: i64,
|
||||
pub cache_creation_tokens: i64,
|
||||
pub cache_creation_ephemeral_5m_tokens: i64,
|
||||
pub cache_creation_ephemeral_1h_tokens: i64,
|
||||
pub cache_read_tokens: i64,
|
||||
pub cache_ttl_minutes: Option<i64>,
|
||||
}
|
||||
@@ -79,6 +81,8 @@ impl BillingUsageInput {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_creation_tokens: 0,
|
||||
cache_creation_ephemeral_5m_tokens: 0,
|
||||
cache_creation_ephemeral_1h_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
cache_ttl_minutes: None,
|
||||
}
|
||||
|
||||
@@ -129,6 +129,13 @@ fn build_dimensions(input: &BillingUsageInput) -> BTreeMap<String, Value> {
|
||||
input.input_tokens,
|
||||
input.cache_read_tokens,
|
||||
);
|
||||
let classified_cache_creation_tokens = input
|
||||
.cache_creation_ephemeral_5m_tokens
|
||||
.saturating_add(input.cache_creation_ephemeral_1h_tokens);
|
||||
let cache_creation_uncategorized_tokens = input
|
||||
.cache_creation_tokens
|
||||
.saturating_sub(classified_cache_creation_tokens)
|
||||
.max(0);
|
||||
let total_input_context = input
|
||||
.input_tokens
|
||||
.saturating_add(input.cache_creation_tokens)
|
||||
@@ -141,6 +148,18 @@ fn build_dimensions(input: &BillingUsageInput) -> BTreeMap<String, Value> {
|
||||
"cache_creation_tokens".to_string(),
|
||||
json!(input.cache_creation_tokens),
|
||||
),
|
||||
(
|
||||
"cache_creation_ephemeral_5m_tokens".to_string(),
|
||||
json!(input.cache_creation_ephemeral_5m_tokens),
|
||||
),
|
||||
(
|
||||
"cache_creation_ephemeral_1h_tokens".to_string(),
|
||||
json!(input.cache_creation_ephemeral_1h_tokens),
|
||||
),
|
||||
(
|
||||
"cache_creation_uncategorized_tokens".to_string(),
|
||||
json!(cache_creation_uncategorized_tokens),
|
||||
),
|
||||
(
|
||||
"cache_read_tokens".to_string(),
|
||||
json!(input.cache_read_tokens),
|
||||
@@ -159,6 +178,15 @@ fn build_dimensions(input: &BillingUsageInput) -> BTreeMap<String, Value> {
|
||||
),
|
||||
]);
|
||||
|
||||
out.insert(
|
||||
"cache_creation_ephemeral_5m_ttl_minutes".to_string(),
|
||||
json!(5),
|
||||
);
|
||||
out.insert(
|
||||
"cache_creation_ephemeral_1h_ttl_minutes".to_string(),
|
||||
json!(60),
|
||||
);
|
||||
|
||||
if let Some(cache_ttl_minutes) = input.cache_ttl_minutes {
|
||||
out.insert(
|
||||
"cache_ttl_minutes".to_string(),
|
||||
@@ -223,6 +251,8 @@ mod tests {
|
||||
input_tokens: 1_000,
|
||||
output_tokens: 500,
|
||||
cache_creation_tokens: 0,
|
||||
cache_creation_ephemeral_5m_tokens: 0,
|
||||
cache_creation_ephemeral_1h_tokens: 0,
|
||||
cache_read_tokens: 100,
|
||||
cache_ttl_minutes: Some(60),
|
||||
},
|
||||
|
||||
@@ -28,6 +28,8 @@ pub struct StoredRequestUsageAudit {
|
||||
pub output_tokens: u64,
|
||||
pub total_tokens: u64,
|
||||
pub cache_creation_input_tokens: u64,
|
||||
pub cache_creation_ephemeral_5m_input_tokens: u64,
|
||||
pub cache_creation_ephemeral_1h_input_tokens: u64,
|
||||
pub cache_read_input_tokens: u64,
|
||||
pub cache_creation_cost_usd: f64,
|
||||
pub cache_read_cost_usd: f64,
|
||||
@@ -166,6 +168,8 @@ impl StoredRequestUsageAudit {
|
||||
output_tokens: parse_u64(output_tokens, "usage.output_tokens")?,
|
||||
total_tokens: parse_u64(total_tokens, "usage.total_tokens")?,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_creation_ephemeral_5m_input_tokens: 0,
|
||||
cache_creation_ephemeral_1h_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_cost_usd: 0.0,
|
||||
cache_read_cost_usd: 0.0,
|
||||
@@ -346,6 +350,8 @@ pub struct UpsertUsageRecord {
|
||||
pub output_tokens: Option<u64>,
|
||||
pub total_tokens: Option<u64>,
|
||||
pub cache_creation_input_tokens: Option<u64>,
|
||||
pub cache_creation_ephemeral_5m_input_tokens: Option<u64>,
|
||||
pub cache_creation_ephemeral_1h_input_tokens: Option<u64>,
|
||||
pub cache_read_input_tokens: Option<u64>,
|
||||
pub cache_creation_cost_usd: Option<f64>,
|
||||
pub cache_read_cost_usd: Option<f64>,
|
||||
@@ -604,6 +610,8 @@ mod tests {
|
||||
output_tokens: Some(20),
|
||||
total_tokens: Some(30),
|
||||
cache_creation_input_tokens: None,
|
||||
cache_creation_ephemeral_5m_input_tokens: None,
|
||||
cache_creation_ephemeral_1h_input_tokens: None,
|
||||
cache_read_input_tokens: None,
|
||||
cache_creation_cost_usd: None,
|
||||
cache_read_cost_usd: None,
|
||||
|
||||
@@ -20,5 +20,6 @@ sha2.workspace = true
|
||||
sqlx = { workspace = true, features = ["migrate", "macros"] }
|
||||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
url.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
-- Align Rust-managed migrations with legacy Alembic revision
|
||||
-- c3d4e5f6a7b8 (usage_token_semantics_v2).
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'usage'
|
||||
AND column_name = 'total_tokens'
|
||||
) AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'usage'
|
||||
AND column_name = 'input_output_total_tokens'
|
||||
) THEN
|
||||
ALTER TABLE "usage" RENAME COLUMN total_tokens TO input_output_total_tokens;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE "usage"
|
||||
ADD COLUMN IF NOT EXISTS input_output_total_tokens INTEGER DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS cache_creation_input_tokens_5m INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS cache_creation_input_tokens_1h INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS input_context_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS total_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS cache_creation_cost_usd_5m NUMERIC(20, 8) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS cache_creation_cost_usd_1h NUMERIC(20, 8) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS actual_cache_creation_cost_usd_5m NUMERIC(20, 8) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS actual_cache_creation_cost_usd_1h NUMERIC(20, 8) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS actual_cache_cost_usd NUMERIC(20, 8) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS cache_creation_price_per_1m_5m NUMERIC(20, 8),
|
||||
ADD COLUMN IF NOT EXISTS cache_creation_price_per_1m_1h NUMERIC(20, 8);
|
||||
|
||||
UPDATE "usage"
|
||||
SET
|
||||
input_output_total_tokens = src.new_iot,
|
||||
input_context_tokens = src.new_ict,
|
||||
total_tokens = src.new_total,
|
||||
cache_creation_cost_usd_5m = src.new_cc5m,
|
||||
cache_creation_cost_usd_1h = src.new_cc1h,
|
||||
actual_cache_creation_cost_usd_5m = src.new_acc5m,
|
||||
actual_cache_creation_cost_usd_1h = src.new_acc1h,
|
||||
actual_cache_cost_usd = src.new_accu,
|
||||
cache_creation_price_per_1m_5m = src.new_cp5m,
|
||||
cache_creation_price_per_1m_1h = src.new_cp1h,
|
||||
cache_cost_usd = src.new_ccu
|
||||
FROM (
|
||||
SELECT
|
||||
id,
|
||||
COALESCE(
|
||||
input_output_total_tokens,
|
||||
COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0)
|
||||
) AS new_iot,
|
||||
COALESCE(input_tokens, 0) + COALESCE(cache_read_input_tokens, 0) AS new_ict,
|
||||
COALESCE(
|
||||
input_output_total_tokens,
|
||||
COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0)
|
||||
) + COALESCE(cache_creation_input_tokens, 0) + COALESCE(cache_read_input_tokens, 0) AS new_total,
|
||||
CASE
|
||||
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
|
||||
AND COALESCE(cache_creation_input_tokens_1h, 0) = 0
|
||||
THEN COALESCE(cache_creation_cost_usd, 0)
|
||||
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
|
||||
AND COALESCE(cache_creation_input_tokens, 0) > 0
|
||||
THEN COALESCE(cache_creation_cost_usd, 0)
|
||||
* (COALESCE(cache_creation_input_tokens_5m, 0) * 1.0
|
||||
/ GREATEST(COALESCE(cache_creation_input_tokens, 0), 1))
|
||||
ELSE 0
|
||||
END AS new_cc5m,
|
||||
CASE
|
||||
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
|
||||
AND COALESCE(cache_creation_input_tokens_5m, 0) = 0
|
||||
THEN COALESCE(cache_creation_cost_usd, 0)
|
||||
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
|
||||
AND COALESCE(cache_creation_input_tokens, 0) > 0
|
||||
THEN COALESCE(cache_creation_cost_usd, 0)
|
||||
* (COALESCE(cache_creation_input_tokens_1h, 0) * 1.0
|
||||
/ GREATEST(COALESCE(cache_creation_input_tokens, 0), 1))
|
||||
ELSE 0
|
||||
END AS new_cc1h,
|
||||
CASE
|
||||
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
|
||||
AND COALESCE(cache_creation_input_tokens_1h, 0) = 0
|
||||
THEN COALESCE(actual_cache_creation_cost_usd, 0)
|
||||
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
|
||||
AND COALESCE(cache_creation_input_tokens, 0) > 0
|
||||
THEN COALESCE(actual_cache_creation_cost_usd, 0)
|
||||
* (COALESCE(cache_creation_input_tokens_5m, 0) * 1.0
|
||||
/ GREATEST(COALESCE(cache_creation_input_tokens, 0), 1))
|
||||
ELSE 0
|
||||
END AS new_acc5m,
|
||||
CASE
|
||||
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
|
||||
AND COALESCE(cache_creation_input_tokens_5m, 0) = 0
|
||||
THEN COALESCE(actual_cache_creation_cost_usd, 0)
|
||||
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
|
||||
AND COALESCE(cache_creation_input_tokens, 0) > 0
|
||||
THEN COALESCE(actual_cache_creation_cost_usd, 0)
|
||||
* (COALESCE(cache_creation_input_tokens_1h, 0) * 1.0
|
||||
/ GREATEST(COALESCE(cache_creation_input_tokens, 0), 1))
|
||||
ELSE 0
|
||||
END AS new_acc1h,
|
||||
COALESCE(actual_cache_creation_cost_usd, 0)
|
||||
+ COALESCE(actual_cache_read_cost_usd, 0) AS new_accu,
|
||||
CASE
|
||||
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
|
||||
AND COALESCE(cache_creation_input_tokens_1h, 0) = 0
|
||||
THEN cache_creation_price_per_1m
|
||||
ELSE NULL
|
||||
END AS new_cp5m,
|
||||
CASE
|
||||
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
|
||||
AND COALESCE(cache_creation_input_tokens_5m, 0) = 0
|
||||
THEN cache_creation_price_per_1m
|
||||
ELSE NULL
|
||||
END AS new_cp1h,
|
||||
COALESCE(cache_creation_cost_usd, 0)
|
||||
+ COALESCE(cache_read_cost_usd, 0) AS new_ccu
|
||||
FROM "usage"
|
||||
) AS src
|
||||
WHERE "usage".id = src.id;
|
||||
22
crates/aether-data/schema/README.md
Normal file
22
crates/aether-data/schema/README.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Public Schema Snapshot
|
||||
|
||||
This directory stores a searchable snapshot of the current local Postgres `public` schema.
|
||||
|
||||
Purpose:
|
||||
- Make legacy Python-era columns discoverable without reverse-tracing every migration.
|
||||
- Keep `migrations/20260403000000_baseline.sql` as a no-op handoff point instead of stuffing the full legacy schema into a fake baseline.
|
||||
|
||||
Files:
|
||||
- `current-public-tables.tsv`: `table_name`, `column_count`
|
||||
- `current-public-columns.tsv`: `table_name`, `ordinal_position`, `column_name`, `data_type`, `is_nullable`, `column_default`, `column_comment`
|
||||
|
||||
Source:
|
||||
- Generated from the local `aether` Postgres database on `2026-04-10`.
|
||||
|
||||
Refresh commands:
|
||||
```bash
|
||||
docker compose exec -T postgres psql -U postgres -d aether -At -F $'\t' -c "SELECT table_name, COUNT(*) FROM information_schema.columns WHERE table_schema = 'public' GROUP BY table_name ORDER BY table_name;"
|
||||
docker compose exec -T postgres psql -U postgres -d aether -At -F $'\t' -c "SELECT c.table_name, c.ordinal_position, c.column_name, c.data_type, c.is_nullable, COALESCE(c.column_default, ''), COALESCE(pg_catalog.col_description((quote_ident(c.table_schema) || '.' || quote_ident(c.table_name))::regclass::oid, c.ordinal_position), '') FROM information_schema.columns c WHERE c.table_schema = 'public' ORDER BY c.table_name, c.ordinal_position;"
|
||||
```
|
||||
|
||||
This snapshot is documentation only. Runtime code must still treat the actual database as source of truth.
|
||||
831
crates/aether-data/schema/current-public-columns.tsv
Normal file
831
crates/aether-data/schema/current-public-columns.tsv
Normal file
@@ -0,0 +1,831 @@
|
||||
table_name ordinal_position column_name data_type is_nullable column_default column_comment
|
||||
_orphan_api_keys_backup 1 id character varying YES
|
||||
_orphan_api_keys_backup 2 api_key character varying YES
|
||||
_orphan_api_keys_backup 3 name character varying YES
|
||||
_orphan_api_keys_backup 4 note character varying YES
|
||||
_orphan_api_keys_backup 5 rate_multiplier double precision YES
|
||||
_orphan_api_keys_backup 6 internal_priority integer YES
|
||||
_orphan_api_keys_backup 7 global_priority integer YES
|
||||
_orphan_api_keys_backup 8 max_concurrent integer YES
|
||||
_orphan_api_keys_backup 9 allowed_models json YES
|
||||
_orphan_api_keys_backup 10 capabilities json YES
|
||||
_orphan_api_keys_backup 11 learned_max_concurrent integer YES
|
||||
_orphan_api_keys_backup 12 concurrent_429_count integer YES
|
||||
_orphan_api_keys_backup 13 rpm_429_count integer YES
|
||||
_orphan_api_keys_backup 14 last_429_at timestamp with time zone YES
|
||||
_orphan_api_keys_backup 15 last_429_type character varying YES
|
||||
_orphan_api_keys_backup 16 last_concurrent_peak integer YES
|
||||
_orphan_api_keys_backup 17 adjustment_history json YES
|
||||
_orphan_api_keys_backup 18 utilization_samples json YES
|
||||
_orphan_api_keys_backup 19 last_probe_increase_at timestamp with time zone YES
|
||||
_orphan_api_keys_backup 20 cache_ttl_minutes integer YES
|
||||
_orphan_api_keys_backup 21 max_probe_interval_minutes integer YES
|
||||
_orphan_api_keys_backup 22 request_count integer YES
|
||||
_orphan_api_keys_backup 23 success_count integer YES
|
||||
_orphan_api_keys_backup 24 error_count integer YES
|
||||
_orphan_api_keys_backup 25 total_response_time_ms integer YES
|
||||
_orphan_api_keys_backup 26 last_used_at timestamp with time zone YES
|
||||
_orphan_api_keys_backup 27 last_error_at timestamp with time zone YES
|
||||
_orphan_api_keys_backup 28 last_error_msg text YES
|
||||
_orphan_api_keys_backup 29 is_active boolean YES
|
||||
_orphan_api_keys_backup 30 expires_at timestamp with time zone YES
|
||||
_orphan_api_keys_backup 31 created_at timestamp with time zone YES
|
||||
_orphan_api_keys_backup 32 updated_at timestamp with time zone YES
|
||||
_orphan_api_keys_backup 33 endpoint_id character varying YES
|
||||
_orphan_api_keys_backup 34 rate_limit integer YES
|
||||
_orphan_api_keys_backup 35 daily_limit integer YES
|
||||
_orphan_api_keys_backup 36 monthly_limit integer YES
|
||||
_orphan_api_keys_backup 37 provider_id character varying YES
|
||||
_orphan_api_keys_backup 38 backup_at timestamp with time zone YES
|
||||
_sqlx_migrations 1 version bigint NO
|
||||
_sqlx_migrations 2 description text NO
|
||||
_sqlx_migrations 3 installed_on timestamp with time zone NO now()
|
||||
_sqlx_migrations 4 success boolean NO
|
||||
_sqlx_migrations 5 checksum bytea NO
|
||||
_sqlx_migrations 6 execution_time bigint NO
|
||||
alembic_version 1 version_num character varying NO
|
||||
announcement_reads 1 id character varying NO
|
||||
announcement_reads 2 user_id character varying NO
|
||||
announcement_reads 3 announcement_id character varying NO
|
||||
announcement_reads 4 read_at timestamp with time zone NO now()
|
||||
announcements 1 id character varying NO
|
||||
announcements 2 title character varying NO
|
||||
announcements 3 content text NO
|
||||
announcements 4 type character varying YES 'info'::character varying
|
||||
announcements 5 priority integer YES 0
|
||||
announcements 6 author_id character varying YES
|
||||
announcements 7 is_active boolean YES true
|
||||
announcements 8 is_pinned boolean YES false
|
||||
announcements 9 start_time timestamp with time zone YES
|
||||
announcements 10 end_time timestamp with time zone YES
|
||||
announcements 11 created_at timestamp with time zone NO now()
|
||||
announcements 12 updated_at timestamp with time zone NO now()
|
||||
api_key_provider_mappings 1 id character varying NO
|
||||
api_key_provider_mappings 2 api_key_id character varying NO
|
||||
api_key_provider_mappings 3 provider_id character varying NO
|
||||
api_key_provider_mappings 4 priority_adjustment integer YES 0
|
||||
api_key_provider_mappings 5 weight_multiplier double precision YES '1'::double precision
|
||||
api_key_provider_mappings 6 is_enabled boolean NO true
|
||||
api_key_provider_mappings 7 created_at timestamp with time zone NO now()
|
||||
api_key_provider_mappings 8 updated_at timestamp with time zone NO now()
|
||||
api_keys 1 id character varying NO
|
||||
api_keys 2 user_id character varying NO
|
||||
api_keys 3 key_hash character varying NO
|
||||
api_keys 4 key_encrypted text YES
|
||||
api_keys 5 name character varying YES
|
||||
api_keys 6 total_requests integer YES 0
|
||||
api_keys 7 total_cost_usd numeric YES '0'::double precision
|
||||
api_keys 10 is_standalone boolean NO false
|
||||
api_keys 11 allowed_providers json YES
|
||||
api_keys 13 allowed_api_formats json YES
|
||||
api_keys 14 allowed_models json YES
|
||||
api_keys 15 rate_limit integer YES 100
|
||||
api_keys 16 concurrent_limit integer YES 5
|
||||
api_keys 17 force_capabilities json YES
|
||||
api_keys 18 is_active boolean NO true
|
||||
api_keys 19 last_used_at timestamp with time zone YES
|
||||
api_keys 20 expires_at timestamp with time zone YES
|
||||
api_keys 21 auto_delete_on_expiry boolean NO false
|
||||
api_keys 22 created_at timestamp with time zone NO now()
|
||||
api_keys 23 updated_at timestamp with time zone NO now()
|
||||
api_keys 24 is_locked boolean NO false
|
||||
audit_logs 1 id character varying NO
|
||||
audit_logs 2 event_type character varying NO
|
||||
audit_logs 3 user_id character varying YES
|
||||
audit_logs 4 api_key_id character varying YES
|
||||
audit_logs 5 description text NO
|
||||
audit_logs 6 ip_address character varying YES
|
||||
audit_logs 7 user_agent character varying YES
|
||||
audit_logs 8 request_id character varying YES
|
||||
audit_logs 9 event_metadata json YES
|
||||
audit_logs 10 status_code integer YES
|
||||
audit_logs 11 error_message text YES
|
||||
audit_logs 12 created_at timestamp with time zone NO now()
|
||||
billing_rules 1 id character varying NO
|
||||
billing_rules 2 global_model_id character varying YES
|
||||
billing_rules 3 model_id character varying YES
|
||||
billing_rules 4 name character varying NO
|
||||
billing_rules 5 task_type character varying NO 'chat'::character varying
|
||||
billing_rules 6 expression text NO
|
||||
billing_rules 7 variables jsonb NO '{}'::jsonb
|
||||
billing_rules 8 dimension_mappings jsonb NO '{}'::jsonb
|
||||
billing_rules 9 is_enabled boolean NO true
|
||||
billing_rules 10 created_at timestamp with time zone NO now()
|
||||
billing_rules 11 updated_at timestamp with time zone NO now()
|
||||
dimension_collectors 1 id character varying NO
|
||||
dimension_collectors 2 api_format character varying NO
|
||||
dimension_collectors 3 task_type character varying NO
|
||||
dimension_collectors 4 dimension_name character varying NO
|
||||
dimension_collectors 5 source_type character varying NO
|
||||
dimension_collectors 6 source_path character varying YES
|
||||
dimension_collectors 7 value_type character varying NO 'float'::character varying
|
||||
dimension_collectors 8 transform_expression text YES
|
||||
dimension_collectors 9 default_value character varying YES
|
||||
dimension_collectors 10 priority integer NO 0
|
||||
dimension_collectors 11 is_enabled boolean NO true
|
||||
dimension_collectors 12 created_at timestamp with time zone NO now()
|
||||
dimension_collectors 13 updated_at timestamp with time zone NO now()
|
||||
gemini_file_mappings 1 id character varying NO
|
||||
gemini_file_mappings 2 file_name character varying NO
|
||||
gemini_file_mappings 3 key_id character varying NO
|
||||
gemini_file_mappings 4 user_id character varying YES
|
||||
gemini_file_mappings 5 display_name character varying YES
|
||||
gemini_file_mappings 6 mime_type character varying YES
|
||||
gemini_file_mappings 7 source_hash character varying YES
|
||||
gemini_file_mappings 8 created_at timestamp with time zone NO
|
||||
gemini_file_mappings 9 expires_at timestamp with time zone NO
|
||||
global_models 1 id character varying NO
|
||||
global_models 2 name character varying NO
|
||||
global_models 3 display_name character varying NO
|
||||
global_models 7 default_price_per_request numeric YES
|
||||
global_models 8 default_tiered_pricing json NO
|
||||
global_models 14 supported_capabilities json YES
|
||||
global_models 15 is_active boolean NO true
|
||||
global_models 16 usage_count integer NO 0
|
||||
global_models 17 created_at timestamp with time zone NO now()
|
||||
global_models 18 updated_at timestamp with time zone NO now()
|
||||
global_models 19 config jsonb YES
|
||||
ldap_configs 1 id integer NO nextval('ldap_configs_id_seq'::regclass)
|
||||
ldap_configs 2 server_url character varying NO
|
||||
ldap_configs 3 bind_dn text NO
|
||||
ldap_configs 4 bind_password_encrypted text YES
|
||||
ldap_configs 5 base_dn text NO
|
||||
ldap_configs 6 user_search_filter text NO '(uid={username})'::character varying
|
||||
ldap_configs 7 username_attr character varying NO 'uid'::character varying
|
||||
ldap_configs 8 email_attr character varying NO 'mail'::character varying
|
||||
ldap_configs 9 display_name_attr character varying NO 'cn'::character varying
|
||||
ldap_configs 10 is_enabled boolean NO false
|
||||
ldap_configs 11 is_exclusive boolean NO false
|
||||
ldap_configs 12 use_starttls boolean NO false
|
||||
ldap_configs 13 connect_timeout integer NO 10
|
||||
ldap_configs 14 created_at timestamp with time zone NO now()
|
||||
ldap_configs 15 updated_at timestamp with time zone NO now()
|
||||
management_tokens 1 id character varying NO
|
||||
management_tokens 2 user_id character varying NO
|
||||
management_tokens 3 token_hash character varying NO
|
||||
management_tokens 4 token_prefix character varying YES
|
||||
management_tokens 5 name character varying NO
|
||||
management_tokens 6 description text YES
|
||||
management_tokens 7 allowed_ips json YES
|
||||
management_tokens 8 expires_at timestamp with time zone YES
|
||||
management_tokens 9 last_used_at timestamp with time zone YES
|
||||
management_tokens 10 last_used_ip character varying YES
|
||||
management_tokens 11 usage_count integer NO 0
|
||||
management_tokens 12 is_active boolean NO true
|
||||
management_tokens 13 created_at timestamp with time zone NO now()
|
||||
management_tokens 14 updated_at timestamp with time zone NO now()
|
||||
models 1 id character varying NO
|
||||
models 2 provider_id character varying NO
|
||||
models 3 global_model_id character varying NO
|
||||
models 4 provider_model_name character varying NO
|
||||
models 5 price_per_request numeric YES
|
||||
models 6 tiered_pricing json YES
|
||||
models 7 supports_vision boolean YES
|
||||
models 8 supports_function_calling boolean YES
|
||||
models 9 supports_streaming boolean YES
|
||||
models 10 supports_extended_thinking boolean YES
|
||||
models 11 supports_image_generation boolean YES
|
||||
models 12 is_active boolean NO true
|
||||
models 13 is_available boolean YES true
|
||||
models 14 config json YES
|
||||
models 15 created_at timestamp with time zone NO now()
|
||||
models 16 updated_at timestamp with time zone NO now()
|
||||
models 17 provider_model_mappings jsonb YES
|
||||
oauth_providers 1 provider_type character varying NO
|
||||
oauth_providers 2 display_name character varying NO
|
||||
oauth_providers 3 client_id text NO
|
||||
oauth_providers 4 client_secret_encrypted text YES
|
||||
oauth_providers 5 authorization_url_override character varying YES
|
||||
oauth_providers 6 token_url_override character varying YES
|
||||
oauth_providers 7 userinfo_url_override character varying YES
|
||||
oauth_providers 8 scopes json YES
|
||||
oauth_providers 9 redirect_uri character varying NO
|
||||
oauth_providers 10 frontend_callback_url character varying NO
|
||||
oauth_providers 11 attribute_mapping json YES
|
||||
oauth_providers 12 extra_config json YES
|
||||
oauth_providers 13 is_enabled boolean NO false
|
||||
oauth_providers 14 created_at timestamp with time zone NO CURRENT_TIMESTAMP
|
||||
oauth_providers 15 updated_at timestamp with time zone NO CURRENT_TIMESTAMP
|
||||
payment_callbacks 1 id character varying NO
|
||||
payment_callbacks 2 payment_order_id character varying YES
|
||||
payment_callbacks 3 payment_method character varying NO
|
||||
payment_callbacks 4 callback_key character varying NO
|
||||
payment_callbacks 5 order_no character varying YES
|
||||
payment_callbacks 6 gateway_order_id character varying YES
|
||||
payment_callbacks 7 payload_hash character varying YES
|
||||
payment_callbacks 8 signature_valid boolean NO false
|
||||
payment_callbacks 9 status character varying NO 'received'::character varying
|
||||
payment_callbacks 10 payload jsonb YES
|
||||
payment_callbacks 11 error_message text YES
|
||||
payment_callbacks 12 created_at timestamp with time zone NO
|
||||
payment_callbacks 13 processed_at timestamp with time zone YES
|
||||
payment_orders 1 id character varying NO
|
||||
payment_orders 2 order_no character varying NO
|
||||
payment_orders 3 wallet_id character varying NO
|
||||
payment_orders 4 user_id character varying YES
|
||||
payment_orders 5 amount_usd numeric NO
|
||||
payment_orders 6 pay_amount numeric YES
|
||||
payment_orders 7 pay_currency character varying YES
|
||||
payment_orders 8 exchange_rate numeric YES
|
||||
payment_orders 9 refunded_amount_usd numeric NO '0'::numeric
|
||||
payment_orders 10 refundable_amount_usd numeric NO '0'::numeric
|
||||
payment_orders 11 payment_method character varying NO
|
||||
payment_orders 12 gateway_order_id character varying YES
|
||||
payment_orders 13 gateway_response jsonb YES
|
||||
payment_orders 14 status character varying NO 'pending'::character varying
|
||||
payment_orders 15 created_at timestamp with time zone NO
|
||||
payment_orders 16 paid_at timestamp with time zone YES
|
||||
payment_orders 17 credited_at timestamp with time zone YES
|
||||
payment_orders 18 expires_at timestamp with time zone YES
|
||||
provider_api_keys 1 id character varying NO
|
||||
provider_api_keys 3 api_key text NO
|
||||
provider_api_keys 4 name character varying NO
|
||||
provider_api_keys 5 note character varying YES
|
||||
provider_api_keys 7 internal_priority integer YES 50
|
||||
provider_api_keys 9 rpm_limit integer YES RPM限制(NULL=自适应模式)
|
||||
provider_api_keys 13 allowed_models json YES
|
||||
provider_api_keys 14 capabilities json YES
|
||||
provider_api_keys 15 learned_rpm_limit integer YES 学习到的RPM限制
|
||||
provider_api_keys 16 concurrent_429_count integer NO 0
|
||||
provider_api_keys 17 rpm_429_count integer NO 0
|
||||
provider_api_keys 18 last_429_at timestamp with time zone YES
|
||||
provider_api_keys 19 last_429_type character varying YES
|
||||
provider_api_keys 20 last_rpm_peak integer YES 触发429时的RPM峰值
|
||||
provider_api_keys 21 adjustment_history json YES
|
||||
provider_api_keys 22 utilization_samples json YES
|
||||
provider_api_keys 23 last_probe_increase_at timestamp with time zone YES
|
||||
provider_api_keys 27 cache_ttl_minutes integer NO 5
|
||||
provider_api_keys 28 max_probe_interval_minutes integer NO 32
|
||||
provider_api_keys 36 request_count integer YES 0
|
||||
provider_api_keys 37 success_count integer YES 0
|
||||
provider_api_keys 38 error_count integer YES 0
|
||||
provider_api_keys 39 total_response_time_ms integer YES 0
|
||||
provider_api_keys 40 last_used_at timestamp with time zone YES
|
||||
provider_api_keys 41 last_error_at timestamp with time zone YES
|
||||
provider_api_keys 42 last_error_msg text YES
|
||||
provider_api_keys 43 is_active boolean NO true
|
||||
provider_api_keys 44 expires_at timestamp with time zone YES
|
||||
provider_api_keys 45 created_at timestamp with time zone NO now()
|
||||
provider_api_keys 46 updated_at timestamp with time zone NO now()
|
||||
provider_api_keys 64 provider_id character varying NO
|
||||
provider_api_keys 65 api_formats json NO '[]'::json
|
||||
provider_api_keys 66 rate_multipliers json YES
|
||||
provider_api_keys 67 health_by_format jsonb YES 按API格式存储的健康度数据
|
||||
provider_api_keys 68 circuit_breaker_by_format jsonb YES 按API格式存储的熔断器状态
|
||||
provider_api_keys 73 auto_fetch_models boolean NO false
|
||||
provider_api_keys 74 last_models_fetch_at timestamp with time zone YES
|
||||
provider_api_keys 75 last_models_fetch_error text YES
|
||||
provider_api_keys 76 locked_models json YES
|
||||
provider_api_keys 77 global_priority_by_format json YES
|
||||
provider_api_keys 80 model_include_patterns json YES
|
||||
provider_api_keys 81 model_exclude_patterns json YES
|
||||
provider_api_keys 82 auth_type character varying NO 'api_key'::character varying
|
||||
provider_api_keys 83 auth_config text YES
|
||||
provider_api_keys 84 upstream_metadata jsonb YES
|
||||
provider_api_keys 85 oauth_invalid_at timestamp with time zone YES
|
||||
provider_api_keys 86 oauth_invalid_reason character varying YES
|
||||
provider_api_keys 87 proxy json YES Key 级别代理配置(覆盖 Provider 级别代理),如 {node_id, enabled}
|
||||
provider_api_keys 88 fingerprint json YES
|
||||
provider_api_keys 89 total_tokens bigint NO
|
||||
provider_api_keys 90 total_cost_usd numeric NO
|
||||
provider_api_keys 91 status_snapshot json YES
|
||||
provider_endpoints 1 id character varying NO
|
||||
provider_endpoints 2 provider_id character varying NO
|
||||
provider_endpoints 3 api_format character varying NO
|
||||
provider_endpoints 4 base_url character varying NO
|
||||
provider_endpoints 7 max_retries integer YES 3
|
||||
provider_endpoints 10 is_active boolean NO true
|
||||
provider_endpoints 11 custom_path character varying YES
|
||||
provider_endpoints 12 config json YES
|
||||
provider_endpoints 13 created_at timestamp with time zone NO now()
|
||||
provider_endpoints 14 updated_at timestamp with time zone NO now()
|
||||
provider_endpoints 15 proxy jsonb YES
|
||||
provider_endpoints 22 header_rules json YES
|
||||
provider_endpoints 23 format_acceptance_config json YES
|
||||
provider_endpoints 24 api_family character varying YES
|
||||
provider_endpoints 25 endpoint_kind character varying YES
|
||||
provider_endpoints 27 body_rules json YES
|
||||
provider_endpoints 28 health_score double precision NO 1.0
|
||||
provider_usage_tracking 1 id character varying NO
|
||||
provider_usage_tracking 2 provider_id character varying NO
|
||||
provider_usage_tracking 3 window_start timestamp with time zone NO
|
||||
provider_usage_tracking 4 window_end timestamp with time zone NO
|
||||
provider_usage_tracking 5 total_requests integer YES 0
|
||||
provider_usage_tracking 6 successful_requests integer YES 0
|
||||
provider_usage_tracking 7 failed_requests integer YES 0
|
||||
provider_usage_tracking 8 avg_response_time_ms double precision YES '0'::double precision
|
||||
provider_usage_tracking 9 total_response_time_ms double precision YES '0'::double precision
|
||||
provider_usage_tracking 10 total_cost_usd double precision YES '0'::double precision
|
||||
provider_usage_tracking 11 created_at timestamp with time zone NO now()
|
||||
provider_usage_tracking 12 updated_at timestamp with time zone NO now()
|
||||
providers 1 id character varying NO
|
||||
providers 3 name character varying NO
|
||||
providers 4 description text YES
|
||||
providers 5 website character varying YES
|
||||
providers 6 billing_type USER-DEFINED NO 'pay_as_you_go'::providerbillingtype
|
||||
providers 7 monthly_quota_usd numeric YES
|
||||
providers 8 monthly_used_usd numeric YES '0'::double precision
|
||||
providers 9 quota_reset_day integer YES 30
|
||||
providers 10 quota_last_reset_at timestamp with time zone YES
|
||||
providers 11 quota_expires_at timestamp with time zone YES
|
||||
providers 15 provider_priority integer YES 100
|
||||
providers 16 is_active boolean NO true
|
||||
providers 18 concurrent_limit integer YES
|
||||
providers 19 config json YES
|
||||
providers 20 created_at timestamp with time zone NO now()
|
||||
providers 21 updated_at timestamp with time zone NO now()
|
||||
providers 34 max_retries integer YES 最大重试次数
|
||||
providers 35 proxy jsonb YES 代理配置
|
||||
providers 36 stream_first_byte_timeout double precision YES
|
||||
providers 37 request_timeout double precision YES
|
||||
providers 38 keep_priority_on_conversion boolean NO false
|
||||
providers 39 enable_format_conversion boolean NO true
|
||||
providers 40 provider_type character varying NO 'custom'::character varying
|
||||
proxy_node_events 1 id bigint NO nextval('proxy_node_events_id_seq'::regclass)
|
||||
proxy_node_events 2 node_id character varying NO
|
||||
proxy_node_events 3 event_type character varying NO 事件类型: connected, disconnected, error
|
||||
proxy_node_events 4 detail character varying YES 事件详情(如断开原因)
|
||||
proxy_node_events 5 created_at timestamp with time zone NO
|
||||
proxy_nodes 1 id character varying NO
|
||||
proxy_nodes 2 name character varying NO
|
||||
proxy_nodes 3 ip character varying NO
|
||||
proxy_nodes 4 port integer NO
|
||||
proxy_nodes 5 region character varying YES
|
||||
proxy_nodes 6 status USER-DEFINED NO 'online'::proxynodestatus
|
||||
proxy_nodes 7 registered_by character varying YES
|
||||
proxy_nodes 8 last_heartbeat_at timestamp with time zone YES
|
||||
proxy_nodes 9 heartbeat_interval integer NO 30
|
||||
proxy_nodes 10 active_connections integer NO 0
|
||||
proxy_nodes 11 total_requests bigint NO 0
|
||||
proxy_nodes 12 avg_latency_ms double precision YES
|
||||
proxy_nodes 13 is_manual boolean NO false 是否为手动添加的代理节点
|
||||
proxy_nodes 14 proxy_url character varying YES 手动节点的完整代理 URL
|
||||
proxy_nodes 15 proxy_username character varying YES 手动节点的代理用户名
|
||||
proxy_nodes 16 proxy_password character varying YES 手动节点的代理密码
|
||||
proxy_nodes 17 created_at timestamp with time zone NO CURRENT_TIMESTAMP
|
||||
proxy_nodes 18 updated_at timestamp with time zone NO CURRENT_TIMESTAMP
|
||||
proxy_nodes 19 remote_config json YES 管理端下发的远程配置 (allowed_ports, log_level, heartbeat_interval, timestamp_tolerance)
|
||||
proxy_nodes 20 config_version integer NO 0 远程配置版本号,每次更新 +1
|
||||
proxy_nodes 23 hardware_info json YES 硬件信息 (cpu_cores, total_memory_mb, os_info, fd_limit, ...)
|
||||
proxy_nodes 24 estimated_max_concurrency integer YES 基于硬件估算的最大并发连接数
|
||||
proxy_nodes 28 tunnel_mode boolean NO false 是否使用 WebSocket 隧道模式
|
||||
proxy_nodes 29 tunnel_connected boolean NO false 隧道是否已连接
|
||||
proxy_nodes 30 tunnel_connected_at timestamp with time zone YES 隧道最近一次建立时间
|
||||
proxy_nodes 31 failed_requests bigint NO '0'::bigint 累计失败请求数
|
||||
proxy_nodes 32 dns_failures bigint NO '0'::bigint 累计 DNS 失败数
|
||||
proxy_nodes 33 stream_errors bigint NO '0'::bigint 累计流错误数
|
||||
proxy_nodes 34 proxy_metadata json YES aether-proxy 上报元数据(版本等)
|
||||
refund_requests 1 id character varying NO
|
||||
refund_requests 2 refund_no character varying NO
|
||||
refund_requests 3 wallet_id character varying NO
|
||||
refund_requests 4 user_id character varying YES
|
||||
refund_requests 5 payment_order_id character varying YES
|
||||
refund_requests 6 source_type character varying NO
|
||||
refund_requests 7 source_id character varying YES
|
||||
refund_requests 8 refund_mode character varying NO
|
||||
refund_requests 9 amount_usd numeric NO
|
||||
refund_requests 10 status character varying NO 'pending_approval'::character varying
|
||||
refund_requests 11 reason text YES
|
||||
refund_requests 12 requested_by character varying YES
|
||||
refund_requests 13 approved_by character varying YES
|
||||
refund_requests 14 processed_by character varying YES
|
||||
refund_requests 15 gateway_refund_id character varying YES
|
||||
refund_requests 16 payout_method character varying YES
|
||||
refund_requests 17 payout_reference character varying YES
|
||||
refund_requests 18 payout_proof jsonb YES
|
||||
refund_requests 19 failure_reason text YES
|
||||
refund_requests 20 idempotency_key character varying YES
|
||||
refund_requests 21 created_at timestamp with time zone NO
|
||||
refund_requests 22 updated_at timestamp with time zone NO
|
||||
refund_requests 23 processed_at timestamp with time zone YES
|
||||
refund_requests 24 completed_at timestamp with time zone YES
|
||||
request_candidates 1 id character varying NO
|
||||
request_candidates 2 request_id character varying NO
|
||||
request_candidates 3 user_id character varying YES
|
||||
request_candidates 4 api_key_id character varying YES
|
||||
request_candidates 5 candidate_index integer NO
|
||||
request_candidates 6 retry_index integer NO 0
|
||||
request_candidates 7 provider_id character varying YES
|
||||
request_candidates 8 endpoint_id character varying YES
|
||||
request_candidates 9 key_id character varying YES
|
||||
request_candidates 10 status character varying NO
|
||||
request_candidates 11 skip_reason text YES
|
||||
request_candidates 12 is_cached boolean YES false
|
||||
request_candidates 13 status_code integer YES
|
||||
request_candidates 14 error_type character varying YES
|
||||
request_candidates 15 error_message text YES
|
||||
request_candidates 16 latency_ms integer YES
|
||||
request_candidates 17 concurrent_requests integer YES
|
||||
request_candidates 18 extra_data json YES
|
||||
request_candidates 19 required_capabilities json YES
|
||||
request_candidates 20 created_at timestamp with time zone NO now()
|
||||
request_candidates 21 started_at timestamp with time zone YES
|
||||
request_candidates 22 finished_at timestamp with time zone YES
|
||||
request_candidates 23 username character varying YES 用户名快照
|
||||
request_candidates 24 api_key_name character varying YES API Key 名称快照
|
||||
stats_daily 1 id character varying NO
|
||||
stats_daily 2 date timestamp with time zone NO
|
||||
stats_daily 3 total_requests integer NO 0
|
||||
stats_daily 4 success_requests integer NO 0
|
||||
stats_daily 5 error_requests integer NO 0
|
||||
stats_daily 6 input_tokens bigint NO '0'::bigint
|
||||
stats_daily 7 output_tokens bigint NO '0'::bigint
|
||||
stats_daily 8 cache_creation_tokens bigint NO '0'::bigint
|
||||
stats_daily 9 cache_read_tokens bigint NO '0'::bigint
|
||||
stats_daily 10 total_cost numeric NO '0'::double precision
|
||||
stats_daily 11 actual_total_cost numeric NO '0'::double precision
|
||||
stats_daily 12 input_cost numeric NO '0'::double precision
|
||||
stats_daily 13 output_cost numeric NO '0'::double precision
|
||||
stats_daily 14 cache_creation_cost numeric NO '0'::double precision
|
||||
stats_daily 15 cache_read_cost numeric NO '0'::double precision
|
||||
stats_daily 16 avg_response_time_ms double precision NO '0'::double precision
|
||||
stats_daily 17 fallback_count integer NO 0
|
||||
stats_daily 18 unique_models integer NO 0
|
||||
stats_daily 19 unique_providers integer NO 0
|
||||
stats_daily 20 created_at timestamp with time zone NO now()
|
||||
stats_daily 21 updated_at timestamp with time zone NO now()
|
||||
stats_daily 22 is_complete boolean NO false
|
||||
stats_daily 23 aggregated_at timestamp with time zone YES
|
||||
stats_daily 24 p50_response_time_ms integer YES
|
||||
stats_daily 25 p90_response_time_ms integer YES
|
||||
stats_daily 26 p99_response_time_ms integer YES
|
||||
stats_daily 27 p50_first_byte_time_ms integer YES
|
||||
stats_daily 28 p90_first_byte_time_ms integer YES
|
||||
stats_daily 29 p99_first_byte_time_ms integer YES
|
||||
stats_daily_api_key 1 id character varying NO
|
||||
stats_daily_api_key 2 api_key_id character varying YES
|
||||
stats_daily_api_key 3 date timestamp with time zone NO
|
||||
stats_daily_api_key 4 total_requests integer NO 0
|
||||
stats_daily_api_key 5 success_requests integer NO 0
|
||||
stats_daily_api_key 6 error_requests integer NO 0
|
||||
stats_daily_api_key 7 input_tokens bigint NO '0'::bigint
|
||||
stats_daily_api_key 8 output_tokens bigint NO '0'::bigint
|
||||
stats_daily_api_key 9 cache_creation_tokens bigint NO '0'::bigint
|
||||
stats_daily_api_key 10 cache_read_tokens bigint NO '0'::bigint
|
||||
stats_daily_api_key 11 total_cost numeric NO '0'::double precision
|
||||
stats_daily_api_key 12 created_at timestamp with time zone NO CURRENT_TIMESTAMP
|
||||
stats_daily_api_key 13 updated_at timestamp with time zone NO CURRENT_TIMESTAMP
|
||||
stats_daily_api_key 14 api_key_name character varying YES API Key 名称快照(删除 Key 后仍可追溯)
|
||||
stats_daily_error 1 id character varying NO
|
||||
stats_daily_error 2 date timestamp with time zone NO
|
||||
stats_daily_error 3 error_category character varying NO
|
||||
stats_daily_error 4 provider_name character varying YES
|
||||
stats_daily_error 5 model character varying YES
|
||||
stats_daily_error 6 count integer NO 0
|
||||
stats_daily_error 7 created_at timestamp with time zone NO CURRENT_TIMESTAMP
|
||||
stats_daily_error 8 updated_at timestamp with time zone NO CURRENT_TIMESTAMP
|
||||
stats_daily_model 1 id character varying NO
|
||||
stats_daily_model 2 date timestamp with time zone NO
|
||||
stats_daily_model 3 model character varying NO
|
||||
stats_daily_model 4 total_requests integer NO
|
||||
stats_daily_model 5 input_tokens bigint NO
|
||||
stats_daily_model 6 output_tokens bigint NO
|
||||
stats_daily_model 7 cache_creation_tokens bigint NO
|
||||
stats_daily_model 8 cache_read_tokens bigint NO
|
||||
stats_daily_model 9 total_cost numeric NO
|
||||
stats_daily_model 10 avg_response_time_ms double precision NO
|
||||
stats_daily_model 11 created_at timestamp with time zone NO now()
|
||||
stats_daily_model 12 updated_at timestamp with time zone NO now()
|
||||
stats_daily_provider 1 id character varying NO
|
||||
stats_daily_provider 2 date timestamp with time zone NO
|
||||
stats_daily_provider 3 provider_name character varying NO
|
||||
stats_daily_provider 4 total_requests integer NO
|
||||
stats_daily_provider 5 input_tokens bigint NO
|
||||
stats_daily_provider 6 output_tokens bigint NO
|
||||
stats_daily_provider 7 cache_creation_tokens bigint NO
|
||||
stats_daily_provider 8 cache_read_tokens bigint NO
|
||||
stats_daily_provider 9 total_cost numeric NO
|
||||
stats_daily_provider 10 created_at timestamp with time zone NO
|
||||
stats_daily_provider 11 updated_at timestamp with time zone NO
|
||||
stats_hourly 1 id character varying NO
|
||||
stats_hourly 2 hour_utc timestamp with time zone NO
|
||||
stats_hourly 3 total_requests integer NO
|
||||
stats_hourly 4 success_requests integer NO
|
||||
stats_hourly 5 error_requests integer NO
|
||||
stats_hourly 6 input_tokens bigint NO
|
||||
stats_hourly 7 output_tokens bigint NO
|
||||
stats_hourly 8 cache_creation_tokens bigint NO
|
||||
stats_hourly 9 cache_read_tokens bigint NO
|
||||
stats_hourly 10 total_cost numeric NO
|
||||
stats_hourly 11 actual_total_cost numeric NO
|
||||
stats_hourly 12 avg_response_time_ms double precision NO
|
||||
stats_hourly 13 is_complete boolean NO
|
||||
stats_hourly 14 aggregated_at timestamp with time zone YES
|
||||
stats_hourly 15 created_at timestamp with time zone NO
|
||||
stats_hourly 16 updated_at timestamp with time zone NO
|
||||
stats_hourly_model 1 id character varying NO
|
||||
stats_hourly_model 2 hour_utc timestamp with time zone NO
|
||||
stats_hourly_model 3 model character varying NO
|
||||
stats_hourly_model 4 total_requests integer NO
|
||||
stats_hourly_model 5 input_tokens bigint NO
|
||||
stats_hourly_model 6 output_tokens bigint NO
|
||||
stats_hourly_model 7 total_cost numeric NO
|
||||
stats_hourly_model 8 avg_response_time_ms double precision NO
|
||||
stats_hourly_model 9 created_at timestamp with time zone NO
|
||||
stats_hourly_model 10 updated_at timestamp with time zone NO
|
||||
stats_hourly_provider 1 id character varying NO
|
||||
stats_hourly_provider 2 hour_utc timestamp with time zone NO
|
||||
stats_hourly_provider 3 provider_name character varying NO
|
||||
stats_hourly_provider 4 total_requests integer NO
|
||||
stats_hourly_provider 5 input_tokens bigint NO
|
||||
stats_hourly_provider 6 output_tokens bigint NO
|
||||
stats_hourly_provider 7 total_cost numeric NO
|
||||
stats_hourly_provider 8 created_at timestamp with time zone NO
|
||||
stats_hourly_provider 9 updated_at timestamp with time zone NO
|
||||
stats_hourly_user 1 id character varying NO
|
||||
stats_hourly_user 2 hour_utc timestamp with time zone NO
|
||||
stats_hourly_user 3 user_id character varying NO
|
||||
stats_hourly_user 4 total_requests integer NO
|
||||
stats_hourly_user 5 success_requests integer NO
|
||||
stats_hourly_user 6 error_requests integer NO
|
||||
stats_hourly_user 7 input_tokens bigint NO
|
||||
stats_hourly_user 8 output_tokens bigint NO
|
||||
stats_hourly_user 9 total_cost numeric NO
|
||||
stats_hourly_user 10 created_at timestamp with time zone NO
|
||||
stats_hourly_user 11 updated_at timestamp with time zone NO
|
||||
stats_summary 1 id character varying NO
|
||||
stats_summary 2 cutoff_date timestamp with time zone NO
|
||||
stats_summary 3 all_time_requests integer NO 0
|
||||
stats_summary 4 all_time_success_requests integer NO 0
|
||||
stats_summary 5 all_time_error_requests integer NO 0
|
||||
stats_summary 6 all_time_input_tokens bigint NO '0'::bigint
|
||||
stats_summary 7 all_time_output_tokens bigint NO '0'::bigint
|
||||
stats_summary 8 all_time_cache_creation_tokens bigint NO '0'::bigint
|
||||
stats_summary 9 all_time_cache_read_tokens bigint NO '0'::bigint
|
||||
stats_summary 10 all_time_cost numeric NO '0'::double precision
|
||||
stats_summary 11 all_time_actual_cost numeric NO '0'::double precision
|
||||
stats_summary 12 total_users integer NO 0
|
||||
stats_summary 13 active_users integer NO 0
|
||||
stats_summary 14 total_api_keys integer NO 0
|
||||
stats_summary 15 active_api_keys integer NO 0
|
||||
stats_summary 16 created_at timestamp with time zone NO now()
|
||||
stats_summary 17 updated_at timestamp with time zone NO now()
|
||||
stats_user_daily 1 id character varying NO
|
||||
stats_user_daily 2 user_id character varying YES
|
||||
stats_user_daily 3 date timestamp with time zone NO
|
||||
stats_user_daily 4 total_requests integer NO 0
|
||||
stats_user_daily 5 success_requests integer NO 0
|
||||
stats_user_daily 6 error_requests integer NO 0
|
||||
stats_user_daily 7 input_tokens bigint NO '0'::bigint
|
||||
stats_user_daily 8 output_tokens bigint NO '0'::bigint
|
||||
stats_user_daily 9 cache_creation_tokens bigint NO '0'::bigint
|
||||
stats_user_daily 10 cache_read_tokens bigint NO '0'::bigint
|
||||
stats_user_daily 11 total_cost numeric NO '0'::double precision
|
||||
stats_user_daily 12 created_at timestamp with time zone NO now()
|
||||
stats_user_daily 13 updated_at timestamp with time zone NO now()
|
||||
stats_user_daily 14 username character varying YES 用户名快照(删除用户后仍可追溯)
|
||||
system_configs 1 id character varying NO
|
||||
system_configs 2 key character varying NO
|
||||
system_configs 3 value json NO
|
||||
system_configs 4 description text YES
|
||||
system_configs 5 created_at timestamp with time zone NO now()
|
||||
system_configs 6 updated_at timestamp with time zone NO now()
|
||||
usage 1 id character varying NO
|
||||
usage 2 user_id character varying YES
|
||||
usage 3 api_key_id character varying YES
|
||||
usage 4 request_id character varying NO
|
||||
usage 5 provider_name character varying NO
|
||||
usage 6 model character varying NO
|
||||
usage 7 target_model character varying YES
|
||||
usage 8 provider_id character varying YES
|
||||
usage 9 provider_endpoint_id character varying YES
|
||||
usage 10 provider_api_key_id character varying YES
|
||||
usage 11 input_tokens integer YES 0
|
||||
usage 12 output_tokens integer YES 0
|
||||
usage 13 input_output_total_tokens integer YES 0
|
||||
usage 14 cache_creation_input_tokens integer YES 0
|
||||
usage 15 cache_read_input_tokens integer YES 0
|
||||
usage 16 input_cost_usd numeric YES '0'::double precision
|
||||
usage 17 output_cost_usd numeric YES '0'::double precision
|
||||
usage 18 cache_cost_usd numeric YES '0'::double precision
|
||||
usage 19 cache_creation_cost_usd numeric YES '0'::double precision
|
||||
usage 20 cache_read_cost_usd numeric YES '0'::double precision
|
||||
usage 21 request_cost_usd numeric YES '0'::double precision
|
||||
usage 22 total_cost_usd numeric YES '0'::double precision
|
||||
usage 23 actual_input_cost_usd numeric YES '0'::double precision
|
||||
usage 24 actual_output_cost_usd numeric YES '0'::double precision
|
||||
usage 25 actual_cache_creation_cost_usd numeric YES '0'::double precision
|
||||
usage 26 actual_cache_read_cost_usd numeric YES '0'::double precision
|
||||
usage 27 actual_request_cost_usd numeric YES '0'::double precision
|
||||
usage 28 actual_total_cost_usd numeric YES '0'::double precision
|
||||
usage 29 rate_multiplier numeric YES '1'::double precision
|
||||
usage 30 input_price_per_1m numeric YES
|
||||
usage 31 output_price_per_1m numeric YES
|
||||
usage 32 cache_creation_price_per_1m numeric YES
|
||||
usage 33 cache_read_price_per_1m numeric YES
|
||||
usage 34 price_per_request numeric YES
|
||||
usage 35 request_type character varying YES
|
||||
usage 36 api_format character varying YES
|
||||
usage 37 is_stream boolean YES false
|
||||
usage 38 status_code integer YES
|
||||
usage 39 error_message text YES
|
||||
usage 40 response_time_ms integer YES
|
||||
usage 41 status character varying NO 'completed'::character varying
|
||||
usage 42 request_headers json YES
|
||||
usage 43 request_body json YES
|
||||
usage 44 provider_request_headers json YES
|
||||
usage 45 response_headers json YES
|
||||
usage 46 response_body json YES
|
||||
usage 47 request_body_compressed bytea YES
|
||||
usage 48 response_body_compressed bytea YES
|
||||
usage 49 request_metadata json YES
|
||||
usage 50 created_at timestamp with time zone NO now()
|
||||
usage 51 first_byte_time_ms integer YES
|
||||
usage 54 client_response_headers json YES
|
||||
usage 59 endpoint_api_format character varying YES
|
||||
usage 60 has_format_conversion boolean YES false
|
||||
usage 63 billing_status character varying NO 'pending'::character varying
|
||||
usage 64 finalized_at timestamp with time zone YES
|
||||
usage 65 error_category character varying YES
|
||||
usage 66 provider_request_body json YES
|
||||
usage 67 provider_request_body_compressed bytea YES
|
||||
usage 68 client_response_body json YES
|
||||
usage 69 client_response_body_compressed bytea YES
|
||||
usage 70 api_family character varying YES
|
||||
usage 71 endpoint_kind character varying YES
|
||||
usage 72 provider_api_family character varying YES
|
||||
usage 73 provider_endpoint_kind character varying YES
|
||||
usage 74 cache_creation_input_tokens_5m integer NO 0 5min TTL cache creation input tokens
|
||||
usage 75 cache_creation_input_tokens_1h integer NO 0 1h TTL cache creation input tokens
|
||||
usage 76 wallet_id character varying YES
|
||||
usage 77 wallet_balance_before numeric YES
|
||||
usage 78 wallet_balance_after numeric YES
|
||||
usage 79 wallet_recharge_balance_before numeric YES
|
||||
usage 80 wallet_recharge_balance_after numeric YES
|
||||
usage 81 wallet_gift_balance_before numeric YES
|
||||
usage 82 wallet_gift_balance_after numeric YES
|
||||
usage 83 username character varying YES 用户名快照
|
||||
usage 84 api_key_name character varying YES API Key 名称快照
|
||||
usage 85 input_context_tokens integer NO 0
|
||||
usage 86 total_tokens integer NO 0
|
||||
usage 87 cache_creation_cost_usd_5m numeric NO '0'::numeric
|
||||
usage 88 cache_creation_cost_usd_1h numeric NO '0'::numeric
|
||||
usage 89 actual_cache_creation_cost_usd_5m numeric NO '0'::numeric
|
||||
usage 90 actual_cache_creation_cost_usd_1h numeric NO '0'::numeric
|
||||
usage 91 actual_cache_cost_usd numeric NO '0'::numeric
|
||||
usage 92 cache_creation_price_per_1m_5m numeric YES
|
||||
usage 93 cache_creation_price_per_1m_1h numeric YES
|
||||
user_model_usage_counts 1 id character varying NO
|
||||
user_model_usage_counts 2 user_id character varying NO
|
||||
user_model_usage_counts 3 model character varying NO
|
||||
user_model_usage_counts 4 usage_count integer NO 0
|
||||
user_model_usage_counts 5 created_at timestamp with time zone NO now()
|
||||
user_model_usage_counts 6 updated_at timestamp with time zone NO now()
|
||||
user_oauth_links 1 id character varying NO
|
||||
user_oauth_links 2 user_id character varying NO
|
||||
user_oauth_links 3 provider_type character varying NO
|
||||
user_oauth_links 4 provider_user_id character varying NO
|
||||
user_oauth_links 5 provider_username character varying YES
|
||||
user_oauth_links 6 provider_email character varying YES
|
||||
user_oauth_links 7 extra_data json YES
|
||||
user_oauth_links 8 linked_at timestamp with time zone NO CURRENT_TIMESTAMP
|
||||
user_oauth_links 9 last_login_at timestamp with time zone YES
|
||||
user_preferences 1 id character varying NO
|
||||
user_preferences 2 user_id character varying NO
|
||||
user_preferences 3 avatar_url character varying YES
|
||||
user_preferences 4 bio text YES
|
||||
user_preferences 5 default_provider_id character varying YES
|
||||
user_preferences 6 theme character varying YES 'light'::character varying
|
||||
user_preferences 7 language character varying YES 'zh-CN'::character varying
|
||||
user_preferences 8 timezone character varying YES 'Asia/Shanghai'::character varying
|
||||
user_preferences 9 email_notifications boolean YES true
|
||||
user_preferences 10 usage_alerts boolean YES true
|
||||
user_preferences 11 announcement_notifications boolean YES true
|
||||
user_preferences 12 created_at timestamp with time zone NO now()
|
||||
user_preferences 13 updated_at timestamp with time zone NO now()
|
||||
user_sessions 1 id character varying NO
|
||||
user_sessions 2 user_id character varying NO
|
||||
user_sessions 3 client_device_id character varying NO
|
||||
user_sessions 4 device_label character varying YES
|
||||
user_sessions 5 device_type character varying NO 'unknown'::character varying
|
||||
user_sessions 6 browser_name character varying YES
|
||||
user_sessions 7 browser_version character varying YES
|
||||
user_sessions 8 os_name character varying YES
|
||||
user_sessions 9 os_version character varying YES
|
||||
user_sessions 10 device_model character varying YES
|
||||
user_sessions 11 ip_address character varying YES
|
||||
user_sessions 12 user_agent character varying YES
|
||||
user_sessions 13 client_hints json YES
|
||||
user_sessions 14 refresh_token_hash character varying NO
|
||||
user_sessions 15 prev_refresh_token_hash character varying YES
|
||||
user_sessions 16 rotated_at timestamp with time zone YES
|
||||
user_sessions 17 last_seen_at timestamp with time zone NO now()
|
||||
user_sessions 18 expires_at timestamp with time zone NO
|
||||
user_sessions 19 revoked_at timestamp with time zone YES
|
||||
user_sessions 20 revoke_reason character varying YES
|
||||
user_sessions 21 created_at timestamp with time zone NO now()
|
||||
user_sessions 22 updated_at timestamp with time zone NO now()
|
||||
users 1 id character varying NO
|
||||
users 2 email character varying YES
|
||||
users 3 username character varying NO
|
||||
users 4 password_hash character varying YES
|
||||
users 5 role USER-DEFINED NO 'user'::userrole
|
||||
users 6 allowed_providers json YES
|
||||
users 7 allowed_api_formats json YES
|
||||
users 8 allowed_models json YES
|
||||
users 9 model_capability_settings json YES
|
||||
users 13 is_active boolean NO true
|
||||
users 14 is_deleted boolean NO false
|
||||
users 15 created_at timestamp with time zone NO now()
|
||||
users 16 updated_at timestamp with time zone NO now()
|
||||
users 17 last_login_at timestamp with time zone YES
|
||||
users 18 auth_source USER-DEFINED NO 'local'::authsource
|
||||
users 19 ldap_dn character varying YES
|
||||
users 20 ldap_username character varying YES
|
||||
users 21 email_verified boolean NO
|
||||
users 22 rate_limit integer YES
|
||||
video_tasks 1 id character varying NO
|
||||
video_tasks 2 external_task_id character varying YES
|
||||
video_tasks 3 user_id character varying YES
|
||||
video_tasks 4 api_key_id character varying YES
|
||||
video_tasks 5 provider_id character varying YES
|
||||
video_tasks 6 endpoint_id character varying YES
|
||||
video_tasks 7 key_id character varying YES
|
||||
video_tasks 8 client_api_format character varying NO
|
||||
video_tasks 9 provider_api_format character varying NO
|
||||
video_tasks 10 format_converted boolean YES false
|
||||
video_tasks 11 model character varying NO
|
||||
video_tasks 12 prompt text NO
|
||||
video_tasks 13 original_request_body json YES
|
||||
video_tasks 14 converted_request_body json YES
|
||||
video_tasks 15 duration_seconds integer YES 4
|
||||
video_tasks 16 resolution character varying YES '720p'::character varying
|
||||
video_tasks 17 aspect_ratio character varying YES '16:9'::character varying
|
||||
video_tasks 18 size character varying YES
|
||||
video_tasks 19 status character varying YES 'pending'::character varying
|
||||
video_tasks 20 progress_percent integer YES 0
|
||||
video_tasks 21 progress_message character varying YES
|
||||
video_tasks 22 video_url character varying YES
|
||||
video_tasks 23 video_urls json YES
|
||||
video_tasks 24 thumbnail_url character varying YES
|
||||
video_tasks 25 video_size_bytes bigint YES
|
||||
video_tasks 26 video_expires_at timestamp with time zone YES
|
||||
video_tasks 27 stored_video_path character varying YES
|
||||
video_tasks 28 storage_provider character varying YES
|
||||
video_tasks 29 error_code character varying YES
|
||||
video_tasks 30 error_message text YES
|
||||
video_tasks 31 retry_count integer YES 0
|
||||
video_tasks 32 max_retries integer YES 3
|
||||
video_tasks 33 poll_interval_seconds integer YES 10
|
||||
video_tasks 34 next_poll_at timestamp with time zone YES
|
||||
video_tasks 35 poll_count integer YES 0
|
||||
video_tasks 36 max_poll_count integer YES 360
|
||||
video_tasks 37 remixed_from_task_id character varying YES
|
||||
video_tasks 38 webhook_url character varying YES
|
||||
video_tasks 39 webhook_sent boolean YES false
|
||||
video_tasks 40 webhook_sent_at timestamp with time zone YES
|
||||
video_tasks 41 created_at timestamp with time zone YES CURRENT_TIMESTAMP
|
||||
video_tasks 42 submitted_at timestamp with time zone YES
|
||||
video_tasks 43 completed_at timestamp with time zone YES
|
||||
video_tasks 44 updated_at timestamp with time zone YES CURRENT_TIMESTAMP
|
||||
video_tasks 46 request_metadata json YES
|
||||
video_tasks 49 request_id character varying NO
|
||||
video_tasks 50 short_id character varying NO
|
||||
video_tasks 52 video_duration_seconds double precision YES
|
||||
video_tasks 53 username character varying YES 用户名快照
|
||||
video_tasks 54 api_key_name character varying YES API Key 名称快照
|
||||
wallet_daily_usage_ledgers 1 id character varying NO
|
||||
wallet_daily_usage_ledgers 2 wallet_id character varying NO
|
||||
wallet_daily_usage_ledgers 3 billing_date date NO
|
||||
wallet_daily_usage_ledgers 4 billing_timezone character varying NO
|
||||
wallet_daily_usage_ledgers 5 total_cost_usd numeric NO '0'::numeric
|
||||
wallet_daily_usage_ledgers 6 total_requests integer NO 0
|
||||
wallet_daily_usage_ledgers 7 input_tokens bigint NO '0'::bigint
|
||||
wallet_daily_usage_ledgers 8 output_tokens bigint NO '0'::bigint
|
||||
wallet_daily_usage_ledgers 9 cache_creation_tokens bigint NO '0'::bigint
|
||||
wallet_daily_usage_ledgers 10 cache_read_tokens bigint NO '0'::bigint
|
||||
wallet_daily_usage_ledgers 11 first_finalized_at timestamp with time zone YES
|
||||
wallet_daily_usage_ledgers 12 last_finalized_at timestamp with time zone YES
|
||||
wallet_daily_usage_ledgers 13 aggregated_at timestamp with time zone NO
|
||||
wallet_daily_usage_ledgers 14 created_at timestamp with time zone NO
|
||||
wallet_daily_usage_ledgers 15 updated_at timestamp with time zone NO
|
||||
wallet_transactions 1 id character varying NO
|
||||
wallet_transactions 2 wallet_id character varying NO
|
||||
wallet_transactions 3 category character varying NO
|
||||
wallet_transactions 4 reason_code character varying NO
|
||||
wallet_transactions 5 amount numeric NO
|
||||
wallet_transactions 6 balance_before numeric NO
|
||||
wallet_transactions 7 balance_after numeric NO
|
||||
wallet_transactions 8 recharge_balance_before numeric NO
|
||||
wallet_transactions 9 recharge_balance_after numeric NO
|
||||
wallet_transactions 10 gift_balance_before numeric NO
|
||||
wallet_transactions 11 gift_balance_after numeric NO
|
||||
wallet_transactions 12 link_type character varying YES
|
||||
wallet_transactions 13 link_id character varying YES
|
||||
wallet_transactions 14 operator_id character varying YES
|
||||
wallet_transactions 15 description text YES
|
||||
wallet_transactions 16 created_at timestamp with time zone NO
|
||||
wallets 1 id character varying NO
|
||||
wallets 2 user_id character varying YES
|
||||
wallets 3 api_key_id character varying YES
|
||||
wallets 4 balance numeric NO '0'::numeric
|
||||
wallets 5 gift_balance numeric NO '0'::numeric
|
||||
wallets 6 limit_mode character varying NO 'finite'::character varying
|
||||
wallets 7 currency character varying NO 'USD'::character varying
|
||||
wallets 8 status character varying NO 'active'::character varying
|
||||
wallets 9 total_recharged numeric NO '0'::numeric
|
||||
wallets 10 total_consumed numeric NO '0'::numeric
|
||||
wallets 11 total_refunded numeric NO '0'::numeric
|
||||
wallets 12 total_adjusted numeric NO '0'::numeric
|
||||
wallets 14 created_at timestamp with time zone NO
|
||||
wallets 15 updated_at timestamp with time zone NO
|
||||
|
49
crates/aether-data/schema/current-public-tables.tsv
Normal file
49
crates/aether-data/schema/current-public-tables.tsv
Normal file
@@ -0,0 +1,49 @@
|
||||
table_name column_count
|
||||
_orphan_api_keys_backup 38
|
||||
_sqlx_migrations 6
|
||||
alembic_version 1
|
||||
announcement_reads 4
|
||||
announcements 12
|
||||
api_key_provider_mappings 8
|
||||
api_keys 21
|
||||
audit_logs 12
|
||||
billing_rules 11
|
||||
dimension_collectors 13
|
||||
gemini_file_mappings 9
|
||||
global_models 11
|
||||
ldap_configs 15
|
||||
management_tokens 14
|
||||
models 17
|
||||
oauth_providers 15
|
||||
payment_callbacks 13
|
||||
payment_orders 18
|
||||
provider_api_keys 52
|
||||
provider_endpoints 17
|
||||
provider_usage_tracking 12
|
||||
providers 23
|
||||
proxy_node_events 5
|
||||
proxy_nodes 29
|
||||
refund_requests 24
|
||||
request_candidates 24
|
||||
stats_daily 29
|
||||
stats_daily_api_key 14
|
||||
stats_daily_error 8
|
||||
stats_daily_model 12
|
||||
stats_daily_provider 11
|
||||
stats_hourly 16
|
||||
stats_hourly_model 10
|
||||
stats_hourly_provider 9
|
||||
stats_hourly_user 11
|
||||
stats_summary 17
|
||||
stats_user_daily 14
|
||||
system_configs 6
|
||||
usage 85
|
||||
user_model_usage_counts 6
|
||||
user_oauth_links 9
|
||||
user_preferences 13
|
||||
user_sessions 22
|
||||
users 19
|
||||
video_tasks 50
|
||||
wallet_daily_usage_ledgers 15
|
||||
wallet_transactions 16
|
||||
wallets 14
|
||||
|
@@ -1,6 +1,160 @@
|
||||
use sqlx::PgPool;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use sqlx::{
|
||||
migrate::{Migrate, MigrateError, Migrator},
|
||||
PgPool,
|
||||
};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
static MIGRATOR: Migrator = sqlx::migrate!("./migrations");
|
||||
|
||||
/// Run all pending migrations embedded at compile time from `migrations/`.
|
||||
pub async fn run_migrations(pool: &PgPool) -> Result<(), sqlx::migrate::MigrateError> {
|
||||
sqlx::migrate!("./migrations").run(pool).await
|
||||
pub async fn run_migrations(pool: &PgPool) -> Result<(), MigrateError> {
|
||||
let mut conn = pool.acquire().await?;
|
||||
|
||||
if MIGRATOR.locking {
|
||||
conn.lock().await?;
|
||||
}
|
||||
|
||||
let result = run_migrations_locked(&mut *conn).await;
|
||||
|
||||
if MIGRATOR.locking {
|
||||
match conn.unlock().await {
|
||||
Ok(()) => {}
|
||||
Err(unlock_error) if result.is_ok() => return Err(unlock_error),
|
||||
Err(unlock_error) => {
|
||||
warn!(
|
||||
error = %unlock_error,
|
||||
"database migration lock release failed after migration error"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
async fn run_migrations_locked<C>(conn: &mut C) -> Result<(), MigrateError>
|
||||
where
|
||||
C: Migrate,
|
||||
{
|
||||
conn.ensure_migrations_table().await?;
|
||||
|
||||
if let Some(version) = conn.dirty_version().await? {
|
||||
error!(version, "database migration state is dirty");
|
||||
return Err(MigrateError::Dirty(version));
|
||||
}
|
||||
|
||||
let applied_migrations = conn.list_applied_migrations().await?;
|
||||
validate_applied_migrations(&applied_migrations)?;
|
||||
|
||||
let known_versions: HashSet<_> = MIGRATOR
|
||||
.iter()
|
||||
.filter(|migration| migration.migration_type.is_up_migration())
|
||||
.map(|migration| migration.version)
|
||||
.collect();
|
||||
let applied_migrations_by_version: HashMap<_, _> = applied_migrations
|
||||
.into_iter()
|
||||
.map(|migration| (migration.version, migration))
|
||||
.collect();
|
||||
|
||||
let pending_migrations: Vec<_> = MIGRATOR
|
||||
.iter()
|
||||
.filter(|migration| migration.migration_type.is_up_migration())
|
||||
.filter(|migration| !applied_migrations_by_version.contains_key(&migration.version))
|
||||
.collect();
|
||||
|
||||
let total_migrations = known_versions.len();
|
||||
let applied_count = total_migrations.saturating_sub(pending_migrations.len());
|
||||
|
||||
if pending_migrations.is_empty() {
|
||||
info!(
|
||||
total_migrations,
|
||||
applied_migrations = applied_count,
|
||||
pending_migrations = 0,
|
||||
"database migrations already up to date"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!(
|
||||
total_migrations,
|
||||
applied_migrations = applied_count,
|
||||
pending_migrations = pending_migrations.len(),
|
||||
"database migrations pending"
|
||||
);
|
||||
|
||||
for (index, migration) in pending_migrations.iter().enumerate() {
|
||||
let current = index + 1;
|
||||
let total = pending_migrations.len();
|
||||
|
||||
info!(
|
||||
current,
|
||||
total,
|
||||
version = migration.version,
|
||||
description = %migration.description,
|
||||
"applying database migration"
|
||||
);
|
||||
|
||||
let elapsed = conn.apply(migration).await?;
|
||||
|
||||
info!(
|
||||
current,
|
||||
total,
|
||||
version = migration.version,
|
||||
description = %migration.description,
|
||||
elapsed_ms = elapsed.as_millis() as u64,
|
||||
"applied database migration"
|
||||
);
|
||||
}
|
||||
|
||||
info!(
|
||||
total_migrations,
|
||||
applied_migrations = total_migrations,
|
||||
pending_migrations = 0,
|
||||
"database migrations complete"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_applied_migrations(
|
||||
applied_migrations: &[sqlx::migrate::AppliedMigration],
|
||||
) -> Result<(), MigrateError> {
|
||||
if MIGRATOR.ignore_missing {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let known_versions: HashSet<_> = MIGRATOR.iter().map(|migration| migration.version).collect();
|
||||
|
||||
for applied_migration in applied_migrations {
|
||||
if !known_versions.contains(&applied_migration.version) {
|
||||
error!(
|
||||
version = applied_migration.version,
|
||||
"applied database migration is missing from embedded migrations"
|
||||
);
|
||||
return Err(MigrateError::VersionMissing(applied_migration.version));
|
||||
}
|
||||
}
|
||||
|
||||
for migration in MIGRATOR
|
||||
.iter()
|
||||
.filter(|migration| migration.migration_type.is_up_migration())
|
||||
{
|
||||
if let Some(applied_migration) = applied_migrations
|
||||
.iter()
|
||||
.find(|applied_migration| applied_migration.version == migration.version)
|
||||
{
|
||||
if migration.checksum != applied_migration.checksum {
|
||||
error!(
|
||||
version = migration.version,
|
||||
description = %migration.description,
|
||||
"database migration checksum mismatch detected"
|
||||
);
|
||||
return Err(MigrateError::VersionMismatch(migration.version));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -258,6 +258,20 @@ impl UsageWriteRepository for InMemoryUsageReadRepository {
|
||||
.map(|existing| existing.cache_creation_input_tokens)
|
||||
.unwrap_or_default()
|
||||
}),
|
||||
cache_creation_ephemeral_5m_input_tokens: usage
|
||||
.cache_creation_ephemeral_5m_input_tokens
|
||||
.unwrap_or_else(|| {
|
||||
existing
|
||||
.map(|existing| existing.cache_creation_ephemeral_5m_input_tokens)
|
||||
.unwrap_or_default()
|
||||
}),
|
||||
cache_creation_ephemeral_1h_input_tokens: usage
|
||||
.cache_creation_ephemeral_1h_input_tokens
|
||||
.unwrap_or_else(|| {
|
||||
existing
|
||||
.map(|existing| existing.cache_creation_ephemeral_1h_input_tokens)
|
||||
.unwrap_or_default()
|
||||
}),
|
||||
cache_read_input_tokens: usage.cache_read_input_tokens.unwrap_or_else(|| {
|
||||
existing
|
||||
.map(|existing| existing.cache_read_input_tokens)
|
||||
@@ -427,6 +441,8 @@ mod tests {
|
||||
output_tokens: Some(20),
|
||||
total_tokens: None,
|
||||
cache_creation_input_tokens: None,
|
||||
cache_creation_ephemeral_5m_input_tokens: None,
|
||||
cache_creation_ephemeral_1h_input_tokens: None,
|
||||
cache_read_input_tokens: None,
|
||||
cache_creation_cost_usd: None,
|
||||
cache_read_cost_usd: None,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use async_trait::async_trait;
|
||||
use futures_util::future::BoxFuture;
|
||||
use serde_json::Value;
|
||||
use sqlx::{PgPool, Postgres, QueryBuilder, Row};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -37,6 +38,8 @@ SELECT
|
||||
output_tokens,
|
||||
total_tokens,
|
||||
COALESCE(cache_creation_input_tokens, 0) AS cache_creation_input_tokens,
|
||||
COALESCE(cache_creation_input_tokens_5m, 0) AS cache_creation_ephemeral_5m_input_tokens,
|
||||
COALESCE(cache_creation_input_tokens_1h, 0) AS cache_creation_ephemeral_1h_input_tokens,
|
||||
COALESCE(cache_read_input_tokens, 0) AS cache_read_input_tokens,
|
||||
COALESCE(CAST(cache_creation_cost_usd AS DOUBLE PRECISION), 0) AS cache_creation_cost_usd,
|
||||
COALESCE(CAST(cache_read_cost_usd AS DOUBLE PRECISION), 0) AS cache_read_cost_usd,
|
||||
@@ -94,6 +97,8 @@ SELECT
|
||||
output_tokens,
|
||||
total_tokens,
|
||||
COALESCE(cache_creation_input_tokens, 0) AS cache_creation_input_tokens,
|
||||
COALESCE(cache_creation_input_tokens_5m, 0) AS cache_creation_ephemeral_5m_input_tokens,
|
||||
COALESCE(cache_creation_input_tokens_1h, 0) AS cache_creation_ephemeral_1h_input_tokens,
|
||||
COALESCE(cache_read_input_tokens, 0) AS cache_read_input_tokens,
|
||||
COALESCE(CAST(cache_creation_cost_usd AS DOUBLE PRECISION), 0) AS cache_creation_cost_usd,
|
||||
COALESCE(CAST(cache_read_cost_usd AS DOUBLE PRECISION), 0) AS cache_read_cost_usd,
|
||||
@@ -181,6 +186,8 @@ SELECT
|
||||
output_tokens,
|
||||
total_tokens,
|
||||
COALESCE(cache_creation_input_tokens, 0) AS cache_creation_input_tokens,
|
||||
COALESCE(cache_creation_input_tokens_5m, 0) AS cache_creation_ephemeral_5m_input_tokens,
|
||||
COALESCE(cache_creation_input_tokens_1h, 0) AS cache_creation_ephemeral_1h_input_tokens,
|
||||
COALESCE(cache_read_input_tokens, 0) AS cache_read_input_tokens,
|
||||
COALESCE(CAST(cache_creation_cost_usd AS DOUBLE PRECISION), 0) AS cache_creation_cost_usd,
|
||||
COALESCE(CAST(cache_read_cost_usd AS DOUBLE PRECISION), 0) AS cache_read_cost_usd,
|
||||
@@ -194,15 +201,15 @@ SELECT
|
||||
first_byte_time_ms,
|
||||
status,
|
||||
billing_status,
|
||||
NULL::jsonb AS request_headers,
|
||||
NULL::jsonb AS request_body,
|
||||
NULL::jsonb AS provider_request_headers,
|
||||
NULL::jsonb AS provider_request_body,
|
||||
NULL::jsonb AS response_headers,
|
||||
NULL::jsonb AS response_body,
|
||||
NULL::jsonb AS client_response_headers,
|
||||
NULL::jsonb AS client_response_body,
|
||||
NULL::jsonb AS request_metadata,
|
||||
NULL::json AS request_headers,
|
||||
NULL::json AS request_body,
|
||||
NULL::json AS provider_request_headers,
|
||||
NULL::json AS provider_request_body,
|
||||
NULL::json AS response_headers,
|
||||
NULL::json AS response_body,
|
||||
NULL::json AS client_response_headers,
|
||||
NULL::json AS client_response_body,
|
||||
NULL::json AS request_metadata,
|
||||
CAST(EXTRACT(EPOCH FROM created_at) AS BIGINT) AS created_at_unix_ms,
|
||||
CAST(EXTRACT(EPOCH FROM COALESCE(finalized_at, created_at)) AS BIGINT) AS updated_at_unix_secs,
|
||||
CAST(EXTRACT(EPOCH FROM finalized_at) AS BIGINT) AS finalized_at_unix_secs
|
||||
@@ -236,6 +243,8 @@ SELECT
|
||||
output_tokens,
|
||||
total_tokens,
|
||||
COALESCE(cache_creation_input_tokens, 0) AS cache_creation_input_tokens,
|
||||
COALESCE(cache_creation_input_tokens_5m, 0) AS cache_creation_ephemeral_5m_input_tokens,
|
||||
COALESCE(cache_creation_input_tokens_1h, 0) AS cache_creation_ephemeral_1h_input_tokens,
|
||||
COALESCE(cache_read_input_tokens, 0) AS cache_read_input_tokens,
|
||||
COALESCE(CAST(cache_creation_cost_usd AS DOUBLE PRECISION), 0) AS cache_creation_cost_usd,
|
||||
COALESCE(CAST(cache_read_cost_usd AS DOUBLE PRECISION), 0) AS cache_read_cost_usd,
|
||||
@@ -249,15 +258,15 @@ SELECT
|
||||
first_byte_time_ms,
|
||||
status,
|
||||
billing_status,
|
||||
NULL::jsonb AS request_headers,
|
||||
NULL::jsonb AS request_body,
|
||||
NULL::jsonb AS provider_request_headers,
|
||||
NULL::jsonb AS provider_request_body,
|
||||
NULL::jsonb AS response_headers,
|
||||
NULL::jsonb AS response_body,
|
||||
NULL::jsonb AS client_response_headers,
|
||||
NULL::jsonb AS client_response_body,
|
||||
NULL::jsonb AS request_metadata,
|
||||
NULL::json AS request_headers,
|
||||
NULL::json AS request_body,
|
||||
NULL::json AS provider_request_headers,
|
||||
NULL::json AS provider_request_body,
|
||||
NULL::json AS response_headers,
|
||||
NULL::json AS response_body,
|
||||
NULL::json AS client_response_headers,
|
||||
NULL::json AS client_response_body,
|
||||
NULL::json AS request_metadata,
|
||||
CAST(EXTRACT(EPOCH FROM created_at) AS BIGINT) AS created_at_unix_ms,
|
||||
CAST(EXTRACT(EPOCH FROM COALESCE(finalized_at, created_at)) AS BIGINT) AS updated_at_unix_secs,
|
||||
CAST(EXTRACT(EPOCH FROM finalized_at) AS BIGINT) AS finalized_at_unix_secs
|
||||
@@ -291,6 +300,8 @@ INSERT INTO "usage" (
|
||||
output_tokens,
|
||||
total_tokens,
|
||||
cache_creation_input_tokens,
|
||||
cache_creation_input_tokens_5m,
|
||||
cache_creation_input_tokens_1h,
|
||||
cache_read_input_tokens,
|
||||
cache_creation_cost_usd,
|
||||
cache_read_cost_usd,
|
||||
@@ -344,10 +355,10 @@ INSERT INTO "usage" (
|
||||
COALESCE($26, 0),
|
||||
COALESCE($27, 0),
|
||||
COALESCE($28, 0),
|
||||
$29,
|
||||
COALESCE($30, 0),
|
||||
COALESCE($29, 0),
|
||||
$30,
|
||||
COALESCE($31, 0),
|
||||
$32,
|
||||
COALESCE($32, 0),
|
||||
$33,
|
||||
$34,
|
||||
$35,
|
||||
@@ -356,18 +367,20 @@ INSERT INTO "usage" (
|
||||
$38,
|
||||
$39,
|
||||
$40,
|
||||
$41,
|
||||
$42,
|
||||
$43,
|
||||
$44,
|
||||
$45,
|
||||
$46,
|
||||
$47,
|
||||
$41::json,
|
||||
$42::json,
|
||||
$43::json,
|
||||
$44::json,
|
||||
$45::json,
|
||||
$46::json,
|
||||
$47::json,
|
||||
$48::json,
|
||||
$49::json,
|
||||
CASE
|
||||
WHEN $48 IS NULL THEN NULL
|
||||
ELSE TO_TIMESTAMP($48::double precision)
|
||||
WHEN $50 IS NULL THEN NULL
|
||||
ELSE TO_TIMESTAMP($50::double precision)
|
||||
END,
|
||||
COALESCE(TO_TIMESTAMP($49::double precision), NOW())
|
||||
COALESCE(TO_TIMESTAMP($51::double precision), NOW())
|
||||
)
|
||||
ON CONFLICT (request_id)
|
||||
DO UPDATE SET
|
||||
@@ -394,6 +407,8 @@ DO UPDATE SET
|
||||
output_tokens = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.output_tokens, "usage".output_tokens) ELSE "usage".output_tokens END,
|
||||
total_tokens = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.total_tokens, "usage".total_tokens) ELSE "usage".total_tokens END,
|
||||
cache_creation_input_tokens = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.cache_creation_input_tokens, "usage".cache_creation_input_tokens) ELSE "usage".cache_creation_input_tokens END,
|
||||
cache_creation_input_tokens_5m = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.cache_creation_input_tokens_5m, "usage".cache_creation_input_tokens_5m) ELSE "usage".cache_creation_input_tokens_5m END,
|
||||
cache_creation_input_tokens_1h = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.cache_creation_input_tokens_1h, "usage".cache_creation_input_tokens_1h) ELSE "usage".cache_creation_input_tokens_1h END,
|
||||
cache_read_input_tokens = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.cache_read_input_tokens, "usage".cache_read_input_tokens) ELSE "usage".cache_read_input_tokens END,
|
||||
cache_creation_cost_usd = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.cache_creation_cost_usd, "usage".cache_creation_cost_usd) ELSE "usage".cache_creation_cost_usd END,
|
||||
cache_read_cost_usd = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.cache_read_cost_usd, "usage".cache_read_cost_usd) ELSE "usage".cache_read_cost_usd END,
|
||||
@@ -443,6 +458,8 @@ RETURNING
|
||||
output_tokens,
|
||||
total_tokens,
|
||||
COALESCE(cache_creation_input_tokens, 0) AS cache_creation_input_tokens,
|
||||
COALESCE(cache_creation_input_tokens_5m, 0) AS cache_creation_ephemeral_5m_input_tokens,
|
||||
COALESCE(cache_creation_input_tokens_1h, 0) AS cache_creation_ephemeral_1h_input_tokens,
|
||||
COALESCE(cache_read_input_tokens, 0) AS cache_read_input_tokens,
|
||||
COALESCE(CAST(cache_creation_cost_usd AS DOUBLE PRECISION), 0) AS cache_creation_cost_usd,
|
||||
COALESCE(CAST(cache_read_cost_usd AS DOUBLE PRECISION), 0) AS cache_read_cost_usd,
|
||||
@@ -653,6 +670,19 @@ impl SqlxUsageReadRepository {
|
||||
self.tx_runner
|
||||
.run_read_write(|tx| {
|
||||
Box::pin(async move {
|
||||
let request_headers_json = json_bind_text(usage.request_headers.as_ref())?;
|
||||
let request_body_json = json_bind_text(usage.request_body.as_ref())?;
|
||||
let provider_request_headers_json =
|
||||
json_bind_text(usage.provider_request_headers.as_ref())?;
|
||||
let provider_request_body_json =
|
||||
json_bind_text(usage.provider_request_body.as_ref())?;
|
||||
let response_headers_json = json_bind_text(usage.response_headers.as_ref())?;
|
||||
let response_body_json = json_bind_text(usage.response_body.as_ref())?;
|
||||
let client_response_headers_json =
|
||||
json_bind_text(usage.client_response_headers.as_ref())?;
|
||||
let client_response_body_json =
|
||||
json_bind_text(usage.client_response_body.as_ref())?;
|
||||
let request_metadata_json = json_bind_text(usage.request_metadata.as_ref())?;
|
||||
let row = sqlx::query(UPSERT_SQL)
|
||||
.bind(Uuid::new_v4().to_string())
|
||||
.bind(&usage.request_id)
|
||||
@@ -690,6 +720,18 @@ impl SqlxUsageReadRepository {
|
||||
.transpose()?,
|
||||
)
|
||||
.bind(usage.cache_creation_input_tokens.map(to_i32).transpose()?)
|
||||
.bind(
|
||||
usage
|
||||
.cache_creation_ephemeral_5m_input_tokens
|
||||
.map(to_i32)
|
||||
.transpose()?,
|
||||
)
|
||||
.bind(
|
||||
usage
|
||||
.cache_creation_ephemeral_1h_input_tokens
|
||||
.map(to_i32)
|
||||
.transpose()?,
|
||||
)
|
||||
.bind(usage.cache_read_input_tokens.map(to_i32).transpose()?)
|
||||
.bind(usage.cache_creation_cost_usd)
|
||||
.bind(usage.cache_read_cost_usd)
|
||||
@@ -703,15 +745,15 @@ impl SqlxUsageReadRepository {
|
||||
.bind(usage.first_byte_time_ms.map(to_i32).transpose()?)
|
||||
.bind(&usage.status)
|
||||
.bind(&usage.billing_status)
|
||||
.bind(&usage.request_headers)
|
||||
.bind(&usage.request_body)
|
||||
.bind(&usage.provider_request_headers)
|
||||
.bind(&usage.provider_request_body)
|
||||
.bind(&usage.response_headers)
|
||||
.bind(&usage.response_body)
|
||||
.bind(&usage.client_response_headers)
|
||||
.bind(&usage.client_response_body)
|
||||
.bind(&usage.request_metadata)
|
||||
.bind(&request_headers_json)
|
||||
.bind(&request_body_json)
|
||||
.bind(&provider_request_headers_json)
|
||||
.bind(&provider_request_body_json)
|
||||
.bind(&response_headers_json)
|
||||
.bind(&response_body_json)
|
||||
.bind(&client_response_headers_json)
|
||||
.bind(&client_response_body_json)
|
||||
.bind(&request_metadata_json)
|
||||
.bind(usage.finalized_at_unix_secs.map(|value| value as f64))
|
||||
.bind(usage.created_at_unix_ms.map(|value| value as f64))
|
||||
.fetch_one(&mut **tx)
|
||||
@@ -826,6 +868,18 @@ fn map_usage_row(row: &sqlx::postgres::PgRow) -> Result<StoredRequestUsageAudit,
|
||||
.map(|value| to_u64(value, "usage.cache_creation_input_tokens"))
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
usage.cache_creation_ephemeral_5m_input_tokens = row
|
||||
.try_get::<Option<i32>, _>("cache_creation_ephemeral_5m_input_tokens")
|
||||
.map_postgres_err()?
|
||||
.map(|value| to_u64(value, "usage.cache_creation_ephemeral_5m_input_tokens"))
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
usage.cache_creation_ephemeral_1h_input_tokens = row
|
||||
.try_get::<Option<i32>, _>("cache_creation_ephemeral_1h_input_tokens")
|
||||
.map_postgres_err()?
|
||||
.map(|value| to_u64(value, "usage.cache_creation_ephemeral_1h_input_tokens"))
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
usage.cache_read_input_tokens = row
|
||||
.try_get::<Option<i32>, _>("cache_read_input_tokens")
|
||||
.map_postgres_err()?
|
||||
@@ -862,6 +916,16 @@ fn to_u64(value: i32, field_name: &str) -> Result<u64, DataLayerError> {
|
||||
.map_err(|_| DataLayerError::UnexpectedValue(format!("invalid {field_name}: {value}")))
|
||||
}
|
||||
|
||||
fn json_bind_text(value: Option<&Value>) -> Result<Option<String>, DataLayerError> {
|
||||
value
|
||||
.map(|value| {
|
||||
serde_json::to_string(value).map_err(|err| {
|
||||
DataLayerError::UnexpectedValue(format!("failed to serialize usage json: {err}"))
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::SqlxUsageReadRepository;
|
||||
@@ -930,6 +994,8 @@ mod tests {
|
||||
output_tokens: Some(20),
|
||||
total_tokens: Some(30),
|
||||
cache_creation_input_tokens: None,
|
||||
cache_creation_ephemeral_5m_input_tokens: None,
|
||||
cache_creation_ephemeral_1h_input_tokens: None,
|
||||
cache_read_input_tokens: None,
|
||||
cache_creation_cost_usd: None,
|
||||
cache_read_cost_usd: None,
|
||||
@@ -980,10 +1046,32 @@ mod tests {
|
||||
assert!(super::LIST_RECENT_USAGE_AUDITS_PREFIX.contains("FROM \"usage\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_sql_uses_json_null_placeholders_for_usage_payload_columns() {
|
||||
assert!(super::LIST_USAGE_AUDITS_PREFIX.contains("NULL::json AS request_headers"));
|
||||
assert!(super::LIST_USAGE_AUDITS_PREFIX.contains("NULL::json AS provider_request_body"));
|
||||
assert!(super::LIST_RECENT_USAGE_AUDITS_PREFIX.contains("NULL::json AS request_headers"));
|
||||
assert!(
|
||||
super::LIST_RECENT_USAGE_AUDITS_PREFIX.contains("NULL::json AS provider_request_body")
|
||||
);
|
||||
assert!(!super::LIST_USAGE_AUDITS_PREFIX.contains("NULL::jsonb"));
|
||||
assert!(!super::LIST_RECENT_USAGE_AUDITS_PREFIX.contains("NULL::jsonb"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_sql_casts_json_payload_bind_parameters_explicitly() {
|
||||
for placeholder in 41..=49 {
|
||||
assert!(
|
||||
super::UPSERT_SQL.contains(format!("${placeholder}::json").as_str()),
|
||||
"missing ::json cast for placeholder ${placeholder}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_sql_insert_values_aligns_request_metadata_and_timestamps() {
|
||||
assert!(super::UPSERT_SQL.contains("\n $46,\n $47,\n CASE"));
|
||||
assert!(super::UPSERT_SQL.contains("WHEN $48 IS NULL THEN NULL"));
|
||||
assert!(super::UPSERT_SQL.contains("TO_TIMESTAMP($49::double precision)"));
|
||||
assert!(super::UPSERT_SQL.contains("\n $48::json,\n $49::json,\n CASE"));
|
||||
assert!(super::UPSERT_SQL.contains("WHEN $50 IS NULL THEN NULL"));
|
||||
assert!(super::UPSERT_SQL.contains("TO_TIMESTAMP($51::double precision)"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::headers::should_skip_upstream_passthrough_header;
|
||||
use super::headers::{
|
||||
should_skip_upstream_complete_passthrough_header, should_skip_upstream_passthrough_header,
|
||||
};
|
||||
use super::snapshot::GatewayProviderTransportSnapshot;
|
||||
|
||||
const DEFAULT_ANTHROPIC_VERSION: &str = "2023-06-01";
|
||||
|
||||
fn collect_passthrough_headers(
|
||||
headers: &http::HeaderMap,
|
||||
extra_headers: &BTreeMap<String, String>,
|
||||
@@ -35,6 +39,38 @@ fn collect_passthrough_headers(
|
||||
out
|
||||
}
|
||||
|
||||
fn collect_complete_passthrough_headers(
|
||||
headers: &http::HeaderMap,
|
||||
extra_headers: &BTreeMap<String, String>,
|
||||
) -> BTreeMap<String, String> {
|
||||
let mut out = BTreeMap::new();
|
||||
for (name, value) in headers.iter() {
|
||||
let Ok(value) = value.to_str() else {
|
||||
continue;
|
||||
};
|
||||
let key = name.as_str().to_ascii_lowercase();
|
||||
if should_skip_upstream_complete_passthrough_header(&key) {
|
||||
continue;
|
||||
}
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
continue;
|
||||
}
|
||||
out.insert(key, value.to_string());
|
||||
}
|
||||
|
||||
for (key, value) in extra_headers {
|
||||
let normalized_key = key.to_ascii_lowercase();
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
continue;
|
||||
}
|
||||
out.insert(normalized_key, value.to_string());
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
pub fn build_passthrough_headers(
|
||||
headers: &http::HeaderMap,
|
||||
extra_headers: &BTreeMap<String, String>,
|
||||
@@ -64,6 +100,76 @@ pub fn build_openai_passthrough_headers(
|
||||
out
|
||||
}
|
||||
|
||||
pub fn build_complete_passthrough_headers(
|
||||
headers: &http::HeaderMap,
|
||||
extra_headers: &BTreeMap<String, String>,
|
||||
content_type: Option<&str>,
|
||||
) -> BTreeMap<String, String> {
|
||||
let mut out = collect_complete_passthrough_headers(headers, extra_headers);
|
||||
out.entry("content-type".to_string()).or_insert_with(|| {
|
||||
content_type
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or("application/json")
|
||||
.trim()
|
||||
.to_string()
|
||||
});
|
||||
out.remove("content-length");
|
||||
out
|
||||
}
|
||||
|
||||
pub fn build_complete_passthrough_headers_with_auth(
|
||||
headers: &http::HeaderMap,
|
||||
auth_header: &str,
|
||||
auth_value: &str,
|
||||
extra_headers: &BTreeMap<String, String>,
|
||||
content_type: Option<&str>,
|
||||
) -> BTreeMap<String, String> {
|
||||
let mut out = build_complete_passthrough_headers(headers, extra_headers, content_type);
|
||||
ensure_upstream_auth_header(&mut out, auth_header, auth_value);
|
||||
out
|
||||
}
|
||||
|
||||
pub fn build_claude_passthrough_headers(
|
||||
headers: &http::HeaderMap,
|
||||
auth_header: &str,
|
||||
auth_value: &str,
|
||||
extra_headers: &BTreeMap<String, String>,
|
||||
content_type: Option<&str>,
|
||||
) -> BTreeMap<String, String> {
|
||||
let mut out = build_openai_passthrough_headers(
|
||||
headers,
|
||||
auth_header,
|
||||
auth_value,
|
||||
extra_headers,
|
||||
content_type,
|
||||
);
|
||||
|
||||
for (name, value) in headers.iter() {
|
||||
let Ok(value) = value.to_str() else {
|
||||
continue;
|
||||
};
|
||||
let key = name.as_str().to_ascii_lowercase();
|
||||
let value = value.trim();
|
||||
if value.is_empty() || !should_restore_claude_passthrough_header(&key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if key == "anthropic-beta" {
|
||||
let merged = merge_comma_header_values(out.get(&key).map(String::as_str), Some(value));
|
||||
if let Some(merged) = merged {
|
||||
out.insert(key, merged);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
out.entry(key).or_insert_with(|| value.to_string());
|
||||
}
|
||||
|
||||
out.entry("anthropic-version".to_string())
|
||||
.or_insert_with(|| DEFAULT_ANTHROPIC_VERSION.to_string());
|
||||
out
|
||||
}
|
||||
|
||||
pub fn build_passthrough_headers_with_auth(
|
||||
headers: &http::HeaderMap,
|
||||
auth_header: &str,
|
||||
@@ -96,6 +202,30 @@ pub fn ensure_upstream_auth_header(
|
||||
}
|
||||
}
|
||||
|
||||
fn should_restore_claude_passthrough_header(name: &str) -> bool {
|
||||
name.starts_with("anthropic-") || name.starts_with("x-stainless-") || name == "x-app"
|
||||
}
|
||||
|
||||
fn merge_comma_header_values(left: Option<&str>, right: Option<&str>) -> Option<String> {
|
||||
let mut merged = Vec::new();
|
||||
|
||||
for raw in [left, right].into_iter().flatten() {
|
||||
for token in raw.split(',') {
|
||||
let token = token.trim();
|
||||
if token.is_empty() || merged.iter().any(|existing: &String| existing == token) {
|
||||
continue;
|
||||
}
|
||||
merged.push(token.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if merged.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(merged.join(","))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_local_openai_chat_auth(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
) -> Option<(String, String)> {
|
||||
@@ -142,3 +272,112 @@ pub fn resolve_local_gemini_auth(
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{build_claude_passthrough_headers, build_complete_passthrough_headers_with_auth};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[test]
|
||||
fn claude_passthrough_headers_restore_stripped_anthropic_headers() {
|
||||
let mut headers = http::HeaderMap::new();
|
||||
headers.insert(
|
||||
"anthropic-beta",
|
||||
http::HeaderValue::from_static("prompt-caching-2024-07-31,context-1m-2025-08-07"),
|
||||
);
|
||||
headers.insert(
|
||||
"x-stainless-runtime-version",
|
||||
http::HeaderValue::from_static("v22.14.0"),
|
||||
);
|
||||
headers.insert("x-app", http::HeaderValue::from_static("cli"));
|
||||
|
||||
let built = build_claude_passthrough_headers(
|
||||
&headers,
|
||||
"x-api-key",
|
||||
"sk-upstream-claude",
|
||||
&BTreeMap::from([("anthropic-beta".to_string(), "custom-beta".to_string())]),
|
||||
Some("application/json"),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
built.get("anthropic-version").map(String::as_str),
|
||||
Some("2023-06-01")
|
||||
);
|
||||
assert_eq!(
|
||||
built.get("anthropic-beta").map(String::as_str),
|
||||
Some("custom-beta,prompt-caching-2024-07-31,context-1m-2025-08-07")
|
||||
);
|
||||
assert_eq!(
|
||||
built.get("x-stainless-runtime-version").map(String::as_str),
|
||||
Some("v22.14.0")
|
||||
);
|
||||
assert_eq!(built.get("x-app").map(String::as_str), Some("cli"));
|
||||
assert_eq!(
|
||||
built.get("x-api-key").map(String::as_str),
|
||||
Some("sk-upstream-claude")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_passthrough_headers_preserve_explicit_anthropic_version_override() {
|
||||
let mut headers = http::HeaderMap::new();
|
||||
headers.insert(
|
||||
"anthropic-version",
|
||||
http::HeaderValue::from_static("2024-01-01"),
|
||||
);
|
||||
|
||||
let built = build_claude_passthrough_headers(
|
||||
&headers,
|
||||
"authorization",
|
||||
"Bearer upstream-token",
|
||||
&BTreeMap::new(),
|
||||
Some("application/json"),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
built.get("anthropic-version").map(String::as_str),
|
||||
Some("2024-01-01")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_passthrough_headers_preserve_business_headers() {
|
||||
let mut headers = http::HeaderMap::new();
|
||||
headers.insert(
|
||||
"anthropic-beta",
|
||||
http::HeaderValue::from_static("prompt-caching-2024-07-31"),
|
||||
);
|
||||
headers.insert(
|
||||
"x-stainless-runtime-version",
|
||||
http::HeaderValue::from_static("v24.0.0"),
|
||||
);
|
||||
headers.insert("x-app", http::HeaderValue::from_static("cli"));
|
||||
headers.insert(
|
||||
"authorization",
|
||||
http::HeaderValue::from_static("Bearer client-token"),
|
||||
);
|
||||
|
||||
let built = build_complete_passthrough_headers_with_auth(
|
||||
&headers,
|
||||
"x-api-key",
|
||||
"sk-upstream",
|
||||
&BTreeMap::new(),
|
||||
Some("application/json"),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
built.get("anthropic-beta").map(String::as_str),
|
||||
Some("prompt-caching-2024-07-31")
|
||||
);
|
||||
assert_eq!(
|
||||
built.get("x-stainless-runtime-version").map(String::as_str),
|
||||
Some("v24.0.0")
|
||||
);
|
||||
assert_eq!(built.get("x-app").map(String::as_str), Some("cli"));
|
||||
assert_eq!(built.get("authorization"), None);
|
||||
assert_eq!(
|
||||
built.get("x-api-key").map(String::as_str),
|
||||
Some("sk-upstream")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,29 @@ pub fn should_skip_upstream_passthrough_header(name: &str) -> bool {
|
||||
) || should_skip_request_header(name)
|
||||
}
|
||||
|
||||
pub(crate) fn should_skip_upstream_complete_passthrough_header(name: &str) -> bool {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
matches!(
|
||||
lower.as_str(),
|
||||
"authorization"
|
||||
| "x-api-key"
|
||||
| "x-goog-api-key"
|
||||
| "host"
|
||||
| "content-length"
|
||||
| "transfer-encoding"
|
||||
| "connection"
|
||||
| "accept-encoding"
|
||||
| "content-encoding"
|
||||
| "x-real-ip"
|
||||
| "x-real-proto"
|
||||
| "x-forwarded-for"
|
||||
| "x-forwarded-proto"
|
||||
| "x-forwarded-scheme"
|
||||
| "x-forwarded-host"
|
||||
| "x-forwarded-port"
|
||||
) || should_skip_request_header(name)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::should_skip_upstream_passthrough_header;
|
||||
|
||||
@@ -258,13 +258,13 @@ CREATE TABLE IF NOT EXISTS proxy_nodes (
|
||||
failed_requests BIGINT NOT NULL DEFAULT 0,
|
||||
dns_failures BIGINT NOT NULL DEFAULT 0,
|
||||
stream_errors BIGINT NOT NULL DEFAULT 0,
|
||||
proxy_metadata JSONB NULL,
|
||||
hardware_info JSONB NULL,
|
||||
proxy_metadata JSON NULL,
|
||||
hardware_info JSON NULL,
|
||||
estimated_max_concurrency INTEGER NULL,
|
||||
tunnel_mode BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
tunnel_connected BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
tunnel_connected_at TIMESTAMPTZ NULL,
|
||||
remote_config JSONB NULL,
|
||||
remote_config JSON NULL,
|
||||
config_version INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
|
||||
@@ -64,6 +64,10 @@ pub struct UsageEventData {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cache_creation_input_tokens: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cache_creation_ephemeral_5m_input_tokens: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cache_creation_ephemeral_1h_input_tokens: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cache_read_input_tokens: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cache_creation_cost_usd: Option<f64>,
|
||||
|
||||
@@ -41,5 +41,6 @@ pub use write::{
|
||||
build_pending_usage_record, build_stream_terminal_usage_event,
|
||||
build_stream_terminal_usage_outcome, build_streaming_usage_record,
|
||||
build_sync_terminal_usage_event, build_sync_terminal_usage_outcome,
|
||||
build_terminal_usage_event_from_outcome, TerminalUsageOutcome, UsageTerminalState,
|
||||
build_terminal_usage_event_from_outcome, build_usage_event_data_seed, TerminalUsageOutcome,
|
||||
UsageTerminalState,
|
||||
};
|
||||
|
||||
@@ -36,6 +36,8 @@ pub fn build_upsert_usage_record_from_event(
|
||||
output_tokens: data.output_tokens,
|
||||
total_tokens: data.total_tokens,
|
||||
cache_creation_input_tokens: data.cache_creation_input_tokens,
|
||||
cache_creation_ephemeral_5m_input_tokens: data.cache_creation_ephemeral_5m_input_tokens,
|
||||
cache_creation_ephemeral_1h_input_tokens: data.cache_creation_ephemeral_1h_input_tokens,
|
||||
cache_read_input_tokens: data.cache_read_input_tokens,
|
||||
cache_creation_cost_usd: data.cache_creation_cost_usd,
|
||||
cache_read_cost_usd: data.cache_read_cost_usd,
|
||||
|
||||
@@ -238,6 +238,25 @@ impl UsageRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn record_terminal_event<T>(&self, data: &T, mut event: UsageEvent)
|
||||
where
|
||||
T: UsageRuntimeAccess,
|
||||
{
|
||||
if !self.is_enabled() {
|
||||
return;
|
||||
}
|
||||
if let Err(err) = data.enrich_usage_event(&mut event).await {
|
||||
warn!(
|
||||
event_name = "usage_terminal_billing_enrichment_failed",
|
||||
log_type = "event",
|
||||
request_id = %event.request_id,
|
||||
error = %err,
|
||||
"usage runtime failed to enrich terminal usage event with billing"
|
||||
);
|
||||
}
|
||||
self.enqueue_or_write_terminal(data, event).await;
|
||||
}
|
||||
|
||||
async fn enqueue_or_write_terminal<T>(&self, data: &T, event: UsageEvent)
|
||||
where
|
||||
T: UsageRuntimeAccess,
|
||||
|
||||
@@ -5,6 +5,8 @@ pub struct StandardizedUsage {
|
||||
pub input_tokens: i64,
|
||||
pub output_tokens: i64,
|
||||
pub cache_creation_tokens: i64,
|
||||
pub cache_creation_ephemeral_5m_tokens: i64,
|
||||
pub cache_creation_ephemeral_1h_tokens: i64,
|
||||
pub cache_read_tokens: i64,
|
||||
pub reasoning_tokens: i64,
|
||||
pub cache_storage_token_hours: f64,
|
||||
@@ -25,6 +27,12 @@ impl StandardizedUsage {
|
||||
"input_tokens" => Some(serde_json::json!(self.input_tokens)),
|
||||
"output_tokens" => Some(serde_json::json!(self.output_tokens)),
|
||||
"cache_creation_tokens" => Some(serde_json::json!(self.cache_creation_tokens)),
|
||||
"cache_creation_ephemeral_5m_tokens" => {
|
||||
Some(serde_json::json!(self.cache_creation_ephemeral_5m_tokens))
|
||||
}
|
||||
"cache_creation_ephemeral_1h_tokens" => {
|
||||
Some(serde_json::json!(self.cache_creation_ephemeral_1h_tokens))
|
||||
}
|
||||
"cache_read_tokens" => Some(serde_json::json!(self.cache_read_tokens)),
|
||||
"reasoning_tokens" => Some(serde_json::json!(self.reasoning_tokens)),
|
||||
"cache_storage_token_hours" => Some(serde_json::json!(self.cache_storage_token_hours)),
|
||||
@@ -40,6 +48,12 @@ impl StandardizedUsage {
|
||||
"input_tokens" => self.input_tokens = as_i64(&value, 0),
|
||||
"output_tokens" => self.output_tokens = as_i64(&value, 0),
|
||||
"cache_creation_tokens" => self.cache_creation_tokens = as_i64(&value, 0),
|
||||
"cache_creation_ephemeral_5m_tokens" => {
|
||||
self.cache_creation_ephemeral_5m_tokens = as_i64(&value, 0)
|
||||
}
|
||||
"cache_creation_ephemeral_1h_tokens" => {
|
||||
self.cache_creation_ephemeral_1h_tokens = as_i64(&value, 0)
|
||||
}
|
||||
"cache_read_tokens" => self.cache_read_tokens = as_i64(&value, 0),
|
||||
"reasoning_tokens" => self.reasoning_tokens = as_i64(&value, 0),
|
||||
"cache_storage_token_hours" => self.cache_storage_token_hours = as_f64(&value, 0.0),
|
||||
@@ -55,6 +69,18 @@ impl StandardizedUsage {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_cache_creation_breakdown(mut self) -> Self {
|
||||
if self.cache_creation_tokens <= 0 {
|
||||
let derived = self
|
||||
.cache_creation_ephemeral_5m_tokens
|
||||
.saturating_add(self.cache_creation_ephemeral_1h_tokens);
|
||||
if derived > 0 {
|
||||
self.cache_creation_tokens = derived;
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
fn as_i64(value: &serde_json::Value, default: i64) -> i64 {
|
||||
|
||||
@@ -26,23 +26,13 @@ impl UsageMapper {
|
||||
}
|
||||
}
|
||||
|
||||
usage
|
||||
usage.normalize_cache_creation_breakdown()
|
||||
}
|
||||
|
||||
pub fn map_from_response(response: &serde_json::Value, api_format: &str) -> StandardizedUsage {
|
||||
let family = api_family(api_format);
|
||||
let usage_value = if family == "gemini" {
|
||||
response
|
||||
.get("usageMetadata")
|
||||
.or_else(|| {
|
||||
response
|
||||
.get("candidates")
|
||||
.and_then(|v| v.get(0))
|
||||
.and_then(|v| v.get("usageMetadata"))
|
||||
})
|
||||
.unwrap_or(&serde_json::Value::Null)
|
||||
} else {
|
||||
response.get("usage").unwrap_or(&serde_json::Value::Null)
|
||||
let Some(usage_value) = resolve_usage_value(response, family.as_str()) else {
|
||||
return StandardizedUsage::new();
|
||||
};
|
||||
Self::map(usage_value, api_format, None)
|
||||
}
|
||||
@@ -74,14 +64,32 @@ fn base_mapping(api_format: &str) -> BTreeMap<String, String> {
|
||||
"openai" => {
|
||||
mapping.insert("prompt_tokens".to_string(), "input_tokens".to_string());
|
||||
mapping.insert("completion_tokens".to_string(), "output_tokens".to_string());
|
||||
mapping.insert("input_tokens".to_string(), "input_tokens".to_string());
|
||||
mapping.insert("output_tokens".to_string(), "output_tokens".to_string());
|
||||
mapping.insert(
|
||||
"prompt_tokens_details.cached_tokens".to_string(),
|
||||
"cache_read_tokens".to_string(),
|
||||
);
|
||||
mapping.insert(
|
||||
"input_tokens_details.cached_tokens".to_string(),
|
||||
"cache_read_tokens".to_string(),
|
||||
);
|
||||
mapping.insert(
|
||||
"prompt_tokens_details.cached_creation_tokens".to_string(),
|
||||
"cache_creation_tokens".to_string(),
|
||||
);
|
||||
mapping.insert(
|
||||
"input_tokens_details.cached_creation_tokens".to_string(),
|
||||
"cache_creation_tokens".to_string(),
|
||||
);
|
||||
mapping.insert(
|
||||
"completion_tokens_details.reasoning_tokens".to_string(),
|
||||
"reasoning_tokens".to_string(),
|
||||
);
|
||||
mapping.insert(
|
||||
"output_tokens_details.reasoning_tokens".to_string(),
|
||||
"reasoning_tokens".to_string(),
|
||||
);
|
||||
}
|
||||
"gemini" => {
|
||||
mapping.insert("promptTokenCount".to_string(), "input_tokens".to_string());
|
||||
@@ -106,6 +114,26 @@ fn base_mapping(api_format: &str) -> BTreeMap<String, String> {
|
||||
"cache_read_tokens".to_string(),
|
||||
);
|
||||
}
|
||||
"claude" | "anthropic" => {
|
||||
mapping.insert("input_tokens".to_string(), "input_tokens".to_string());
|
||||
mapping.insert("output_tokens".to_string(), "output_tokens".to_string());
|
||||
mapping.insert(
|
||||
"cache_creation_input_tokens".to_string(),
|
||||
"cache_creation_tokens".to_string(),
|
||||
);
|
||||
mapping.insert(
|
||||
"cache_creation.ephemeral_5m_input_tokens".to_string(),
|
||||
"cache_creation_ephemeral_5m_tokens".to_string(),
|
||||
);
|
||||
mapping.insert(
|
||||
"cache_creation.ephemeral_1h_input_tokens".to_string(),
|
||||
"cache_creation_ephemeral_1h_tokens".to_string(),
|
||||
);
|
||||
mapping.insert(
|
||||
"cache_read_input_tokens".to_string(),
|
||||
"cache_read_tokens".to_string(),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
mapping.insert("input_tokens".to_string(), "input_tokens".to_string());
|
||||
mapping.insert("output_tokens".to_string(), "output_tokens".to_string());
|
||||
@@ -130,6 +158,47 @@ fn get_nested_value<'a>(value: &'a serde_json::Value, path: &str) -> Option<&'a
|
||||
Some(current)
|
||||
}
|
||||
|
||||
fn resolve_usage_value<'a>(
|
||||
response: &'a serde_json::Value,
|
||||
family: &str,
|
||||
) -> Option<&'a serde_json::Value> {
|
||||
match family {
|
||||
"gemini" => {
|
||||
if let Some(usage) = response.get("usageMetadata") {
|
||||
return Some(usage);
|
||||
}
|
||||
if let Some(usage) = response
|
||||
.get("candidates")
|
||||
.and_then(|value| value.get(0))
|
||||
.and_then(|value| value.get("usageMetadata"))
|
||||
{
|
||||
return Some(usage);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(usage) = response.get("usage") {
|
||||
return Some(usage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(nested) = response.get("response") {
|
||||
if let Some(usage) = resolve_usage_value(nested, family) {
|
||||
return Some(usage);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(chunks) = response.get("chunks").and_then(serde_json::Value::as_array) {
|
||||
for chunk in chunks.iter().rev() {
|
||||
if let Some(usage) = resolve_usage_value(chunk, family) {
|
||||
return Some(usage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{map_usage, map_usage_from_response};
|
||||
@@ -140,7 +209,10 @@ mod tests {
|
||||
&serde_json::json!({
|
||||
"prompt_tokens": 12,
|
||||
"completion_tokens": 8,
|
||||
"prompt_tokens_details": { "cached_tokens": 2 },
|
||||
"prompt_tokens_details": {
|
||||
"cached_tokens": 2,
|
||||
"cached_creation_tokens": 1
|
||||
},
|
||||
"completion_tokens_details": { "reasoning_tokens": 3 }
|
||||
}),
|
||||
"openai:chat",
|
||||
@@ -148,10 +220,81 @@ mod tests {
|
||||
|
||||
assert_eq!(usage.input_tokens, 12);
|
||||
assert_eq!(usage.output_tokens, 8);
|
||||
assert_eq!(usage.cache_creation_tokens, 1);
|
||||
assert_eq!(usage.cache_read_tokens, 2);
|
||||
assert_eq!(usage.reasoning_tokens, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_openai_responses_usage_from_response() {
|
||||
let usage = map_usage_from_response(
|
||||
&serde_json::json!({
|
||||
"usage": {
|
||||
"input_tokens": 14,
|
||||
"output_tokens": 6,
|
||||
"total_tokens": 20,
|
||||
"input_tokens_details": {
|
||||
"cached_tokens": 3,
|
||||
"cached_creation_tokens": 2
|
||||
},
|
||||
"output_tokens_details": {
|
||||
"reasoning_tokens": 1
|
||||
}
|
||||
}
|
||||
}),
|
||||
"openai:cli",
|
||||
);
|
||||
|
||||
assert_eq!(usage.input_tokens, 14);
|
||||
assert_eq!(usage.output_tokens, 6);
|
||||
assert_eq!(usage.cache_creation_tokens, 2);
|
||||
assert_eq!(usage.cache_read_tokens, 3);
|
||||
assert_eq!(usage.reasoning_tokens, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_openai_responses_usage_from_stream_chunks() {
|
||||
let usage = map_usage_from_response(
|
||||
&serde_json::json!({
|
||||
"chunks": [
|
||||
{
|
||||
"type": "response.created",
|
||||
"response": {
|
||||
"id": "resp_123",
|
||||
"object": "response"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "response.completed",
|
||||
"response": {
|
||||
"id": "resp_123",
|
||||
"object": "response",
|
||||
"usage": {
|
||||
"input_tokens": 9,
|
||||
"output_tokens": 4,
|
||||
"total_tokens": 13,
|
||||
"input_tokens_details": {
|
||||
"cached_tokens": 5,
|
||||
"cached_creation_tokens": 2
|
||||
},
|
||||
"output_tokens_details": {
|
||||
"reasoning_tokens": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}),
|
||||
"openai:cli",
|
||||
);
|
||||
|
||||
assert_eq!(usage.input_tokens, 9);
|
||||
assert_eq!(usage.output_tokens, 4);
|
||||
assert_eq!(usage.cache_creation_tokens, 2);
|
||||
assert_eq!(usage.cache_read_tokens, 5);
|
||||
assert_eq!(usage.reasoning_tokens, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_claude_usage() {
|
||||
let usage = map_usage(
|
||||
@@ -170,6 +313,30 @@ mod tests {
|
||||
assert_eq!(usage.cache_read_tokens, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_claude_usage_with_ephemeral_cache_breakdown() {
|
||||
let usage = map_usage(
|
||||
&serde_json::json!({
|
||||
"input_tokens": 1,
|
||||
"output_tokens": 8,
|
||||
"cache_creation": {
|
||||
"ephemeral_1h_input_tokens": 0,
|
||||
"ephemeral_5m_input_tokens": 5191
|
||||
},
|
||||
"cache_creation_input_tokens": 5191,
|
||||
"cache_read_input_tokens": 97634
|
||||
}),
|
||||
"claude:chat",
|
||||
);
|
||||
|
||||
assert_eq!(usage.input_tokens, 1);
|
||||
assert_eq!(usage.output_tokens, 8);
|
||||
assert_eq!(usage.cache_creation_tokens, 5191);
|
||||
assert_eq!(usage.cache_creation_ephemeral_5m_tokens, 5191);
|
||||
assert_eq!(usage.cache_creation_ephemeral_1h_tokens, 0);
|
||||
assert_eq!(usage.cache_read_tokens, 97634);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_gemini_usage_from_response() {
|
||||
let usage = map_usage_from_response(
|
||||
|
||||
@@ -409,6 +409,8 @@ fn build_upsert_usage_record(
|
||||
output_tokens: data.output_tokens,
|
||||
total_tokens: data.total_tokens,
|
||||
cache_creation_input_tokens: data.cache_creation_input_tokens,
|
||||
cache_creation_ephemeral_5m_input_tokens: data.cache_creation_ephemeral_5m_input_tokens,
|
||||
cache_creation_ephemeral_1h_input_tokens: data.cache_creation_ephemeral_1h_input_tokens,
|
||||
cache_read_input_tokens: data.cache_read_input_tokens,
|
||||
cache_creation_cost_usd: data.cache_creation_cost_usd,
|
||||
cache_read_cost_usd: data.cache_read_cost_usd,
|
||||
@@ -437,7 +439,10 @@ fn build_upsert_usage_record(
|
||||
})
|
||||
}
|
||||
|
||||
fn build_base_usage_data(plan: &ExecutionPlan, report_context: Option<&Value>) -> UsageEventData {
|
||||
pub fn build_usage_event_data_seed(
|
||||
plan: &ExecutionPlan,
|
||||
report_context: Option<&Value>,
|
||||
) -> UsageEventData {
|
||||
let context = report_context.and_then(Value::as_object);
|
||||
let api_format = context_string(context, "client_api_format")
|
||||
.or_else(|| non_empty_string(Some(plan.client_api_format.clone())));
|
||||
@@ -497,6 +502,10 @@ fn build_base_usage_data(plan: &ExecutionPlan, report_context: Option<&Value>) -
|
||||
}
|
||||
}
|
||||
|
||||
fn build_base_usage_data(plan: &ExecutionPlan, report_context: Option<&Value>) -> UsageEventData {
|
||||
build_usage_event_data_seed(plan, report_context)
|
||||
}
|
||||
|
||||
fn merge_usage_data(base: UsageEventData, override_data: UsageEventData) -> UsageEventData {
|
||||
UsageEventData {
|
||||
user_id: override_data.user_id.or(base.user_id),
|
||||
@@ -544,6 +553,12 @@ fn merge_usage_data(base: UsageEventData, override_data: UsageEventData) -> Usag
|
||||
cache_creation_input_tokens: override_data
|
||||
.cache_creation_input_tokens
|
||||
.or(base.cache_creation_input_tokens),
|
||||
cache_creation_ephemeral_5m_input_tokens: override_data
|
||||
.cache_creation_ephemeral_5m_input_tokens
|
||||
.or(base.cache_creation_ephemeral_5m_input_tokens),
|
||||
cache_creation_ephemeral_1h_input_tokens: override_data
|
||||
.cache_creation_ephemeral_1h_input_tokens
|
||||
.or(base.cache_creation_ephemeral_1h_input_tokens),
|
||||
cache_read_input_tokens: override_data
|
||||
.cache_read_input_tokens
|
||||
.or(base.cache_read_input_tokens),
|
||||
@@ -668,6 +683,14 @@ fn apply_standardized_usage(
|
||||
if usage.cache_creation_tokens > 0 {
|
||||
data.cache_creation_input_tokens = Some(usage.cache_creation_tokens as u64);
|
||||
}
|
||||
if usage.cache_creation_ephemeral_5m_tokens > 0 {
|
||||
data.cache_creation_ephemeral_5m_input_tokens =
|
||||
Some(usage.cache_creation_ephemeral_5m_tokens as u64);
|
||||
}
|
||||
if usage.cache_creation_ephemeral_1h_tokens > 0 {
|
||||
data.cache_creation_ephemeral_1h_input_tokens =
|
||||
Some(usage.cache_creation_ephemeral_1h_tokens as u64);
|
||||
}
|
||||
if usage.cache_read_tokens > 0 {
|
||||
data.cache_read_input_tokens = Some(usage.cache_read_tokens as u64);
|
||||
}
|
||||
@@ -1167,7 +1190,7 @@ mod tests {
|
||||
"event: response.output_text.delta\n",
|
||||
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"Hello from CLI stream\"}\n\n",
|
||||
"event: response.completed\n",
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_123\",\"object\":\"response\",\"model\":\"gpt-5.4\",\"status\":\"completed\",\"output\":[{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello from CLI stream\"}]}],\"usage\":{\"input_tokens\":3,\"output_tokens\":5,\"total_tokens\":8}}}\n\n",
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_123\",\"object\":\"response\",\"model\":\"gpt-5.4\",\"status\":\"completed\",\"output\":[{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello from CLI stream\"}]}],\"usage\":{\"input_tokens\":3,\"input_tokens_details\":{\"cached_tokens\":2,\"cached_creation_tokens\":1},\"output_tokens\":5,\"output_tokens_details\":{\"reasoning_tokens\":1},\"total_tokens\":8}}}\n\n",
|
||||
"data: [DONE]\n",
|
||||
);
|
||||
let payload = GatewayStreamReportRequest {
|
||||
@@ -1191,6 +1214,8 @@ mod tests {
|
||||
assert_eq!(event.data.input_tokens, Some(3));
|
||||
assert_eq!(event.data.output_tokens, Some(5));
|
||||
assert_eq!(event.data.total_tokens, Some(8));
|
||||
assert_eq!(event.data.cache_creation_input_tokens, Some(1));
|
||||
assert_eq!(event.data.cache_read_input_tokens, Some(2));
|
||||
assert_eq!(
|
||||
event.data.response_body,
|
||||
Some(json!({
|
||||
@@ -1229,7 +1254,14 @@ mod tests {
|
||||
],
|
||||
"usage": {
|
||||
"input_tokens": 3,
|
||||
"input_tokens_details": {
|
||||
"cached_tokens": 2,
|
||||
"cached_creation_tokens": 1
|
||||
},
|
||||
"output_tokens": 5,
|
||||
"output_tokens_details": {
|
||||
"reasoning_tokens": 1
|
||||
},
|
||||
"total_tokens": 8
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +128,7 @@ export interface RequestDetail {
|
||||
}
|
||||
provider: string
|
||||
api_format?: string
|
||||
endpoint_api_format?: string
|
||||
model: string
|
||||
target_model?: string | null // 映射后的目标模型名
|
||||
tokens: {
|
||||
@@ -142,6 +143,7 @@ export interface RequestDetail {
|
||||
}
|
||||
// Additional token fields
|
||||
input_tokens?: number
|
||||
effective_input_tokens?: number
|
||||
output_tokens?: number
|
||||
total_tokens?: number
|
||||
cache_creation_input_tokens?: number
|
||||
|
||||
@@ -51,6 +51,7 @@ export interface UsageRecordDetail {
|
||||
provider: string
|
||||
model: string
|
||||
input_tokens: number
|
||||
effective_input_tokens?: number
|
||||
output_tokens: number
|
||||
total_tokens: number
|
||||
cost: number // 官方费率
|
||||
@@ -80,9 +81,12 @@ export interface ModelSummary {
|
||||
model: string
|
||||
requests: number
|
||||
input_tokens: number
|
||||
effective_input_tokens?: number
|
||||
output_tokens: number
|
||||
total_tokens: number
|
||||
cache_read_tokens?: number
|
||||
cache_creation_tokens?: number
|
||||
total_input_context?: number
|
||||
cache_hit_rate?: number
|
||||
total_cost_usd: number
|
||||
actual_total_cost_usd?: number // 倍率消耗(仅管理员可见)
|
||||
@@ -92,8 +96,12 @@ export interface ModelSummary {
|
||||
export interface ProviderSummary {
|
||||
provider: string
|
||||
requests: number
|
||||
effective_input_tokens?: number
|
||||
total_tokens: number
|
||||
output_tokens?: number
|
||||
cache_read_tokens?: number
|
||||
cache_creation_tokens?: number
|
||||
total_input_context?: number
|
||||
cache_hit_rate?: number
|
||||
total_cost_usd: number
|
||||
success_rate: number | null
|
||||
@@ -104,8 +112,12 @@ export interface ProviderSummary {
|
||||
export interface ApiFormatSummary {
|
||||
api_format: string
|
||||
request_count: number
|
||||
effective_input_tokens?: number
|
||||
total_tokens: number
|
||||
output_tokens?: number
|
||||
cache_read_tokens: number
|
||||
cache_creation_tokens?: number
|
||||
total_input_context?: number
|
||||
cache_hit_rate: number
|
||||
total_cost_usd: number
|
||||
avg_response_time_ms: number
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface UsageRecord {
|
||||
provider_name?: string
|
||||
model: string
|
||||
input_tokens: number
|
||||
effective_input_tokens?: number
|
||||
output_tokens: number
|
||||
cache_creation_input_tokens?: number
|
||||
cache_read_input_tokens?: number
|
||||
@@ -38,6 +39,10 @@ export interface UsageByModel {
|
||||
model: string
|
||||
request_count: number
|
||||
total_tokens: number
|
||||
effective_input_tokens?: number
|
||||
total_input_context?: number
|
||||
output_tokens?: number
|
||||
cache_creation_tokens?: number
|
||||
total_cost: number
|
||||
avg_response_time?: number
|
||||
cache_read_tokens?: number
|
||||
@@ -58,6 +63,10 @@ export interface UsageByProvider {
|
||||
provider: string
|
||||
request_count: number
|
||||
total_tokens: number
|
||||
effective_input_tokens?: number
|
||||
total_input_context?: number
|
||||
output_tokens?: number
|
||||
cache_creation_tokens?: number
|
||||
total_cost: number
|
||||
actual_cost: number
|
||||
avg_response_time_ms: number
|
||||
@@ -71,6 +80,10 @@ export interface UsageByApiFormat {
|
||||
api_format: string
|
||||
request_count: number
|
||||
total_tokens: number
|
||||
effective_input_tokens?: number
|
||||
total_input_context?: number
|
||||
output_tokens?: number
|
||||
cache_creation_tokens?: number
|
||||
total_cost: number
|
||||
actual_cost: number
|
||||
avg_response_time_ms: number
|
||||
@@ -276,6 +289,7 @@ export const usageApi = {
|
||||
id: string
|
||||
status: 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
|
||||
input_tokens: number
|
||||
effective_input_tokens?: number | null
|
||||
output_tokens: number
|
||||
cache_creation_input_tokens?: number | null
|
||||
cache_read_input_tokens?: number | null
|
||||
|
||||
@@ -194,7 +194,7 @@
|
||||
<!-- 阶梯标题 -->
|
||||
<div class="text-xs text-muted-foreground flex items-center gap-2 flex-wrap">
|
||||
<span class="font-medium text-foreground">Token 计费</span>
|
||||
<span class="text-muted-foreground/60">(输入 {{ formatNumber(detail.tokens?.input || detail.input_tokens || 0) }} + 缓存创建 {{ cacheCreationSummaryText }} + 缓存读取 {{ formatNumber(detail.cache_read_input_tokens || 0) }})</span>
|
||||
<span class="text-muted-foreground/60">(输入 {{ formatNumber(displayInputTokens) }} + 缓存创建 {{ cacheCreationSummaryText }} + 缓存读取 {{ formatNumber(detail.cache_read_input_tokens || 0) }})</span>
|
||||
<Badge
|
||||
v-if="displayTiers.length > 1"
|
||||
variant="outline"
|
||||
@@ -260,7 +260,7 @@
|
||||
<div class="flex items-center">
|
||||
<div class="flex items-center flex-1">
|
||||
<span class="text-xs text-muted-foreground w-[56px]">输入</span>
|
||||
<span class="text-sm font-semibold font-mono flex-1 text-center">{{ detail.tokens?.input || detail.input_tokens || 0 }}</span>
|
||||
<span class="text-sm font-semibold font-mono flex-1 text-center">{{ displayInputTokens }}</span>
|
||||
<span class="text-xs font-mono">${{ (detail.cost?.input || detail.input_cost || 0).toFixed(6) }}</span>
|
||||
</div>
|
||||
<Separator
|
||||
@@ -701,6 +701,7 @@ import { dashboardApi, type RequestDetail } from '@/api/dashboard'
|
||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
import { formatShortRequestId } from '@/utils/format'
|
||||
import { log } from '@/utils/logger'
|
||||
import { getEffectiveInputTokens } from '../token-normalization'
|
||||
|
||||
// 子组件
|
||||
import RequestHeadersContent from './RequestDetailDrawer/RequestHeadersContent.vue'
|
||||
@@ -774,6 +775,16 @@ let timelineMountTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const fullRequestId = computed(() => detail.value?.request_id || detail.value?.id || '-')
|
||||
const displayRequestId = computed(() => formatShortRequestId(fullRequestId.value))
|
||||
const displayInputTokens = computed(() => {
|
||||
if (!detail.value) return 0
|
||||
return getEffectiveInputTokens({
|
||||
effective_input_tokens: detail.value.effective_input_tokens,
|
||||
input_tokens: detail.value.input_tokens ?? detail.value.tokens?.input,
|
||||
cache_read_input_tokens: detail.value.cache_read_input_tokens,
|
||||
api_format: detail.value.api_format,
|
||||
endpoint_api_format: detail.value.endpoint_api_format,
|
||||
})
|
||||
})
|
||||
|
||||
// 监听标签页切换
|
||||
watch(activeTab, (newTab) => {
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
<div class="flex flex-col items-end text-xs gap-0.5 whitespace-nowrap">
|
||||
<span>{{ formatTokens(item.total_input_context || 0) }} / {{ formatTokens(item.output_tokens || 0) }}</span>
|
||||
<span>{{ formatTokens(item.effective_input_tokens ?? item.total_input_context ?? 0) }} / {{ formatTokens(item.output_tokens || 0) }}</span>
|
||||
<span class="text-muted-foreground">{{ formatTokens((item.cache_read_tokens || 0) + (item.cache_creation_tokens || 0)) }}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
<div class="flex flex-col items-end text-xs gap-0.5 whitespace-nowrap">
|
||||
<span>{{ formatTokens(model.total_input_context || 0) }} / {{ formatTokens(model.output_tokens || 0) }}</span>
|
||||
<span>{{ formatTokens(model.effective_input_tokens ?? model.total_input_context ?? 0) }} / {{ formatTokens(model.output_tokens || 0) }}</span>
|
||||
<span class="text-muted-foreground">{{ formatTokens(model.cache_read_tokens || 0) }}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
</TableCell>
|
||||
<TableCell class="text-right py-2 px-2">
|
||||
<div class="flex flex-col items-end text-xs gap-0.5 whitespace-nowrap">
|
||||
<span>{{ formatTokens(provider.totalInputContext || 0) }} / {{ formatTokens(provider.outputTokens || 0) }}</span>
|
||||
<span>{{ formatTokens(provider.effectiveInputTokens ?? provider.totalInputContext ?? 0) }} / {{ formatTokens(provider.outputTokens || 0) }}</span>
|
||||
<span class="text-muted-foreground">{{ formatTokens((provider.cacheReadTokens || 0) + (provider.cacheCreationTokens || 0)) }}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
@@ -273,7 +273,7 @@
|
||||
>-</span>
|
||||
<span class="text-muted-foreground/50">|</span>
|
||||
<!-- Tokens -->
|
||||
<span>{{ formatTokens(record.input_tokens || 0) }}/{{ formatTokens(record.output_tokens || 0) }}</span>
|
||||
<span>{{ formatTokens(getRecordEffectiveInputTokens(record)) }}/{{ formatTokens(record.output_tokens || 0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -552,7 +552,7 @@
|
||||
<TableCell class="text-right py-4 w-[140px]">
|
||||
<div class="flex flex-col items-end text-xs gap-0.5">
|
||||
<div class="flex items-center gap-1">
|
||||
<span>{{ formatTokens(record.input_tokens || 0) }}</span>
|
||||
<span>{{ formatTokens(getRecordEffectiveInputTokens(record)) }}</span>
|
||||
<span class="text-muted-foreground">/</span>
|
||||
<span>{{ formatTokens(record.output_tokens || 0) }}</span>
|
||||
</div>
|
||||
@@ -677,6 +677,7 @@ import {
|
||||
import { RefreshCcw, Search } from 'lucide-vue-next'
|
||||
import { formatTokens, formatCurrency } from '@/utils/format'
|
||||
import { formatDateTime } from '../composables'
|
||||
import { getEffectiveInputTokens } from '../token-normalization'
|
||||
import { useRowClick } from '@/composables/useRowClick'
|
||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
import type { DateRangeParams, UsageRecord } from '../types'
|
||||
@@ -777,6 +778,10 @@ function handleRowClick(event: MouseEvent, id: string) {
|
||||
emit('showDetail', id)
|
||||
}
|
||||
|
||||
function getRecordEffectiveInputTokens(record: UsageRecord): number {
|
||||
return getEffectiveInputTokens(record)
|
||||
}
|
||||
|
||||
// useDebounceFn 自动处理清理,无需 onUnmounted
|
||||
|
||||
// 判断是否应该显示格式转换信息
|
||||
|
||||
@@ -110,6 +110,7 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
model: item.model,
|
||||
request_count: item.request_count || 0,
|
||||
total_tokens: item.total_tokens || 0,
|
||||
effective_input_tokens: typeof raw.effective_input_tokens === 'number' ? raw.effective_input_tokens : 0,
|
||||
total_input_context: typeof raw.total_input_context === 'number' ? raw.total_input_context : 0,
|
||||
output_tokens: typeof raw.output_tokens === 'number' ? raw.output_tokens : 0,
|
||||
cache_read_tokens: typeof raw.cache_read_tokens === 'number' ? raw.cache_read_tokens : 0,
|
||||
@@ -129,6 +130,7 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
provider: item.provider,
|
||||
requests: item.request_count,
|
||||
totalTokens: item.total_tokens || 0,
|
||||
effectiveInputTokens: item.effective_input_tokens || 0,
|
||||
totalInputContext: item.total_input_context || 0,
|
||||
outputTokens: item.output_tokens || 0,
|
||||
cacheReadTokens: item.cache_read_tokens || 0,
|
||||
@@ -151,6 +153,7 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
api_format: item.api_format,
|
||||
request_count: item.request_count || 0,
|
||||
total_tokens: item.total_tokens || 0,
|
||||
effective_input_tokens: item.effective_input_tokens || 0,
|
||||
total_input_context: item.total_input_context || 0,
|
||||
output_tokens: item.output_tokens || 0,
|
||||
cache_read_tokens: item.cache_read_tokens || 0,
|
||||
@@ -188,6 +191,7 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
model: item.model,
|
||||
request_count: item.requests || 0,
|
||||
total_tokens: item.total_tokens || 0,
|
||||
effective_input_tokens: item.effective_input_tokens || 0,
|
||||
total_input_context: item.total_input_context || 0,
|
||||
output_tokens: item.output_tokens || 0,
|
||||
cache_read_tokens: item.cache_read_tokens || 0,
|
||||
@@ -201,6 +205,7 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
provider: item.provider,
|
||||
requests: item.requests || 0,
|
||||
totalTokens: item.total_tokens || 0,
|
||||
effectiveInputTokens: item.effective_input_tokens || 0,
|
||||
totalInputContext: item.total_input_context || 0,
|
||||
outputTokens: item.output_tokens || 0,
|
||||
cacheReadTokens: item.cache_read_tokens || 0,
|
||||
@@ -234,6 +239,7 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
api_format: item.api_format,
|
||||
request_count: item.request_count || 0,
|
||||
total_tokens: item.total_tokens || 0,
|
||||
effective_input_tokens: item.effective_input_tokens || 0,
|
||||
total_input_context: item.total_input_context || 0,
|
||||
output_tokens: item.output_tokens || 0,
|
||||
cache_read_tokens: item.cache_read_tokens || 0,
|
||||
@@ -384,6 +390,7 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
status: mergedStatus,
|
||||
provider: protectProvider ? existing.provider : (record.provider || existing.provider),
|
||||
input_tokens: existing.input_tokens || record.input_tokens,
|
||||
effective_input_tokens: existing.effective_input_tokens ?? record.effective_input_tokens,
|
||||
output_tokens: existing.output_tokens || record.output_tokens,
|
||||
cache_creation_input_tokens: existing.cache_creation_input_tokens ?? record.cache_creation_input_tokens,
|
||||
cache_read_input_tokens: existing.cache_read_input_tokens ?? record.cache_read_input_tokens,
|
||||
|
||||
40
frontend/src/features/usage/token-normalization.ts
Normal file
40
frontend/src/features/usage/token-normalization.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
type UsageTokenLike = {
|
||||
effective_input_tokens?: number | null
|
||||
input_tokens?: number | null
|
||||
cache_read_input_tokens?: number | null
|
||||
api_format?: string | null
|
||||
endpoint_api_format?: string | null
|
||||
}
|
||||
|
||||
function toNonNegativeNumber(value: number | null | undefined): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? Math.max(value, 0) : 0
|
||||
}
|
||||
|
||||
function apiFamily(apiFormat: string | null | undefined): string {
|
||||
return String(apiFormat || '')
|
||||
.split(':', 1)[0]
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
export function getEffectiveInputTokens(usage: UsageTokenLike): number {
|
||||
const explicit = toNonNegativeNumber(usage.effective_input_tokens)
|
||||
if (explicit > 0) {
|
||||
return explicit
|
||||
}
|
||||
|
||||
const inputTokens = toNonNegativeNumber(usage.input_tokens)
|
||||
const cacheReadTokens = toNonNegativeNumber(usage.cache_read_input_tokens)
|
||||
if (inputTokens === 0 || cacheReadTokens === 0) {
|
||||
return inputTokens
|
||||
}
|
||||
|
||||
switch (apiFamily(usage.endpoint_api_format || usage.api_format)) {
|
||||
case 'openai':
|
||||
case 'gemini':
|
||||
case 'google':
|
||||
return Math.max(inputTokens - cacheReadTokens, 0)
|
||||
default:
|
||||
return inputTokens
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export interface ModelStatsItem {
|
||||
model: string
|
||||
request_count: number
|
||||
total_tokens: number
|
||||
effective_input_tokens?: number
|
||||
total_input_context?: number
|
||||
output_tokens?: number
|
||||
cache_read_tokens?: number
|
||||
@@ -41,6 +42,7 @@ export interface ProviderStatsItem {
|
||||
provider: string
|
||||
requests: number
|
||||
totalTokens: number
|
||||
effectiveInputTokens?: number
|
||||
totalInputContext?: number
|
||||
outputTokens?: number
|
||||
cacheReadTokens?: number
|
||||
@@ -57,6 +59,7 @@ export interface ApiFormatStatsItem {
|
||||
api_format: string
|
||||
request_count: number
|
||||
total_tokens: number
|
||||
effective_input_tokens?: number
|
||||
total_input_context?: number
|
||||
output_tokens?: number
|
||||
cache_read_tokens?: number
|
||||
@@ -92,6 +95,7 @@ export interface UsageRecord {
|
||||
endpoint_api_format?: string // 端点原生格式
|
||||
has_format_conversion?: boolean // 是否发生了格式转换
|
||||
input_tokens: number
|
||||
effective_input_tokens?: number
|
||||
output_tokens: number
|
||||
cache_creation_input_tokens?: number
|
||||
cache_read_input_tokens?: number
|
||||
|
||||
@@ -391,6 +391,7 @@ async function pollActiveRequests() {
|
||||
if (shouldApply) {
|
||||
// 进行中状态也需要持续更新(provider/key/TTFB 可能在 streaming 后才落库)
|
||||
record.input_tokens = update.input_tokens
|
||||
record.effective_input_tokens = update.effective_input_tokens ?? record.effective_input_tokens
|
||||
record.output_tokens = update.output_tokens
|
||||
record.cache_creation_input_tokens = update.cache_creation_input_tokens ?? undefined
|
||||
record.cache_read_input_tokens = update.cache_read_input_tokens ?? undefined
|
||||
|
||||
Reference in New Issue
Block a user