mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +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"
|
name = "aether-admin"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aether-billing",
|
||||||
"aether-contracts",
|
"aether-contracts",
|
||||||
"aether-data",
|
"aether-data",
|
||||||
"aether-data-contracts",
|
"aether-data-contracts",
|
||||||
@@ -113,6 +114,7 @@ dependencies = [
|
|||||||
"sqlx",
|
"sqlx",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tracing",
|
||||||
"url",
|
"url",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ use crate::ai_pipeline::transport::antigravity::{
|
|||||||
classify_local_antigravity_request_support, AntigravityEnvelopeRequestType,
|
classify_local_antigravity_request_support, AntigravityEnvelopeRequestType,
|
||||||
AntigravityRequestEnvelopeSupport, AntigravityRequestSideSupport,
|
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::claude_code::build_claude_code_passthrough_headers;
|
||||||
use crate::ai_pipeline::transport::kiro::{
|
use crate::ai_pipeline::transport::kiro::{
|
||||||
build_kiro_provider_headers, KiroProviderHeadersInput, KIRO_ENVELOPE_NAME,
|
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(),
|
transport.key.fingerprint.as_ref(),
|
||||||
)
|
)
|
||||||
} else if is_vertex {
|
} else if is_vertex {
|
||||||
crate::ai_pipeline::transport::build_passthrough_headers(
|
build_complete_passthrough_headers(
|
||||||
&parts.headers,
|
&parts.headers,
|
||||||
&extra_headers,
|
&extra_headers,
|
||||||
Some("application/json"),
|
Some("application/json"),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
build_openai_passthrough_headers(
|
build_complete_passthrough_headers_with_auth(
|
||||||
&parts.headers,
|
&parts.headers,
|
||||||
auth_header.as_deref().unwrap_or_default(),
|
auth_header.as_deref().unwrap_or_default(),
|
||||||
auth_value.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::common::force_upstream_streaming_for_provider;
|
||||||
use crate::ai_pipeline::planner::standard::apply_codex_openai_cli_special_headers;
|
use crate::ai_pipeline::planner::standard::apply_codex_openai_cli_special_headers;
|
||||||
use crate::ai_pipeline::transport::auth::{
|
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::{
|
use crate::ai_pipeline::transport::{
|
||||||
apply_local_header_rules, resolve_transport_execution_timeouts,
|
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:") {
|
||||||
&parts.headers,
|
build_claude_passthrough_headers(
|
||||||
&auth_header,
|
&parts.headers,
|
||||||
&auth_value,
|
&auth_header,
|
||||||
&BTreeMap::new(),
|
&auth_value,
|
||||||
Some("application/json"),
|
&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(
|
if !apply_local_header_rules(
|
||||||
&mut provider_request_headers,
|
&mut provider_request_headers,
|
||||||
transport.endpoint.header_rules.as_ref(),
|
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]
|
#[test]
|
||||||
fn builds_openai_chat_cross_format_request_body_from_openai_cli_source() {
|
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");
|
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]
|
#[test]
|
||||||
fn strips_metadata_for_codex_openai_cli_requests() {
|
fn strips_metadata_for_codex_openai_cli_requests() {
|
||||||
let body_json = json!({
|
let body_json = json!({
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use crate::ai_pipeline::planner::standard::{
|
|||||||
build_cross_format_openai_chat_upstream_url,
|
build_cross_format_openai_chat_upstream_url,
|
||||||
};
|
};
|
||||||
use crate::ai_pipeline::transport::auth::{
|
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::{
|
use crate::ai_pipeline::transport::{
|
||||||
apply_local_header_rules, resolve_transport_execution_timeouts,
|
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;
|
return None;
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut provider_request_headers = build_openai_passthrough_headers(
|
let mut provider_request_headers = if provider_api_format.starts_with("claude:") {
|
||||||
&parts.headers,
|
build_claude_passthrough_headers(
|
||||||
&auth_header,
|
&parts.headers,
|
||||||
&auth_value,
|
&auth_header,
|
||||||
&BTreeMap::new(),
|
&auth_value,
|
||||||
Some("application/json"),
|
&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(
|
if !apply_local_header_rules(
|
||||||
&mut provider_request_headers,
|
&mut provider_request_headers,
|
||||||
transport.endpoint.header_rules.as_ref(),
|
transport.endpoint.header_rules.as_ref(),
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ use crate::ai_pipeline::planner::standard::{
|
|||||||
build_local_openai_chat_upstream_url,
|
build_local_openai_chat_upstream_url,
|
||||||
};
|
};
|
||||||
use crate::ai_pipeline::transport::auth::{
|
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::policy::supports_local_openai_chat_transport;
|
||||||
use crate::ai_pipeline::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;
|
return None;
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut provider_request_headers = build_openai_passthrough_headers(
|
let mut provider_request_headers = build_complete_passthrough_headers_with_auth(
|
||||||
&parts.headers,
|
&parts.headers,
|
||||||
&auth_header,
|
&auth_header,
|
||||||
&auth_value,
|
&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::apply_local_header_rules;
|
||||||
use crate::ai_pipeline::transport::auth::{
|
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,
|
build_openai_passthrough_headers, ensure_upstream_auth_header, resolve_local_gemini_auth,
|
||||||
resolve_local_standard_auth,
|
resolve_local_standard_auth,
|
||||||
};
|
};
|
||||||
@@ -354,16 +355,35 @@ pub(crate) async fn resolve_local_openai_cli_candidate_payload_parts(
|
|||||||
return None;
|
return None;
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut provider_request_headers = build_openai_passthrough_headers(
|
let extra_headers = antigravity_auth
|
||||||
&parts.headers,
|
.as_ref()
|
||||||
&auth_header,
|
.map(build_antigravity_static_identity_headers)
|
||||||
&auth_value,
|
.unwrap_or_default();
|
||||||
&antigravity_auth
|
let mut provider_request_headers = if same_format {
|
||||||
.as_ref()
|
build_complete_passthrough_headers_with_auth(
|
||||||
.map(build_antigravity_static_identity_headers)
|
&parts.headers,
|
||||||
.unwrap_or_default(),
|
&auth_header,
|
||||||
Some("application/json"),
|
&auth_value,
|
||||||
);
|
&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(
|
if !apply_local_header_rules(
|
||||||
&mut provider_request_headers,
|
&mut provider_request_headers,
|
||||||
transport.endpoint.header_rules.as_ref(),
|
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::provider_adaptation_requires_eventstream_accept;
|
||||||
use crate::ai_pipeline::transport::auth::{
|
use crate::ai_pipeline::transport::auth::{
|
||||||
|
build_claude_passthrough_headers, build_complete_passthrough_headers_with_auth,
|
||||||
build_openai_passthrough_headers, ensure_upstream_auth_header,
|
build_openai_passthrough_headers, ensure_upstream_auth_header,
|
||||||
};
|
};
|
||||||
use crate::ai_pipeline::transport::url::{build_openai_chat_url, build_openai_cli_url};
|
use crate::ai_pipeline::transport::url::{build_openai_chat_url, build_openai_cli_url};
|
||||||
@@ -132,13 +133,31 @@ pub(crate) fn build_openai_chat_stream_plan_from_decision(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let mut provider_request_headers = if payload.provider_request_headers.is_empty() {
|
let mut provider_request_headers = if payload.provider_request_headers.is_empty() {
|
||||||
build_openai_passthrough_headers(
|
if provider_api_format == client_api_format {
|
||||||
&parts.headers,
|
build_complete_passthrough_headers_with_auth(
|
||||||
&auth_header,
|
&parts.headers,
|
||||||
&auth_value,
|
&auth_header,
|
||||||
&payload.extra_headers,
|
&auth_value,
|
||||||
payload.content_type.as_deref(),
|
&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,
|
||||||
|
&auth_value,
|
||||||
|
&payload.extra_headers,
|
||||||
|
payload.content_type.as_deref(),
|
||||||
|
)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
payload.provider_request_headers.clone()
|
payload.provider_request_headers.clone()
|
||||||
};
|
};
|
||||||
@@ -281,6 +300,11 @@ pub(crate) fn build_openai_cli_stream_plan_from_decision(
|
|||||||
} else {
|
} else {
|
||||||
provider_request_headers.insert("accept".to_string(), "text/event-stream".to_string());
|
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 {
|
let plan = ExecutionPlan {
|
||||||
request_id,
|
request_id,
|
||||||
candidate_id: payload.candidate_id.clone(),
|
candidate_id: payload.candidate_id.clone(),
|
||||||
@@ -306,15 +330,276 @@ pub(crate) fn build_openai_cli_stream_plan_from_decision(
|
|||||||
timeouts: payload.timeouts.clone(),
|
timeouts: payload.timeouts.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let report_context = augment_sync_report_context(
|
|
||||||
payload.report_context,
|
|
||||||
&plan.headers,
|
|
||||||
&provider_request_body_value,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
Ok(Some(LocalStreamPlanAndReport {
|
Ok(Some(LocalStreamPlanAndReport {
|
||||||
plan,
|
plan,
|
||||||
report_kind: payload.report_kind,
|
report_kind: payload.report_kind,
|
||||||
report_context,
|
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,
|
LocalSyncPlanAndReport,
|
||||||
};
|
};
|
||||||
use crate::ai_pipeline::transport::auth::{
|
use crate::ai_pipeline::transport::auth::{
|
||||||
|
build_claude_passthrough_headers, build_complete_passthrough_headers_with_auth,
|
||||||
build_openai_passthrough_headers, ensure_upstream_auth_header,
|
build_openai_passthrough_headers, ensure_upstream_auth_header,
|
||||||
};
|
};
|
||||||
use crate::ai_pipeline::transport::url::{build_openai_chat_url, build_openai_cli_url};
|
use crate::ai_pipeline::transport::url::{build_openai_chat_url, build_openai_cli_url};
|
||||||
@@ -132,13 +133,31 @@ pub(crate) fn build_openai_chat_sync_plan_from_decision(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let mut provider_request_headers = if payload.provider_request_headers.is_empty() {
|
let mut provider_request_headers = if payload.provider_request_headers.is_empty() {
|
||||||
build_openai_passthrough_headers(
|
if provider_api_format == client_api_format {
|
||||||
&parts.headers,
|
build_complete_passthrough_headers_with_auth(
|
||||||
&auth_header,
|
&parts.headers,
|
||||||
&auth_value,
|
&auth_header,
|
||||||
&payload.extra_headers,
|
&auth_value,
|
||||||
payload.content_type.as_deref(),
|
&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,
|
||||||
|
&auth_value,
|
||||||
|
&payload.extra_headers,
|
||||||
|
payload.content_type.as_deref(),
|
||||||
|
)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
payload.provider_request_headers.clone()
|
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") {
|
if payload.upstream_is_stream && !provider_request_headers.contains_key("accept") {
|
||||||
provider_request_headers.insert("accept".to_string(), "text/event-stream".to_string());
|
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 {
|
let plan = ExecutionPlan {
|
||||||
request_id,
|
request_id,
|
||||||
candidate_id: payload.candidate_id.clone(),
|
candidate_id: payload.candidate_id.clone(),
|
||||||
@@ -300,15 +324,268 @@ pub(crate) fn build_openai_cli_sync_plan_from_decision(
|
|||||||
timeouts: payload.timeouts.clone(),
|
timeouts: payload.timeouts.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let report_context = augment_sync_report_context(
|
|
||||||
payload.report_context,
|
|
||||||
&plan.headers,
|
|
||||||
&provider_request_body_value,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
Ok(Some(LocalSyncPlanAndReport {
|
Ok(Some(LocalSyncPlanAndReport {
|
||||||
plan,
|
plan,
|
||||||
report_kind: payload.report_kind,
|
report_kind: payload.report_kind,
|
||||||
report_context,
|
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::constants::CONTROL_EXECUTED_HEADER;
|
||||||
use crate::control::GatewayControlDecision;
|
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 crate::{AppState, GatewayError};
|
||||||
|
|
||||||
use super::resolve_execution_runtime_auth_context;
|
use super::resolve_execution_runtime_auth_context;
|
||||||
@@ -19,9 +21,9 @@ pub(crate) async fn maybe_execute_via_control(
|
|||||||
trace_id: &str,
|
trace_id: &str,
|
||||||
decision: Option<&GatewayControlDecision>,
|
decision: Option<&GatewayControlDecision>,
|
||||||
require_stream: bool,
|
require_stream: bool,
|
||||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
) -> Result<LocalExecutionRequestOutcome, GatewayError> {
|
||||||
let Some(decision) = decision else {
|
let Some(decision) = decision else {
|
||||||
return Ok(None);
|
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut local_decision = decision.clone();
|
let mut local_decision = decision.clone();
|
||||||
@@ -46,7 +48,15 @@ pub(crate) async fn maybe_execute_via_control(
|
|||||||
.await?
|
.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> {
|
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_data_contracts::repository::candidates::RequestCandidateStatus;
|
||||||
use aether_scheduler_core::SchedulerRequestCandidateStatusUpdate;
|
use aether_scheduler_core::SchedulerRequestCandidateStatusUpdate;
|
||||||
|
|
||||||
use crate::ai_pipeline_api::{LocalStreamPlanAndReport, LocalSyncPlanAndReport};
|
use crate::ai_pipeline_api::{LocalStreamPlanAndReport, LocalSyncPlanAndReport};
|
||||||
use crate::control::GatewayControlDecision;
|
use crate::control::GatewayControlDecision;
|
||||||
use crate::execution_runtime::{execute_execution_runtime_stream, execute_execution_runtime_sync};
|
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::request_candidate_runtime::record_local_request_candidate_status;
|
||||||
use crate::{AppState, GatewayError};
|
use crate::{AppState, GatewayError};
|
||||||
|
|
||||||
@@ -53,12 +51,17 @@ pub(crate) async fn execute_sync_plan_and_reports<T>(
|
|||||||
decision: &GatewayControlDecision,
|
decision: &GatewayControlDecision,
|
||||||
plan_kind: &str,
|
plan_kind: &str,
|
||||||
plan_and_reports: Vec<T>,
|
plan_and_reports: Vec<T>,
|
||||||
) -> Result<Option<Response<Body>>, GatewayError>
|
) -> Result<LocalExecutionRequestOutcome, GatewayError>
|
||||||
where
|
where
|
||||||
T: LocalPlanAndReport,
|
T: LocalPlanAndReport,
|
||||||
{
|
{
|
||||||
let mut remaining = plan_and_reports.into_iter();
|
let mut remaining = plan_and_reports.into_iter();
|
||||||
|
let mut last_attempted = None;
|
||||||
while let Some(plan_and_report) = remaining.next() {
|
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(
|
if let Some(response) = execute_execution_runtime_sync(
|
||||||
state,
|
state,
|
||||||
parts.uri.path(),
|
parts.uri.path(),
|
||||||
@@ -72,11 +75,16 @@ where
|
|||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
mark_unused_local_candidates(state, remaining.collect()).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>(
|
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,
|
decision: &GatewayControlDecision,
|
||||||
plan_kind: &str,
|
plan_kind: &str,
|
||||||
plan_and_reports: Vec<T>,
|
plan_and_reports: Vec<T>,
|
||||||
) -> Result<Option<Response<Body>>, GatewayError>
|
) -> Result<LocalExecutionRequestOutcome, GatewayError>
|
||||||
where
|
where
|
||||||
T: LocalPlanAndReport,
|
T: LocalPlanAndReport,
|
||||||
{
|
{
|
||||||
let mut remaining = plan_and_reports.into_iter();
|
let mut remaining = plan_and_reports.into_iter();
|
||||||
|
let mut last_attempted = None;
|
||||||
while let Some(plan_and_report) = remaining.next() {
|
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(
|
if let Some(response) = execute_execution_runtime_stream(
|
||||||
state,
|
state,
|
||||||
plan_and_report.plan().clone(),
|
plan_and_report.plan().clone(),
|
||||||
@@ -103,11 +116,16 @@ where
|
|||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
mark_unused_local_candidates(state, remaining.collect()).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>)
|
pub(crate) async fn mark_unused_local_candidates<T>(state: &AppState, remaining: Vec<T>)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
pub(crate) mod candidate_loop;
|
pub(crate) mod candidate_loop;
|
||||||
mod orchestration;
|
mod orchestration;
|
||||||
|
mod outcome;
|
||||||
mod plan_fallback;
|
mod plan_fallback;
|
||||||
mod policy;
|
mod policy;
|
||||||
mod remote;
|
mod remote;
|
||||||
@@ -9,8 +10,15 @@ mod sync_path;
|
|||||||
pub(crate) use crate::request_candidate_runtime::{
|
pub(crate) use crate::request_candidate_runtime::{
|
||||||
persist_available_local_candidate, persist_skipped_local_candidate,
|
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 orchestration::*;
|
||||||
|
pub(crate) use outcome::{
|
||||||
|
build_local_execution_exhaustion, record_failed_usage_for_exhausted_request,
|
||||||
|
LocalExecutionExhaustion, LocalExecutionRequestOutcome,
|
||||||
|
};
|
||||||
pub(crate) use plan_fallback::{
|
pub(crate) use plan_fallback::{
|
||||||
maybe_execute_stream_via_plan_fallback, maybe_execute_sync_via_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::{
|
use crate::ai_pipeline_api::{
|
||||||
build_local_gemini_files_stream_plan_and_reports_for_kind,
|
build_local_gemini_files_stream_plan_and_reports_for_kind,
|
||||||
build_local_gemini_files_sync_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::{
|
use crate::executor::candidate_loop::{
|
||||||
execute_stream_plan_and_reports, execute_sync_plan_and_reports,
|
execute_stream_plan_and_reports, execute_sync_plan_and_reports,
|
||||||
};
|
};
|
||||||
|
use crate::executor::LocalExecutionRequestOutcome;
|
||||||
use crate::{AppState, GatewayControlSyncDecisionResponse, GatewayError};
|
use crate::{AppState, GatewayControlSyncDecisionResponse, GatewayError};
|
||||||
|
|
||||||
pub(crate) async fn maybe_execute_sync_local_path(
|
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,
|
body_bytes: &axum::body::Bytes,
|
||||||
trace_id: &str,
|
trace_id: &str,
|
||||||
decision: &GatewayControlDecision,
|
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
|
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,
|
body_bytes: &axum::body::Bytes,
|
||||||
trace_id: &str,
|
trace_id: &str,
|
||||||
decision: &GatewayControlDecision,
|
decision: &GatewayControlDecision,
|
||||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
) -> Result<LocalExecutionRequestOutcome, GatewayError> {
|
||||||
super::maybe_execute_via_stream_decision_path(state, parts, body_bytes, trace_id, decision)
|
super::maybe_execute_via_stream_decision_path(state, parts, body_bytes, trace_id, decision)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -51,17 +49,17 @@ pub(crate) async fn maybe_execute_sync_via_local_decision(
|
|||||||
decision: &GatewayControlDecision,
|
decision: &GatewayControlDecision,
|
||||||
body_json: &serde_json::Value,
|
body_json: &serde_json::Value,
|
||||||
plan_kind: &str,
|
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(
|
let plan_and_reports = build_local_openai_chat_sync_plan_and_reports_for_kind(
|
||||||
state, parts, trace_id, decision, body_json, plan_kind,
|
state, parts, trace_id, decision, body_json, plan_kind,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
if plan_and_reports.is_empty() {
|
if plan_and_reports.is_empty() {
|
||||||
return Ok(None);
|
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
let plan_count = plan_and_reports.len();
|
let plan_count = plan_and_reports.len();
|
||||||
if let Some(response) = execute_sync_plan_and_reports(
|
let outcome = execute_sync_plan_and_reports(
|
||||||
state,
|
state,
|
||||||
parts,
|
parts,
|
||||||
trace_id,
|
trace_id,
|
||||||
@@ -69,15 +67,15 @@ pub(crate) async fn maybe_execute_sync_via_local_decision(
|
|||||||
plan_kind,
|
plan_kind,
|
||||||
plan_and_reports,
|
plan_and_reports,
|
||||||
)
|
)
|
||||||
.await?
|
.await?;
|
||||||
{
|
|
||||||
return Ok(Some(response));
|
if let LocalExecutionRequestOutcome::Exhausted(_) = &outcome {
|
||||||
|
set_local_openai_chat_execution_exhausted_diagnostic(
|
||||||
|
state, trace_id, decision, plan_kind, body_json, plan_count,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
set_local_openai_chat_execution_exhausted_diagnostic(
|
Ok(outcome)
|
||||||
state, trace_id, decision, plan_kind, body_json, plan_count,
|
|
||||||
);
|
|
||||||
Ok(None)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn maybe_execute_stream_via_local_decision(
|
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,
|
decision: &GatewayControlDecision,
|
||||||
body_json: &serde_json::Value,
|
body_json: &serde_json::Value,
|
||||||
plan_kind: &str,
|
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(
|
let plan_and_reports = build_local_openai_chat_stream_plan_and_reports_for_kind(
|
||||||
state, parts, trace_id, decision, body_json, plan_kind,
|
state, parts, trace_id, decision, body_json, plan_kind,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
if plan_and_reports.is_empty() {
|
if plan_and_reports.is_empty() {
|
||||||
return Ok(None);
|
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
let plan_count = plan_and_reports.len();
|
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)
|
execute_stream_plan_and_reports(state, trace_id, decision, plan_kind, plan_and_reports)
|
||||||
.await?
|
.await?;
|
||||||
{
|
|
||||||
return Ok(Some(response));
|
if let LocalExecutionRequestOutcome::Exhausted(_) = &outcome {
|
||||||
|
set_local_openai_chat_execution_exhausted_diagnostic(
|
||||||
|
state, trace_id, decision, plan_kind, body_json, plan_count,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
set_local_openai_chat_execution_exhausted_diagnostic(
|
Ok(outcome)
|
||||||
state, trace_id, decision, plan_kind, body_json, plan_count,
|
|
||||||
);
|
|
||||||
Ok(None)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn maybe_execute_sync_via_local_openai_cli_decision(
|
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,
|
decision: &GatewayControlDecision,
|
||||||
body_json: &serde_json::Value,
|
body_json: &serde_json::Value,
|
||||||
plan_kind: &str,
|
plan_kind: &str,
|
||||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
) -> Result<LocalExecutionRequestOutcome, GatewayError> {
|
||||||
let plan_and_reports: Vec<LocalSyncPlanAndReport> =
|
let plan_and_reports: Vec<LocalSyncPlanAndReport> =
|
||||||
build_local_openai_cli_sync_plan_and_reports_for_kind(
|
build_local_openai_cli_sync_plan_and_reports_for_kind(
|
||||||
state, parts, trace_id, decision, body_json, plan_kind,
|
state, parts, trace_id, decision, body_json, plan_kind,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
if plan_and_reports.is_empty() {
|
if plan_and_reports.is_empty() {
|
||||||
return Ok(None);
|
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
execute_sync_plan_and_reports(
|
execute_sync_plan_and_reports(
|
||||||
@@ -145,14 +143,14 @@ pub(crate) async fn maybe_execute_stream_via_local_openai_cli_decision(
|
|||||||
decision: &GatewayControlDecision,
|
decision: &GatewayControlDecision,
|
||||||
body_json: &serde_json::Value,
|
body_json: &serde_json::Value,
|
||||||
plan_kind: &str,
|
plan_kind: &str,
|
||||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
) -> Result<LocalExecutionRequestOutcome, GatewayError> {
|
||||||
let plan_and_reports: Vec<LocalStreamPlanAndReport> =
|
let plan_and_reports: Vec<LocalStreamPlanAndReport> =
|
||||||
build_local_openai_cli_stream_plan_and_reports_for_kind(
|
build_local_openai_cli_stream_plan_and_reports_for_kind(
|
||||||
state, parts, trace_id, decision, body_json, plan_kind,
|
state, parts, trace_id, decision, body_json, plan_kind,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
if plan_and_reports.is_empty() {
|
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
|
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,
|
body_json: &serde_json::Value,
|
||||||
plan_kind: &str,
|
plan_kind: &str,
|
||||||
resolve_sync_spec: fn(&str) -> Option<LocalStandardSpec>,
|
resolve_sync_spec: fn(&str) -> Option<LocalStandardSpec>,
|
||||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
) -> Result<LocalExecutionRequestOutcome, GatewayError> {
|
||||||
let Some(spec) = resolve_sync_spec(plan_kind) else {
|
let Some(spec) = resolve_sync_spec(plan_kind) else {
|
||||||
return Ok(None);
|
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||||
};
|
};
|
||||||
|
|
||||||
let plan_and_reports: Vec<LocalSyncPlanAndReport> =
|
let plan_and_reports: Vec<LocalSyncPlanAndReport> =
|
||||||
@@ -177,7 +175,7 @@ pub(crate) async fn maybe_execute_sync_via_standard_family_decision(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
if plan_and_reports.is_empty() {
|
if plan_and_reports.is_empty() {
|
||||||
return Ok(None);
|
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
execute_sync_plan_and_reports(
|
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,
|
body_json: &serde_json::Value,
|
||||||
plan_kind: &str,
|
plan_kind: &str,
|
||||||
resolve_stream_spec: fn(&str) -> Option<LocalStandardSpec>,
|
resolve_stream_spec: fn(&str) -> Option<LocalStandardSpec>,
|
||||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
) -> Result<LocalExecutionRequestOutcome, GatewayError> {
|
||||||
let Some(spec) = resolve_stream_spec(plan_kind) else {
|
let Some(spec) = resolve_stream_spec(plan_kind) else {
|
||||||
return Ok(None);
|
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||||
};
|
};
|
||||||
|
|
||||||
let plan_and_reports: Vec<LocalStreamPlanAndReport> =
|
let plan_and_reports: Vec<LocalStreamPlanAndReport> =
|
||||||
@@ -210,7 +208,7 @@ pub(crate) async fn maybe_execute_stream_via_standard_family_decision(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
if plan_and_reports.is_empty() {
|
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
|
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,
|
decision: &GatewayControlDecision,
|
||||||
body_json: &serde_json::Value,
|
body_json: &serde_json::Value,
|
||||||
plan_kind: &str,
|
plan_kind: &str,
|
||||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
) -> Result<LocalExecutionRequestOutcome, GatewayError> {
|
||||||
if let Some(response) = maybe_execute_sync_via_standard_family_decision(
|
let mut exhausted = None;
|
||||||
|
|
||||||
|
match maybe_execute_sync_via_standard_family_decision(
|
||||||
state,
|
state,
|
||||||
parts,
|
parts,
|
||||||
trace_id,
|
trace_id,
|
||||||
@@ -235,10 +235,14 @@ pub(crate) async fn maybe_execute_sync_via_local_standard_decision(
|
|||||||
)
|
)
|
||||||
.await?
|
.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,
|
state,
|
||||||
parts,
|
parts,
|
||||||
trace_id,
|
trace_id,
|
||||||
@@ -247,7 +251,18 @@ pub(crate) async fn maybe_execute_sync_via_local_standard_decision(
|
|||||||
plan_kind,
|
plan_kind,
|
||||||
resolve_gemini_sync_spec,
|
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(
|
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,
|
decision: &GatewayControlDecision,
|
||||||
body_json: &serde_json::Value,
|
body_json: &serde_json::Value,
|
||||||
plan_kind: &str,
|
plan_kind: &str,
|
||||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
) -> Result<LocalExecutionRequestOutcome, GatewayError> {
|
||||||
if let Some(response) = maybe_execute_stream_via_standard_family_decision(
|
let mut exhausted = None;
|
||||||
|
|
||||||
|
match maybe_execute_stream_via_standard_family_decision(
|
||||||
state,
|
state,
|
||||||
parts,
|
parts,
|
||||||
trace_id,
|
trace_id,
|
||||||
@@ -269,10 +286,14 @@ pub(crate) async fn maybe_execute_stream_via_local_standard_decision(
|
|||||||
)
|
)
|
||||||
.await?
|
.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,
|
state,
|
||||||
parts,
|
parts,
|
||||||
trace_id,
|
trace_id,
|
||||||
@@ -281,7 +302,18 @@ pub(crate) async fn maybe_execute_stream_via_local_standard_decision(
|
|||||||
plan_kind,
|
plan_kind,
|
||||||
resolve_gemini_stream_spec,
|
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(
|
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,
|
decision: &GatewayControlDecision,
|
||||||
body_json: &serde_json::Value,
|
body_json: &serde_json::Value,
|
||||||
plan_kind: &str,
|
plan_kind: &str,
|
||||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
) -> Result<LocalExecutionRequestOutcome, GatewayError> {
|
||||||
let Some(spec) = resolve_local_same_format_sync_spec(plan_kind) else {
|
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> =
|
let plan_and_reports: Vec<LocalSyncPlanAndReport> =
|
||||||
@@ -302,7 +334,7 @@ pub(crate) async fn maybe_execute_sync_via_local_same_format_provider_decision(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
if plan_and_reports.is_empty() {
|
if plan_and_reports.is_empty() {
|
||||||
return Ok(None);
|
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
execute_sync_plan_and_reports(
|
execute_sync_plan_and_reports(
|
||||||
@@ -323,9 +355,9 @@ pub(crate) async fn maybe_execute_stream_via_local_same_format_provider_decision
|
|||||||
decision: &GatewayControlDecision,
|
decision: &GatewayControlDecision,
|
||||||
body_json: &serde_json::Value,
|
body_json: &serde_json::Value,
|
||||||
plan_kind: &str,
|
plan_kind: &str,
|
||||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
) -> Result<LocalExecutionRequestOutcome, GatewayError> {
|
||||||
let Some(spec) = resolve_local_same_format_stream_spec(plan_kind) else {
|
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> =
|
let plan_and_reports: Vec<LocalStreamPlanAndReport> =
|
||||||
@@ -334,7 +366,7 @@ pub(crate) async fn maybe_execute_stream_via_local_same_format_provider_decision
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
if plan_and_reports.is_empty() {
|
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
|
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,
|
trace_id: &str,
|
||||||
decision: &GatewayControlDecision,
|
decision: &GatewayControlDecision,
|
||||||
plan_kind: &str,
|
plan_kind: &str,
|
||||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
) -> Result<LocalExecutionRequestOutcome, GatewayError> {
|
||||||
let plan_and_reports: Vec<LocalSyncPlanAndReport> =
|
let plan_and_reports: Vec<LocalSyncPlanAndReport> =
|
||||||
build_local_gemini_files_sync_plan_and_reports_for_kind(
|
build_local_gemini_files_sync_plan_and_reports_for_kind(
|
||||||
state,
|
state,
|
||||||
@@ -363,7 +395,7 @@ pub(crate) async fn maybe_execute_sync_via_local_gemini_files_decision(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
if plan_and_reports.is_empty() {
|
if plan_and_reports.is_empty() {
|
||||||
return Ok(None);
|
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
execute_sync_plan_and_reports(
|
execute_sync_plan_and_reports(
|
||||||
@@ -383,14 +415,14 @@ pub(crate) async fn maybe_execute_stream_via_local_gemini_files_decision(
|
|||||||
trace_id: &str,
|
trace_id: &str,
|
||||||
decision: &GatewayControlDecision,
|
decision: &GatewayControlDecision,
|
||||||
plan_kind: &str,
|
plan_kind: &str,
|
||||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
) -> Result<LocalExecutionRequestOutcome, GatewayError> {
|
||||||
let plan_and_reports: Vec<LocalStreamPlanAndReport> =
|
let plan_and_reports: Vec<LocalStreamPlanAndReport> =
|
||||||
build_local_gemini_files_stream_plan_and_reports_for_kind(
|
build_local_gemini_files_stream_plan_and_reports_for_kind(
|
||||||
state, parts, trace_id, decision, plan_kind,
|
state, parts, trace_id, decision, plan_kind,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
if plan_and_reports.is_empty() {
|
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
|
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,
|
trace_id: &str,
|
||||||
decision: &GatewayControlDecision,
|
decision: &GatewayControlDecision,
|
||||||
plan_kind: &str,
|
plan_kind: &str,
|
||||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
) -> Result<LocalExecutionRequestOutcome, GatewayError> {
|
||||||
let plan_and_reports: Vec<LocalSyncPlanAndReport> =
|
let plan_and_reports: Vec<LocalSyncPlanAndReport> =
|
||||||
build_local_video_sync_plan_and_reports_for_kind(
|
build_local_video_sync_plan_and_reports_for_kind(
|
||||||
state, parts, body_json, trace_id, decision, plan_kind,
|
state, parts, body_json, trace_id, decision, plan_kind,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
if plan_and_reports.is_empty() {
|
if plan_and_reports.is_empty() {
|
||||||
return Ok(None);
|
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
execute_sync_plan_and_reports(
|
execute_sync_plan_and_reports(
|
||||||
@@ -430,14 +462,14 @@ pub(crate) async fn maybe_execute_sync_request(
|
|||||||
body_bytes: &axum::body::Bytes,
|
body_bytes: &axum::body::Bytes,
|
||||||
trace_id: &str,
|
trace_id: &str,
|
||||||
decision: Option<&GatewayControlDecision>,
|
decision: Option<&GatewayControlDecision>,
|
||||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
) -> Result<LocalExecutionRequestOutcome, GatewayError> {
|
||||||
let Some(decision) = decision else {
|
let Some(decision) = decision else {
|
||||||
return Ok(None);
|
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||||
};
|
};
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
{
|
{
|
||||||
if parts.method != http::Method::POST {
|
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;
|
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()
|
.is_empty()
|
||||||
&& parts.method != http::Method::POST
|
&& 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
|
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,
|
body_bytes: &axum::body::Bytes,
|
||||||
trace_id: &str,
|
trace_id: &str,
|
||||||
decision: Option<&GatewayControlDecision>,
|
decision: Option<&GatewayControlDecision>,
|
||||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
) -> Result<LocalExecutionRequestOutcome, GatewayError> {
|
||||||
let Some(decision) = decision else {
|
let Some(decision) = decision else {
|
||||||
return Ok(None);
|
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||||
};
|
};
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
{
|
{
|
||||||
if parts.method != http::Method::POST {
|
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;
|
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()
|
.is_empty()
|
||||||
&& parts.method != http::Method::POST
|
&& 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
|
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 crate::ai_pipeline_api::{
|
||||||
use axum::http::Response;
|
maybe_build_stream_plan_payload, maybe_build_sync_plan_payload, LocalStreamPlanAndReport,
|
||||||
|
LocalSyncPlanAndReport,
|
||||||
use crate::ai_pipeline_api::{maybe_build_stream_plan_payload, maybe_build_sync_plan_payload};
|
};
|
||||||
use crate::control::GatewayControlDecision;
|
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};
|
use crate::{AppState, GatewayControlPlanResponse, GatewayError, GatewayFallbackReason};
|
||||||
|
|
||||||
pub(crate) async fn maybe_execute_sync_via_plan_fallback(
|
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,
|
_plan_kind: &str,
|
||||||
_bypass_cache_key: String,
|
_bypass_cache_key: String,
|
||||||
_fallback_reason: GatewayFallbackReason,
|
_fallback_reason: GatewayFallbackReason,
|
||||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
) -> Result<LocalExecutionRequestOutcome, GatewayError> {
|
||||||
let body_is_empty =
|
let body_is_empty =
|
||||||
body_base64.is_none() && body_json.as_object().is_some_and(|value| value.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(
|
let Some(payload) = maybe_build_sync_plan_payload(
|
||||||
@@ -30,7 +32,7 @@ pub(crate) async fn maybe_execute_sync_via_plan_fallback(
|
|||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
else {
|
else {
|
||||||
return Ok(None);
|
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||||
};
|
};
|
||||||
|
|
||||||
let GatewayControlPlanResponse {
|
let GatewayControlPlanResponse {
|
||||||
@@ -43,18 +45,20 @@ pub(crate) async fn maybe_execute_sync_via_plan_fallback(
|
|||||||
} = payload;
|
} = payload;
|
||||||
|
|
||||||
let (Some(plan_kind), Some(plan)) = (plan_kind, plan) else {
|
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,
|
state,
|
||||||
parts.uri.path(),
|
parts,
|
||||||
plan,
|
|
||||||
trace_id,
|
trace_id,
|
||||||
decision,
|
decision,
|
||||||
plan_kind.as_str(),
|
plan_kind.as_str(),
|
||||||
report_kind,
|
vec![LocalSyncPlanAndReport {
|
||||||
report_context,
|
plan,
|
||||||
|
report_kind,
|
||||||
|
report_context,
|
||||||
|
}],
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -69,11 +73,11 @@ pub(crate) async fn maybe_execute_stream_via_plan_fallback(
|
|||||||
_plan_kind: &str,
|
_plan_kind: &str,
|
||||||
_bypass_cache_key: String,
|
_bypass_cache_key: String,
|
||||||
_fallback_reason: GatewayFallbackReason,
|
_fallback_reason: GatewayFallbackReason,
|
||||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
) -> Result<LocalExecutionRequestOutcome, GatewayError> {
|
||||||
let Some(payload) =
|
let Some(payload) =
|
||||||
maybe_build_stream_plan_payload(state, parts, trace_id, decision, body_json).await?
|
maybe_build_stream_plan_payload(state, parts, trace_id, decision, body_json).await?
|
||||||
else {
|
else {
|
||||||
return Ok(None);
|
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||||
};
|
};
|
||||||
|
|
||||||
let GatewayControlPlanResponse {
|
let GatewayControlPlanResponse {
|
||||||
@@ -86,17 +90,19 @@ pub(crate) async fn maybe_execute_stream_via_plan_fallback(
|
|||||||
} = payload;
|
} = payload;
|
||||||
|
|
||||||
let (Some(plan_kind), Some(plan)) = (plan_kind, plan) else {
|
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,
|
state,
|
||||||
plan,
|
|
||||||
trace_id,
|
trace_id,
|
||||||
decision,
|
decision,
|
||||||
plan_kind.as_str(),
|
plan_kind.as_str(),
|
||||||
report_kind,
|
vec![LocalStreamPlanAndReport {
|
||||||
report_context,
|
plan,
|
||||||
|
report_kind,
|
||||||
|
report_context,
|
||||||
|
}],
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,20 +4,21 @@ use std::collections::BTreeMap;
|
|||||||
|
|
||||||
use crate::ai_pipeline_api::{
|
use crate::ai_pipeline_api::{
|
||||||
is_matching_stream_request, resolve_execution_runtime_stream_plan_kind,
|
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::api::response::build_client_response_from_parts;
|
||||||
use crate::control::GatewayControlDecision;
|
use crate::control::GatewayControlDecision;
|
||||||
use crate::execution_runtime::execute_execution_runtime_stream;
|
|
||||||
use crate::{AppState, GatewayError, GatewayFallbackReason};
|
use crate::{AppState, GatewayError, GatewayFallbackReason};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
build_direct_plan_bypass_cache_key, maybe_execute_stream_via_local_decision,
|
build_direct_plan_bypass_cache_key, execute_stream_plan_and_reports,
|
||||||
maybe_execute_stream_via_local_gemini_files_decision,
|
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_openai_cli_decision,
|
||||||
maybe_execute_stream_via_local_same_format_provider_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_local_standard_decision, maybe_execute_stream_via_plan_fallback,
|
||||||
maybe_execute_stream_via_remote_decision, parse_local_request_body, should_skip_direct_plan,
|
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(
|
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,
|
body_bytes: &Bytes,
|
||||||
trace_id: &str,
|
trace_id: &str,
|
||||||
decision: &GatewayControlDecision,
|
decision: &GatewayControlDecision,
|
||||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
) -> Result<LocalExecutionRequestOutcome, GatewayError> {
|
||||||
let Some(plan_kind) = resolve_execution_runtime_stream_plan_kind(parts, decision) else {
|
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 {
|
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) {
|
if !is_matching_stream_request(plan_kind, parts, &body_json) {
|
||||||
return Ok(None);
|
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
let bypass_cache_key =
|
let bypass_cache_key =
|
||||||
build_direct_plan_bypass_cache_key(plan_kind, parts, body_bytes, decision);
|
build_direct_plan_bypass_cache_key(plan_kind, parts, body_bytes, decision);
|
||||||
if should_skip_direct_plan(state, &bypass_cache_key) {
|
if should_skip_direct_plan(state, &bypass_cache_key) {
|
||||||
return Ok(None);
|
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(response) =
|
let mut exhausted = None;
|
||||||
maybe_execute_local_video_task_content_stream(state, parts, trace_id, decision, plan_kind)
|
|
||||||
.await?
|
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 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,
|
state, parts, trace_id, decision, &body_json, plan_kind,
|
||||||
)
|
)
|
||||||
.await?
|
.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,
|
state, parts, trace_id, decision, &body_json, plan_kind,
|
||||||
)
|
)
|
||||||
.await?
|
.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,
|
state, parts, trace_id, decision, &body_json, plan_kind,
|
||||||
)
|
)
|
||||||
.await?
|
.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,
|
state, parts, trace_id, decision, &body_json, plan_kind,
|
||||||
)
|
)
|
||||||
.await?
|
.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,
|
state, parts, trace_id, decision, plan_kind,
|
||||||
)
|
)
|
||||||
.await?
|
.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(
|
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?
|
.await?
|
||||||
{
|
{
|
||||||
return Ok(Some(response));
|
return Ok(LocalExecutionRequestOutcome::Responded(response));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
maybe_execute_stream_via_plan_fallback(
|
match maybe_execute_stream_via_plan_fallback(
|
||||||
state,
|
state,
|
||||||
parts,
|
parts,
|
||||||
trace_id,
|
trace_id,
|
||||||
@@ -117,7 +143,18 @@ pub(crate) async fn maybe_execute_via_stream_decision_path(
|
|||||||
GatewayFallbackReason::SchedulerDecisionUnsupported
|
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(
|
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,
|
trace_id: &str,
|
||||||
decision: &GatewayControlDecision,
|
decision: &GatewayControlDecision,
|
||||||
plan_kind: &str,
|
plan_kind: &str,
|
||||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
) -> Result<LocalExecutionRequestOutcome, GatewayError> {
|
||||||
if plan_kind != OPENAI_VIDEO_CONTENT_PLAN_KIND
|
if plan_kind != OPENAI_VIDEO_CONTENT_PLAN_KIND
|
||||||
|| decision.route_family.as_deref() != Some("openai")
|
|| decision.route_family.as_deref() != Some("openai")
|
||||||
{
|
{
|
||||||
return Ok(None);
|
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = state
|
let _ = state
|
||||||
@@ -155,22 +192,28 @@ async fn maybe_execute_local_video_task_content_stream(
|
|||||||
parts.uri.query(),
|
parts.uri.query(),
|
||||||
trace_id,
|
trace_id,
|
||||||
) else {
|
) else {
|
||||||
return Ok(None);
|
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||||
};
|
};
|
||||||
|
|
||||||
match action {
|
match action {
|
||||||
crate::video_tasks::LocalVideoTaskContentAction::Immediate {
|
crate::video_tasks::LocalVideoTaskContentAction::Immediate {
|
||||||
status_code,
|
status_code,
|
||||||
body_json,
|
body_json,
|
||||||
} => Ok(Some(build_json_response(
|
} => Ok(LocalExecutionRequestOutcome::Responded(
|
||||||
trace_id,
|
build_json_response(trace_id, decision, status_code, &body_json)?,
|
||||||
decision,
|
)),
|
||||||
status_code,
|
|
||||||
&body_json,
|
|
||||||
)?)),
|
|
||||||
crate::video_tasks::LocalVideoTaskContentAction::StreamPlan(plan) => {
|
crate::video_tasks::LocalVideoTaskContentAction::StreamPlan(plan) => {
|
||||||
execute_execution_runtime_stream(
|
let plan = *plan;
|
||||||
state, *plan, trace_id, decision, plan_kind, None, None,
|
execute_stream_plan_and_reports(
|
||||||
|
state,
|
||||||
|
trace_id,
|
||||||
|
decision,
|
||||||
|
plan_kind,
|
||||||
|
vec![LocalStreamPlanAndReport {
|
||||||
|
plan,
|
||||||
|
report_kind: None,
|
||||||
|
report_context: None,
|
||||||
|
}],
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,23 +5,22 @@ use std::collections::BTreeMap;
|
|||||||
use crate::ai_pipeline_api::{
|
use crate::ai_pipeline_api::{
|
||||||
is_matching_stream_request, resolve_execution_runtime_stream_plan_kind,
|
is_matching_stream_request, resolve_execution_runtime_stream_plan_kind,
|
||||||
resolve_execution_runtime_sync_plan_kind, supports_sync_scheduler_decision_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,
|
OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND, OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND,
|
||||||
};
|
};
|
||||||
use crate::api::response::build_client_response_from_parts;
|
use crate::api::response::build_client_response_from_parts;
|
||||||
use crate::control::resolve_execution_runtime_auth_context;
|
use crate::control::resolve_execution_runtime_auth_context;
|
||||||
use crate::control::GatewayControlDecision;
|
use crate::control::GatewayControlDecision;
|
||||||
use crate::execution_runtime::execute_execution_runtime_sync;
|
|
||||||
use crate::{AppState, GatewayError, GatewayFallbackReason};
|
use crate::{AppState, GatewayError, GatewayFallbackReason};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
build_direct_plan_bypass_cache_key, maybe_execute_sync_via_local_decision,
|
build_direct_plan_bypass_cache_key, execute_sync_plan_and_reports,
|
||||||
maybe_execute_sync_via_local_gemini_files_decision,
|
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_openai_cli_decision,
|
||||||
maybe_execute_sync_via_local_same_format_provider_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_local_standard_decision, maybe_execute_sync_via_local_video_decision,
|
||||||
maybe_execute_sync_via_plan_fallback, maybe_execute_sync_via_remote_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(
|
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,
|
body_bytes: &Bytes,
|
||||||
trace_id: &str,
|
trace_id: &str,
|
||||||
decision: &GatewayControlDecision,
|
decision: &GatewayControlDecision,
|
||||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
) -> Result<LocalExecutionRequestOutcome, GatewayError> {
|
||||||
if let Some(response) =
|
if let LocalExecutionRequestOutcome::Responded(response) =
|
||||||
maybe_build_local_video_task_read_response(state, parts, trace_id, decision).await?
|
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 {
|
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 {
|
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 let Some(stream_plan_kind) = resolve_execution_runtime_stream_plan_kind(parts, decision) {
|
||||||
if is_matching_stream_request(stream_plan_kind, parts, &body_json) {
|
if is_matching_stream_request(stream_plan_kind, parts, &body_json) {
|
||||||
return Ok(None);
|
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let bypass_cache_key =
|
let bypass_cache_key =
|
||||||
build_direct_plan_bypass_cache_key(plan_kind, parts, body_bytes, decision);
|
build_direct_plan_bypass_cache_key(plan_kind, parts, body_bytes, decision);
|
||||||
if should_skip_direct_plan(state, &bypass_cache_key) {
|
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,
|
state, parts, &body_json, trace_id, decision, plan_kind,
|
||||||
)
|
)
|
||||||
.await?
|
.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 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,
|
state, parts, &body_json, trace_id, decision, plan_kind,
|
||||||
)
|
)
|
||||||
.await?
|
.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,
|
state, parts, trace_id, decision, &body_json, plan_kind,
|
||||||
)
|
)
|
||||||
.await?
|
.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,
|
state, parts, trace_id, decision, &body_json, plan_kind,
|
||||||
)
|
)
|
||||||
.await?
|
.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,
|
state, parts, trace_id, decision, &body_json, plan_kind,
|
||||||
)
|
)
|
||||||
.await?
|
.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,
|
state, parts, trace_id, decision, &body_json, plan_kind,
|
||||||
)
|
)
|
||||||
.await?
|
.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,
|
state,
|
||||||
parts,
|
parts,
|
||||||
&body_json,
|
&body_json,
|
||||||
@@ -118,7 +143,11 @@ pub(crate) async fn maybe_execute_via_sync_decision_path(
|
|||||||
)
|
)
|
||||||
.await?
|
.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(
|
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?
|
.await?
|
||||||
{
|
{
|
||||||
return Ok(Some(response));
|
return Ok(LocalExecutionRequestOutcome::Responded(response));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
maybe_execute_sync_via_plan_fallback(
|
match maybe_execute_sync_via_plan_fallback(
|
||||||
state,
|
state,
|
||||||
parts,
|
parts,
|
||||||
trace_id,
|
trace_id,
|
||||||
@@ -145,7 +174,18 @@ pub(crate) async fn maybe_execute_via_sync_decision_path(
|
|||||||
GatewayFallbackReason::SchedulerDecisionUnsupported
|
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(
|
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,
|
parts: &http::request::Parts,
|
||||||
trace_id: &str,
|
trace_id: &str,
|
||||||
decision: &GatewayControlDecision,
|
decision: &GatewayControlDecision,
|
||||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
) -> Result<LocalExecutionRequestOutcome, GatewayError> {
|
||||||
if parts.method != http::Method::GET || decision.route_kind.as_deref() != Some("video") {
|
if parts.method != http::Method::GET || decision.route_kind.as_deref() != Some("video") {
|
||||||
return Ok(None);
|
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = state
|
let _ = state
|
||||||
@@ -187,7 +227,7 @@ async fn maybe_build_local_video_task_read_response(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let Some(read_response) = read_response else {
|
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)
|
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-type".to_string(), "application/json".to_string());
|
||||||
headers.insert("content-length".to_string(), body_bytes.len().to_string());
|
headers.insert("content-length".to_string(), body_bytes.len().to_string());
|
||||||
|
|
||||||
Ok(Some(build_client_response_from_parts(
|
Ok(LocalExecutionRequestOutcome::Responded(
|
||||||
read_response.status_code,
|
build_client_response_from_parts(
|
||||||
&headers,
|
read_response.status_code,
|
||||||
Body::from(body_bytes),
|
&headers,
|
||||||
trace_id,
|
Body::from(body_bytes),
|
||||||
Some(decision),
|
trace_id,
|
||||||
)?))
|
Some(decision),
|
||||||
|
)?,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn maybe_execute_local_video_task_follow_up_sync(
|
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,
|
trace_id: &str,
|
||||||
decision: &GatewayControlDecision,
|
decision: &GatewayControlDecision,
|
||||||
plan_kind: &str,
|
plan_kind: &str,
|
||||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
) -> Result<LocalExecutionRequestOutcome, GatewayError> {
|
||||||
if !matches!(
|
if !matches!(
|
||||||
plan_kind,
|
plan_kind,
|
||||||
OPENAI_VIDEO_REMIX_SYNC_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
|
| OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND
|
||||||
| GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND
|
| GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND
|
||||||
) {
|
) {
|
||||||
return Ok(None);
|
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = state
|
let _ = state
|
||||||
@@ -242,18 +284,20 @@ async fn maybe_execute_local_video_task_follow_up_sync(
|
|||||||
auth_context.as_ref(),
|
auth_context.as_ref(),
|
||||||
trace_id,
|
trace_id,
|
||||||
) else {
|
) else {
|
||||||
return Ok(None);
|
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||||
};
|
};
|
||||||
|
|
||||||
execute_execution_runtime_sync(
|
execute_sync_plan_and_reports(
|
||||||
state,
|
state,
|
||||||
parts.uri.path(),
|
parts,
|
||||||
follow_up.plan,
|
|
||||||
trace_id,
|
trace_id,
|
||||||
decision,
|
decision,
|
||||||
plan_kind,
|
plan_kind,
|
||||||
follow_up.report_kind,
|
vec![LocalSyncPlanAndReport {
|
||||||
follow_up.report_context,
|
plan: follow_up.plan,
|
||||||
|
report_kind: follow_up.report_kind,
|
||||||
|
report_context: follow_up.report_context,
|
||||||
|
}],
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,10 @@ use crate::control::{
|
|||||||
should_buffer_request_for_local_auth, trusted_auth_local_rejection, GatewayControlDecision,
|
should_buffer_request_for_local_auth, trusted_auth_local_rejection, GatewayControlDecision,
|
||||||
GatewayPublicRequestContext,
|
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::{
|
use crate::handlers::shared::{
|
||||||
build_admin_proxy_auth_required_response, build_unhandled_admin_proxy_response,
|
build_admin_proxy_auth_required_response, build_unhandled_admin_proxy_response,
|
||||||
local_proxy_route_requires_buffered_body, request_enables_control_execute,
|
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::body::{to_bytes, Body, Bytes};
|
||||||
use axum::extract::{ConnectInfo, Request, State};
|
use axum::extract::{ConnectInfo, Request, State};
|
||||||
use axum::http::{self, header::HeaderName, header::HeaderValue, Response};
|
use axum::http::{self, header::HeaderName, header::HeaderValue, Response};
|
||||||
use chrono::Utc;
|
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
@@ -645,8 +647,9 @@ pub(crate) async fn proxy_request(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.expect("execution runtime/control auth gate should have buffered request body");
|
.expect("execution runtime/control auth gate should have buffered request body");
|
||||||
let stream_request = request_wants_stream(&request_context, buffered_body);
|
let stream_request = request_wants_stream(&request_context, buffered_body);
|
||||||
|
let mut local_execution_exhaustion = None;
|
||||||
if stream_request {
|
if stream_request {
|
||||||
if let Some(execution_runtime_response) = maybe_execute_stream_request(
|
match maybe_execute_stream_request(
|
||||||
&state,
|
&state,
|
||||||
&parts,
|
&parts,
|
||||||
buffered_body,
|
buffered_body,
|
||||||
@@ -655,35 +658,46 @@ pub(crate) async fn proxy_request(
|
|||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
|
LocalExecutionRequestOutcome::Responded(execution_runtime_response) => {
|
||||||
|
state.clear_local_execution_runtime_miss_diagnostic(&trace_id);
|
||||||
|
return Ok(finalize_gateway_response_with_context(
|
||||||
|
&state,
|
||||||
|
execution_runtime_response,
|
||||||
|
&remote_addr,
|
||||||
|
&request_context,
|
||||||
|
EXECUTION_PATH_EXECUTION_RUNTIME_STREAM,
|
||||||
|
&started_at,
|
||||||
|
request_permit.take(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
LocalExecutionRequestOutcome::Exhausted(outcome) => {
|
||||||
|
local_execution_exhaustion = Some(outcome);
|
||||||
|
}
|
||||||
|
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);
|
state.clear_local_execution_runtime_miss_diagnostic(&trace_id);
|
||||||
return Ok(finalize_gateway_response_with_context(
|
return Ok(finalize_gateway_response_with_context(
|
||||||
&state,
|
&state,
|
||||||
execution_runtime_response,
|
execution_runtime_response,
|
||||||
&remote_addr,
|
&remote_addr,
|
||||||
&request_context,
|
&request_context,
|
||||||
EXECUTION_PATH_EXECUTION_RUNTIME_STREAM,
|
EXECUTION_PATH_EXECUTION_RUNTIME_SYNC,
|
||||||
&started_at,
|
&started_at,
|
||||||
request_permit.take(),
|
request_permit.take(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
LocalExecutionRequestOutcome::Exhausted(outcome) => {
|
||||||
if let Some(execution_runtime_response) =
|
local_execution_exhaustion = Some(outcome);
|
||||||
maybe_execute_sync_request(&state, &parts, buffered_body, &trace_id, control_decision)
|
}
|
||||||
.await?
|
LocalExecutionRequestOutcome::NoPath => {}
|
||||||
{
|
|
||||||
state.clear_local_execution_runtime_miss_diagnostic(&trace_id);
|
|
||||||
return Ok(finalize_gateway_response_with_context(
|
|
||||||
&state,
|
|
||||||
execution_runtime_response,
|
|
||||||
&remote_addr,
|
|
||||||
&request_context,
|
|
||||||
EXECUTION_PATH_EXECUTION_RUNTIME_SYNC,
|
|
||||||
&started_at,
|
|
||||||
request_permit.take(),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
if parts.method != http::Method::POST {
|
if parts.method != http::Method::POST {
|
||||||
if let Some(execution_runtime_response) = maybe_execute_stream_request(
|
match maybe_execute_stream_request(
|
||||||
&state,
|
&state,
|
||||||
&parts,
|
&parts,
|
||||||
buffered_body,
|
buffered_body,
|
||||||
@@ -692,20 +706,26 @@ pub(crate) async fn proxy_request(
|
|||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
state.clear_local_execution_runtime_miss_diagnostic(&trace_id);
|
LocalExecutionRequestOutcome::Responded(execution_runtime_response) => {
|
||||||
return Ok(finalize_gateway_response_with_context(
|
state.clear_local_execution_runtime_miss_diagnostic(&trace_id);
|
||||||
&state,
|
return Ok(finalize_gateway_response_with_context(
|
||||||
execution_runtime_response,
|
&state,
|
||||||
&remote_addr,
|
execution_runtime_response,
|
||||||
&request_context,
|
&remote_addr,
|
||||||
EXECUTION_PATH_EXECUTION_RUNTIME_STREAM,
|
&request_context,
|
||||||
&started_at,
|
EXECUTION_PATH_EXECUTION_RUNTIME_STREAM,
|
||||||
request_permit.take(),
|
&started_at,
|
||||||
));
|
request_permit.take(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
LocalExecutionRequestOutcome::Exhausted(outcome) => {
|
||||||
|
local_execution_exhaustion = Some(outcome);
|
||||||
|
}
|
||||||
|
LocalExecutionRequestOutcome::NoPath => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if allow_control_execute_fallback {
|
if allow_control_execute_fallback {
|
||||||
if let Some(control_response) = maybe_execute_via_control(
|
match maybe_execute_via_control(
|
||||||
&state,
|
&state,
|
||||||
&parts,
|
&parts,
|
||||||
buffered_body.clone(),
|
buffered_body.clone(),
|
||||||
@@ -715,41 +735,47 @@ pub(crate) async fn proxy_request(
|
|||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
let reason = GatewayFallbackReason::ControlExecuteEmergency;
|
LocalExecutionRequestOutcome::Responded(control_response) => {
|
||||||
let control_execution_path = if stream_request {
|
let reason = GatewayFallbackReason::ControlExecuteEmergency;
|
||||||
EXECUTION_PATH_CONTROL_EXECUTE_STREAM
|
let control_execution_path = if stream_request {
|
||||||
} else {
|
EXECUTION_PATH_CONTROL_EXECUTE_STREAM
|
||||||
EXECUTION_PATH_CONTROL_EXECUTE_SYNC
|
} else {
|
||||||
};
|
EXECUTION_PATH_CONTROL_EXECUTE_SYNC
|
||||||
state.record_fallback_metric(
|
};
|
||||||
GatewayFallbackMetricKind::ControlExecuteFallback,
|
state.record_fallback_metric(
|
||||||
control_decision,
|
GatewayFallbackMetricKind::ControlExecuteFallback,
|
||||||
None,
|
control_decision,
|
||||||
Some(control_execution_path),
|
None,
|
||||||
reason,
|
Some(control_execution_path),
|
||||||
);
|
reason,
|
||||||
state.record_fallback_metric(
|
);
|
||||||
GatewayFallbackMetricKind::RemoteExecuteEmergency,
|
state.record_fallback_metric(
|
||||||
control_decision,
|
GatewayFallbackMetricKind::RemoteExecuteEmergency,
|
||||||
None,
|
control_decision,
|
||||||
Some(control_execution_path),
|
None,
|
||||||
reason,
|
Some(control_execution_path),
|
||||||
);
|
reason,
|
||||||
let mut control_response = control_response;
|
);
|
||||||
state.clear_local_execution_runtime_miss_diagnostic(&trace_id);
|
let mut control_response = control_response;
|
||||||
control_response.headers_mut().insert(
|
state.clear_local_execution_runtime_miss_diagnostic(&trace_id);
|
||||||
HeaderName::from_static(DEPENDENCY_REASON_HEADER),
|
control_response.headers_mut().insert(
|
||||||
HeaderValue::from_static(reason.as_label_value()),
|
HeaderName::from_static(DEPENDENCY_REASON_HEADER),
|
||||||
);
|
HeaderValue::from_static(reason.as_label_value()),
|
||||||
return Ok(finalize_gateway_response_with_context(
|
);
|
||||||
&state,
|
return Ok(finalize_gateway_response_with_context(
|
||||||
control_response,
|
&state,
|
||||||
&remote_addr,
|
control_response,
|
||||||
&request_context,
|
&remote_addr,
|
||||||
control_execution_path,
|
&request_context,
|
||||||
&started_at,
|
control_execution_path,
|
||||||
request_permit.take(),
|
&started_at,
|
||||||
));
|
request_permit.take(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
LocalExecutionRequestOutcome::Exhausted(outcome) => {
|
||||||
|
local_execution_exhaustion = Some(outcome);
|
||||||
|
}
|
||||||
|
LocalExecutionRequestOutcome::NoPath => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let local_execution_runtime_miss_detail =
|
let local_execution_runtime_miss_detail =
|
||||||
@@ -779,6 +805,16 @@ pub(crate) async fn proxy_request(
|
|||||||
"gateway local execution runtime miss"
|
"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(
|
let mut response = build_local_http_error_response(
|
||||||
&trace_id,
|
&trace_id,
|
||||||
control_decision,
|
control_decision,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
|
use aether_billing::normalize_input_tokens_for_billing;
|
||||||
use aether_data_contracts::repository::usage::{StoredRequestUsageAudit, UsageAuditListQuery};
|
use aether_data_contracts::repository::usage::{StoredRequestUsageAudit, UsageAuditListQuery};
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Body,
|
body::Body,
|
||||||
@@ -88,11 +89,33 @@ fn parse_users_me_usage_ids(query: Option<&str>) -> Option<BTreeSet<String>> {
|
|||||||
(!values.is_empty()).then_some(values)
|
(!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 {
|
fn users_me_usage_total_input_context(item: &StoredRequestUsageAudit) -> u64 {
|
||||||
item.input_tokens
|
item.input_tokens
|
||||||
|
.saturating_add(users_me_usage_cache_creation_tokens(item))
|
||||||
.saturating_add(item.cache_read_input_tokens)
|
.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 {
|
fn users_me_usage_effective_unix_secs(item: &StoredRequestUsageAudit) -> u64 {
|
||||||
item.finalized_at_unix_secs
|
item.finalized_at_unix_secs
|
||||||
.unwrap_or(item.created_at_unix_ms)
|
.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,
|
"endpoint_api_format": item.endpoint_api_format,
|
||||||
"has_format_conversion": item.has_format_conversion,
|
"has_format_conversion": item.has_format_conversion,
|
||||||
"input_tokens": item.input_tokens,
|
"input_tokens": item.input_tokens,
|
||||||
|
"effective_input_tokens": users_me_usage_effective_input_tokens(item),
|
||||||
"output_tokens": item.output_tokens,
|
"output_tokens": item.output_tokens,
|
||||||
"total_tokens": item.total_tokens,
|
"total_tokens": item.total_tokens,
|
||||||
"cost": round_to(item.total_cost_usd, 6),
|
"cost": round_to(item.total_cost_usd, 6),
|
||||||
@@ -161,6 +185,8 @@ fn build_users_me_usage_record_payload(
|
|||||||
"status": item.status,
|
"status": item.status,
|
||||||
"created_at": unix_secs_to_rfc3339(item.created_at_unix_ms),
|
"created_at": unix_secs_to_rfc3339(item.created_at_unix_ms),
|
||||||
"cache_creation_input_tokens": item.cache_creation_input_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,
|
"cache_read_input_tokens": item.cache_read_input_tokens,
|
||||||
"status_code": item.status_code,
|
"status_code": item.status_code,
|
||||||
"error_message": item.error_message,
|
"error_message": item.error_message,
|
||||||
@@ -186,8 +212,11 @@ fn build_users_me_usage_active_payload(item: &StoredRequestUsageAudit) -> serde_
|
|||||||
"id": item.id,
|
"id": item.id,
|
||||||
"status": item.status,
|
"status": item.status,
|
||||||
"input_tokens": item.input_tokens,
|
"input_tokens": item.input_tokens,
|
||||||
|
"effective_input_tokens": users_me_usage_effective_input_tokens(item),
|
||||||
"output_tokens": item.output_tokens,
|
"output_tokens": item.output_tokens,
|
||||||
"cache_creation_input_tokens": item.cache_creation_input_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,
|
"cache_read_input_tokens": item.cache_read_input_tokens,
|
||||||
"cost": round_to(item.total_cost_usd, 6),
|
"cost": round_to(item.total_cost_usd, 6),
|
||||||
"actual_cost": round_to(item.actual_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,
|
"model": item.model,
|
||||||
"requests": 0_u64,
|
"requests": 0_u64,
|
||||||
"input_tokens": 0_u64,
|
"input_tokens": 0_u64,
|
||||||
|
"effective_input_tokens": 0_u64,
|
||||||
"output_tokens": 0_u64,
|
"output_tokens": 0_u64,
|
||||||
"total_tokens": 0_u64,
|
"total_tokens": 0_u64,
|
||||||
"cache_read_tokens": 0_u64,
|
"cache_read_tokens": 0_u64,
|
||||||
"cache_creation_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,
|
"total_input_context": 0_u64,
|
||||||
"cache_hit_rate": 0.0,
|
"cache_hit_rate": 0.0,
|
||||||
"total_cost_usd": 0.0,
|
"total_cost_usd": 0.0,
|
||||||
@@ -245,6 +277,10 @@ fn build_users_me_usage_summary_by_model(
|
|||||||
.as_u64()
|
.as_u64()
|
||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
.saturating_add(item.input_tokens));
|
.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"]
|
entry["output_tokens"] = json!(entry["output_tokens"]
|
||||||
.as_u64()
|
.as_u64()
|
||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
@@ -260,7 +296,17 @@ fn build_users_me_usage_summary_by_model(
|
|||||||
entry["cache_creation_tokens"] = json!(entry["cache_creation_tokens"]
|
entry["cache_creation_tokens"] = json!(entry["cache_creation_tokens"]
|
||||||
.as_u64()
|
.as_u64()
|
||||||
.unwrap_or(0)
|
.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"]
|
entry["total_input_context"] = json!(entry["total_input_context"]
|
||||||
.as_u64()
|
.as_u64()
|
||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
@@ -320,11 +366,14 @@ fn build_users_me_usage_summary_by_provider(
|
|||||||
json!({
|
json!({
|
||||||
"provider": item.provider_name,
|
"provider": item.provider_name,
|
||||||
"requests": 0_u64,
|
"requests": 0_u64,
|
||||||
|
"effective_input_tokens": 0_u64,
|
||||||
"total_tokens": 0_u64,
|
"total_tokens": 0_u64,
|
||||||
"total_input_context": 0_u64,
|
"total_input_context": 0_u64,
|
||||||
"output_tokens": 0_u64,
|
"output_tokens": 0_u64,
|
||||||
"cache_read_tokens": 0_u64,
|
"cache_read_tokens": 0_u64,
|
||||||
"cache_creation_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,
|
"cache_hit_rate": 0.0,
|
||||||
"total_cost_usd": 0.0,
|
"total_cost_usd": 0.0,
|
||||||
"success_rate": 0.0,
|
"success_rate": 0.0,
|
||||||
@@ -343,6 +392,10 @@ fn build_users_me_usage_summary_by_provider(
|
|||||||
.as_u64()
|
.as_u64()
|
||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
.saturating_add(users_me_usage_total_input_context(item)));
|
.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"]
|
entry["output_tokens"] = json!(entry["output_tokens"]
|
||||||
.as_u64()
|
.as_u64()
|
||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
@@ -354,7 +407,17 @@ fn build_users_me_usage_summary_by_provider(
|
|||||||
entry["cache_creation_tokens"] = json!(entry["cache_creation_tokens"]
|
entry["cache_creation_tokens"] = json!(entry["cache_creation_tokens"]
|
||||||
.as_u64()
|
.as_u64()
|
||||||
.unwrap_or(0)
|
.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"] =
|
entry["total_cost_usd"] =
|
||||||
json!(entry["total_cost_usd"].as_f64().unwrap_or(0.0) + item.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,
|
"api_format": api_format,
|
||||||
"request_count": 0_u64,
|
"request_count": 0_u64,
|
||||||
"total_tokens": 0_u64,
|
"total_tokens": 0_u64,
|
||||||
|
"effective_input_tokens": 0_u64,
|
||||||
"total_input_context": 0_u64,
|
"total_input_context": 0_u64,
|
||||||
"output_tokens": 0_u64,
|
"output_tokens": 0_u64,
|
||||||
"cache_read_tokens": 0_u64,
|
"cache_read_tokens": 0_u64,
|
||||||
"cache_creation_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,
|
"cache_hit_rate": 0.0,
|
||||||
"total_cost_usd": 0.0,
|
"total_cost_usd": 0.0,
|
||||||
"avg_response_time_ms": 0.0,
|
"avg_response_time_ms": 0.0,
|
||||||
@@ -468,6 +534,10 @@ fn build_users_me_usage_summary_by_api_format(
|
|||||||
.as_u64()
|
.as_u64()
|
||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
.saturating_add(users_me_usage_total_input_context(item)));
|
.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"]
|
entry["output_tokens"] = json!(entry["output_tokens"]
|
||||||
.as_u64()
|
.as_u64()
|
||||||
.unwrap_or(0)
|
.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"]
|
entry["cache_creation_tokens"] = json!(entry["cache_creation_tokens"]
|
||||||
.as_u64()
|
.as_u64()
|
||||||
.unwrap_or(0)
|
.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"] =
|
entry["total_cost_usd"] =
|
||||||
json!(entry["total_cost_usd"].as_f64().unwrap_or(0.0) + item.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 {
|
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,
|
url: String,
|
||||||
model: String,
|
model: String,
|
||||||
auth_header_value: String,
|
auth_header_value: String,
|
||||||
|
anthropic_version: String,
|
||||||
|
anthropic_beta: String,
|
||||||
endpoint_tag: String,
|
endpoint_tag: String,
|
||||||
metadata_mode: String,
|
metadata_mode: String,
|
||||||
metadata_source: 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())
|
.and_then(|value| value.as_str())
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.to_string(),
|
.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
|
endpoint_tag: payload
|
||||||
.get("headers")
|
.get("headers")
|
||||||
.and_then(|value| value.get("x-endpoint-tag"))
|
.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"))
|
.post(format!("{gateway_url}/v1/messages"))
|
||||||
.header(http::header::CONTENT_TYPE, "application/json")
|
.header(http::header::CONTENT_TYPE, "application/json")
|
||||||
.header("x-api-key", "sk-client-claude-chat-local")
|
.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")
|
.header(TRACE_ID_HEADER, "trace-claude-chat-local-123")
|
||||||
.body(
|
.body(
|
||||||
"{\"model\":\"claude-sonnet-4-5\",\"messages\":[],\"metadata\":{\"client\":\"desktop-claude\"}}",
|
"{\"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,
|
seen_execution_runtime_request.auth_header_value,
|
||||||
"sk-upstream-claude-chat"
|
"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!(
|
assert_eq!(
|
||||||
seen_execution_runtime_request.endpoint_tag,
|
seen_execution_runtime_request.endpoint_tag,
|
||||||
"claude-chat-local"
|
"claude-chat-local"
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ async fn gateway_executes_claude_cli_sync_via_local_decision_gate_with_local_syn
|
|||||||
url: String,
|
url: String,
|
||||||
model: String,
|
model: String,
|
||||||
authorization: String,
|
authorization: String,
|
||||||
|
anthropic_version: String,
|
||||||
|
anthropic_beta: String,
|
||||||
endpoint_tag: String,
|
endpoint_tag: String,
|
||||||
metadata_mode: String,
|
metadata_mode: String,
|
||||||
metadata_source: 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())
|
.and_then(|value| value.as_str())
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.to_string(),
|
.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
|
endpoint_tag: payload
|
||||||
.get("headers")
|
.get("headers")
|
||||||
.and_then(|value| value.get("x-endpoint-tag"))
|
.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,
|
http::header::AUTHORIZATION,
|
||||||
"Bearer sk-client-claude-cli-local",
|
"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")
|
.header(TRACE_ID_HEADER, "trace-claude-cli-local-sync-123")
|
||||||
.body(
|
.body(
|
||||||
"{\"model\":\"claude-code\",\"messages\":[],\"metadata\":{\"client\":\"desktop-claude-cli\"}}",
|
"{\"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,
|
seen_execution_runtime_request.authorization,
|
||||||
"Bearer sk-upstream-claude-cli"
|
"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!(
|
assert_eq!(
|
||||||
seen_execution_runtime_request.endpoint_tag,
|
seen_execution_runtime_request.endpoint_tag,
|
||||||
"claude-cli-local"
|
"claude-cli-local"
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ fn sample_usage_row(
|
|||||||
actual_total_cost_usd: f64,
|
actual_total_cost_usd: f64,
|
||||||
created_at_unix_ms: i64,
|
created_at_unix_ms: i64,
|
||||||
) -> StoredRequestUsageAudit {
|
) -> StoredRequestUsageAudit {
|
||||||
StoredRequestUsageAudit::new(
|
let mut usage = StoredRequestUsageAudit::new(
|
||||||
id.to_string(),
|
id.to_string(),
|
||||||
request_id.to_string(),
|
request_id.to_string(),
|
||||||
user_id.map(str::to_string),
|
user_id.map(str::to_string),
|
||||||
@@ -190,7 +190,10 @@ fn sample_usage_row(
|
|||||||
Some(created_at_unix_ms + 2),
|
Some(created_at_unix_ms + 2),
|
||||||
)
|
)
|
||||||
.expect("usage row should build")
|
.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 {
|
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["total_tokens"], 240);
|
||||||
assert_eq!(payload["error_count"], 1);
|
assert_eq!(payload["error_count"], 1);
|
||||||
assert_eq!(payload["cache_stats"]["cache_creation_tokens"], 30);
|
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!(payload["cache_stats"]["cache_read_tokens"], 10);
|
||||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||||
|
|
||||||
@@ -296,52 +307,66 @@ async fn gateway_handles_admin_usage_aggregation_stats_locally_with_trusted_admi
|
|||||||
let (upstream_url, upstream_hits, upstream_handle) =
|
let (upstream_url, upstream_hits, upstream_handle) =
|
||||||
start_usage_upstream("/api/admin/usage/aggregation/stats").await;
|
start_usage_upstream("/api/admin/usage/aggregation/stats").await;
|
||||||
|
|
||||||
|
let mut usage_1 = sample_usage_row(
|
||||||
|
"usage-1",
|
||||||
|
"req-1",
|
||||||
|
Some("user-1"),
|
||||||
|
Some("key-1"),
|
||||||
|
Some("primary"),
|
||||||
|
"OpenAI",
|
||||||
|
"gpt-5",
|
||||||
|
"completed",
|
||||||
|
120,
|
||||||
|
30,
|
||||||
|
0.3,
|
||||||
|
0.36,
|
||||||
|
DAY_1_UNIX_SECS,
|
||||||
|
);
|
||||||
|
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"),
|
||||||
|
Some("key-2"),
|
||||||
|
Some("secondary"),
|
||||||
|
"OpenAI",
|
||||||
|
"gpt-5",
|
||||||
|
"completed",
|
||||||
|
40,
|
||||||
|
10,
|
||||||
|
0.1,
|
||||||
|
0.12,
|
||||||
|
DAY_2_UNIX_SECS,
|
||||||
|
);
|
||||||
|
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"),
|
||||||
|
Some("key-2"),
|
||||||
|
Some("secondary"),
|
||||||
|
"Anthropic",
|
||||||
|
"claude-3-7",
|
||||||
|
"completed",
|
||||||
|
60,
|
||||||
|
20,
|
||||||
|
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![
|
let usage_repository = Arc::new(InMemoryUsageReadRepository::seed(vec![
|
||||||
sample_usage_row(
|
usage_1, usage_2, usage_3,
|
||||||
"usage-1",
|
|
||||||
"req-1",
|
|
||||||
Some("user-1"),
|
|
||||||
Some("key-1"),
|
|
||||||
Some("primary"),
|
|
||||||
"OpenAI",
|
|
||||||
"gpt-5",
|
|
||||||
"completed",
|
|
||||||
120,
|
|
||||||
30,
|
|
||||||
0.3,
|
|
||||||
0.36,
|
|
||||||
DAY_1_UNIX_SECS,
|
|
||||||
),
|
|
||||||
sample_usage_row(
|
|
||||||
"usage-2",
|
|
||||||
"req-2",
|
|
||||||
Some("user-2"),
|
|
||||||
Some("key-2"),
|
|
||||||
Some("secondary"),
|
|
||||||
"OpenAI",
|
|
||||||
"gpt-5",
|
|
||||||
"completed",
|
|
||||||
40,
|
|
||||||
10,
|
|
||||||
0.1,
|
|
||||||
0.12,
|
|
||||||
DAY_2_UNIX_SECS,
|
|
||||||
),
|
|
||||||
sample_usage_row(
|
|
||||||
"usage-3",
|
|
||||||
"req-3",
|
|
||||||
Some("user-2"),
|
|
||||||
Some("key-2"),
|
|
||||||
Some("secondary"),
|
|
||||||
"Anthropic",
|
|
||||||
"claude-3-7",
|
|
||||||
"completed",
|
|
||||||
60,
|
|
||||||
20,
|
|
||||||
0.2,
|
|
||||||
0.24,
|
|
||||||
DAY_2_UNIX_SECS,
|
|
||||||
),
|
|
||||||
]));
|
]));
|
||||||
|
|
||||||
let gateway = build_router_with_state(
|
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.len(), 2);
|
||||||
assert_eq!(items[0]["model"], "gpt-5");
|
assert_eq!(items[0]["model"], "gpt-5");
|
||||||
assert_eq!(items[0]["request_count"], 2);
|
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]["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);
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||||
|
|
||||||
gateway_handle.abort();
|
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");
|
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"].as_array().expect("array").len(), 1);
|
||||||
assert_eq!(payload["requests"][0]["id"], "usage-pending");
|
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"], "OpenAI");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
payload["requests"][0]["provider_key_name"],
|
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"],
|
payload["records"][0]["provider_key_name"],
|
||||||
"upstream-primary"
|
"upstream-primary"
|
||||||
);
|
);
|
||||||
|
assert_eq!(payload["records"][0]["effective_input_tokens"], 35);
|
||||||
assert_eq!(payload["records"][0]["first_byte_time_ms"], 120);
|
assert_eq!(payload["records"][0]["first_byte_time_ms"], 120);
|
||||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
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["api_key"]["name"], "primary");
|
||||||
assert_eq!(payload["provider"], "OpenAI");
|
assert_eq!(payload["provider"], "OpenAI");
|
||||||
assert_eq!(payload["model"], "gpt-5");
|
assert_eq!(payload["model"], "gpt-5");
|
||||||
|
assert_eq!(payload["effective_input_tokens"], 115);
|
||||||
assert_eq!(payload["total_tokens"], 170);
|
assert_eq!(payload["total_tokens"], 170);
|
||||||
assert_eq!(payload["cache_creation_cost"], 0.0);
|
assert_eq!(payload["cache_creation_cost"], 0.0);
|
||||||
assert_eq!(payload["cache_read_cost"], 0.0);
|
assert_eq!(payload["cache_read_cost"], 0.0);
|
||||||
|
|||||||
@@ -2387,6 +2387,8 @@ fn sample_user_usage_audit(
|
|||||||
)
|
)
|
||||||
.expect("usage should build");
|
.expect("usage should build");
|
||||||
usage.cache_creation_input_tokens = 10;
|
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_read_input_tokens = 15;
|
||||||
usage.cache_creation_cost_usd = 0.05;
|
usage.cache_creation_cost_usd = 0.05;
|
||||||
usage.cache_read_cost_usd = 0.02;
|
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(),
|
payload["records"].as_array().expect("records array").len(),
|
||||||
3
|
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!(
|
assert_eq!(
|
||||||
payload["summary_by_model"]
|
payload["summary_by_model"]
|
||||||
.as_array()
|
.as_array()
|
||||||
@@ -4740,6 +4751,19 @@ async fn gateway_handles_users_me_usage_locally_without_proxying_upstream() {
|
|||||||
.len(),
|
.len(),
|
||||||
2
|
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!(payload["billing"]["id"], "wallet-auth-1");
|
||||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
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");
|
let requests = payload["requests"].as_array().expect("requests array");
|
||||||
assert_eq!(requests.len(), 2);
|
assert_eq!(requests.len(), 2);
|
||||||
assert_eq!(requests[0]["status"], "streaming");
|
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);
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||||
|
|
||||||
gateway_handle.abort();
|
gateway_handle.abort();
|
||||||
|
|||||||
@@ -183,6 +183,212 @@ async fn gateway_handles_local_openai_chat_sync_report_with_local_reporting_when
|
|||||||
upstream_handle.abort();
|
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]
|
#[tokio::test]
|
||||||
async fn gateway_handles_local_openai_chat_stream_report_with_local_reporting_when_usage_runtime_enabled(
|
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"] }
|
clap = { version = "4", features = ["derive", "env"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json.workspace = true
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
bytes = "1"
|
bytes = "1"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ repository.workspace = true
|
|||||||
description = "Shared admin contracts and pure helpers for Aether"
|
description = "Shared admin contracts and pure helpers for Aether"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
aether-billing.workspace = true
|
||||||
aether-contracts.workspace = true
|
aether-contracts.workspace = true
|
||||||
aether-data.workspace = true
|
aether-data.workspace = true
|
||||||
aether-data-contracts.workspace = true
|
aether-data-contracts.workspace = true
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use crate::observability::stats::{aggregate_usage_stats, parse_bounded_u32, round_to};
|
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::repository::users::StoredUserSummary;
|
||||||
use aether_data_contracts::repository::{
|
use aether_data_contracts::repository::{
|
||||||
provider_catalog::{StoredProviderCatalogEndpoint, StoredProviderCatalogProvider},
|
provider_catalog::{StoredProviderCatalogEndpoint, StoredProviderCatalogProvider},
|
||||||
@@ -257,8 +258,11 @@ pub fn admin_usage_record_json(
|
|||||||
"model": item.model,
|
"model": item.model,
|
||||||
"target_model": item.target_model,
|
"target_model": item.target_model,
|
||||||
"input_tokens": item.input_tokens,
|
"input_tokens": item.input_tokens,
|
||||||
|
"effective_input_tokens": admin_usage_effective_input_tokens(item),
|
||||||
"output_tokens": item.output_tokens,
|
"output_tokens": item.output_tokens,
|
||||||
"cache_creation_input_tokens": item.cache_creation_input_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,
|
"cache_read_input_tokens": item.cache_read_input_tokens,
|
||||||
"total_tokens": admin_usage_total_tokens(item),
|
"total_tokens": admin_usage_total_tokens(item),
|
||||||
"cost": round_to(item.total_cost_usd, 6),
|
"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 {
|
pub fn admin_usage_total_tokens(item: &StoredRequestUsageAudit) -> u64 {
|
||||||
item.input_tokens
|
item.input_tokens
|
||||||
.saturating_add(item.output_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)
|
.saturating_add(item.cache_read_input_tokens)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn admin_usage_token_cache_hit_rate(input_tokens: u64, cache_read_tokens: u64) -> f64 {
|
pub fn admin_usage_cache_creation_tokens(item: &StoredRequestUsageAudit) -> u64 {
|
||||||
let total_input_context = input_tokens.saturating_add(cache_read_tokens);
|
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 {
|
if total_input_context == 0 {
|
||||||
0.0
|
0.0
|
||||||
} else {
|
} 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(
|
pub fn admin_usage_aggregation_by_model_json(
|
||||||
usage: &[StoredRequestUsageAudit],
|
usage: &[StoredRequestUsageAudit],
|
||||||
limit: usize,
|
limit: usize,
|
||||||
) -> Value {
|
) -> 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 {
|
for item in usage {
|
||||||
let key = item.model.clone();
|
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.0 = entry.0.saturating_add(1);
|
||||||
entry.1 = entry.1.saturating_add(item.total_tokens);
|
entry.1 = entry.1.saturating_add(item.total_tokens);
|
||||||
entry.2 = entry.2.saturating_add(item.input_tokens);
|
entry.2 = entry.2.saturating_add(item.input_tokens);
|
||||||
entry.3 = entry.3.saturating_add(item.cache_read_input_tokens);
|
entry.3 = entry.3.saturating_add(item.output_tokens);
|
||||||
entry.4 += item.total_cost_usd;
|
entry.4 = entry
|
||||||
entry.5 += item.actual_total_cost_usd;
|
.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
|
let mut items: Vec<Value> = grouped
|
||||||
@@ -331,6 +387,11 @@ pub fn admin_usage_aggregation_by_model_json(
|
|||||||
request_count,
|
request_count,
|
||||||
total_tokens,
|
total_tokens,
|
||||||
input_tokens,
|
input_tokens,
|
||||||
|
output_tokens,
|
||||||
|
effective_input_tokens,
|
||||||
|
cache_creation_tokens,
|
||||||
|
cache_creation_ephemeral_5m_tokens,
|
||||||
|
cache_creation_ephemeral_1h_tokens,
|
||||||
cache_read_tokens,
|
cache_read_tokens,
|
||||||
total_cost,
|
total_cost,
|
||||||
actual_cost,
|
actual_cost,
|
||||||
@@ -340,13 +401,23 @@ pub fn admin_usage_aggregation_by_model_json(
|
|||||||
"model": model,
|
"model": model,
|
||||||
"request_count": request_count,
|
"request_count": request_count,
|
||||||
"total_tokens": total_tokens,
|
"total_tokens": total_tokens,
|
||||||
"total_input_context": input_tokens.saturating_add(cache_read_tokens),
|
"effective_input_tokens": effective_input_tokens,
|
||||||
"output_tokens": total_tokens.saturating_sub(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),
|
"total_cost": round_to(total_cost, 6),
|
||||||
"actual_cost": round_to(actual_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_read_tokens": cache_read_tokens,
|
||||||
"cache_creation_tokens": 0,
|
"cache_hit_rate": admin_usage_token_cache_hit_rate(
|
||||||
"cache_hit_rate": admin_usage_token_cache_hit_rate(input_tokens, cache_read_tokens),
|
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,
|
limit: usize,
|
||||||
) -> Value {
|
) -> Value {
|
||||||
#[allow(clippy::type_complexity)]
|
#[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 {
|
for item in usage {
|
||||||
let key = item
|
let key = item
|
||||||
.provider_id
|
.provider_id
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| "unknown".to_string());
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
let entry = grouped.entry(key).or_insert((0, 0, 0, 0, 0.0, 0.0, 0, 0));
|
let provider_name =
|
||||||
entry.0 = entry.0.saturating_add(1);
|
admin_usage_provider_display_name(item).unwrap_or_else(|| "Unknown".to_string());
|
||||||
entry.1 = entry.1.saturating_add(item.total_tokens);
|
let entry = grouped.entry(key).or_insert((
|
||||||
entry.2 = entry.2.saturating_add(item.input_tokens);
|
provider_name.clone(),
|
||||||
entry.3 = entry.3.saturating_add(item.cache_read_input_tokens);
|
0,
|
||||||
entry.4 += item.total_cost_usd;
|
0,
|
||||||
entry.5 += item.actual_total_cost_usd;
|
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
|
entry.6 = entry
|
||||||
.6
|
.6
|
||||||
.saturating_add(item.response_time_ms.unwrap_or_default());
|
.saturating_add(admin_usage_cache_creation_tokens(item));
|
||||||
entry.7 = entry
|
entry.7 = entry
|
||||||
.7
|
.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 });
|
.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_id,
|
||||||
(
|
(
|
||||||
|
provider_name,
|
||||||
request_count,
|
request_count,
|
||||||
total_tokens,
|
total_tokens,
|
||||||
input_tokens,
|
input_tokens,
|
||||||
|
output_tokens,
|
||||||
|
effective_input_tokens,
|
||||||
|
cache_creation_tokens,
|
||||||
|
cache_creation_ephemeral_5m_tokens,
|
||||||
|
cache_creation_ephemeral_1h_tokens,
|
||||||
cache_read_tokens,
|
cache_read_tokens,
|
||||||
total_cost,
|
total_cost,
|
||||||
actual_cost,
|
actual_cost,
|
||||||
@@ -422,19 +550,29 @@ pub fn admin_usage_aggregation_by_provider_json(
|
|||||||
};
|
};
|
||||||
json!({
|
json!({
|
||||||
"provider_id": provider_id,
|
"provider_id": provider_id,
|
||||||
"provider": Value::Null,
|
"provider": provider_name,
|
||||||
"request_count": request_count,
|
"request_count": request_count,
|
||||||
"total_tokens": total_tokens,
|
"total_tokens": total_tokens,
|
||||||
"total_input_context": input_tokens.saturating_add(cache_read_tokens),
|
"effective_input_tokens": effective_input_tokens,
|
||||||
"output_tokens": total_tokens.saturating_sub(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),
|
"total_cost": round_to(total_cost, 6),
|
||||||
"actual_cost": round_to(actual_cost, 6),
|
"actual_cost": round_to(actual_cost, 6),
|
||||||
"avg_response_time_ms": avg_response_time_ms,
|
"avg_response_time_ms": avg_response_time_ms,
|
||||||
"success_rate": success_rate,
|
"success_rate": success_rate,
|
||||||
"error_count": error_count,
|
"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_read_tokens": cache_read_tokens,
|
||||||
"cache_creation_tokens": 0,
|
"cache_hit_rate": admin_usage_token_cache_hit_rate(
|
||||||
"cache_hit_rate": admin_usage_token_cache_hit_rate(input_tokens, cache_read_tokens),
|
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,
|
limit: usize,
|
||||||
) -> Value {
|
) -> Value {
|
||||||
#[allow(clippy::type_complexity)]
|
#[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 {
|
for item in usage {
|
||||||
let key = item
|
let key = item
|
||||||
.api_format
|
.api_format
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| "unknown".to_string());
|
.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.0 = entry.0.saturating_add(1);
|
||||||
entry.1 = entry.1.saturating_add(item.total_tokens);
|
entry.1 = entry.1.saturating_add(item.total_tokens);
|
||||||
entry.2 = entry.2.saturating_add(item.input_tokens);
|
entry.2 = entry.2.saturating_add(item.input_tokens);
|
||||||
entry.3 = entry.3.saturating_add(item.cache_read_input_tokens);
|
entry.3 = entry.3.saturating_add(item.output_tokens);
|
||||||
entry.4 += item.total_cost_usd;
|
entry.4 = entry
|
||||||
entry.5 += item.actual_total_cost_usd;
|
.4
|
||||||
|
.saturating_add(admin_usage_effective_input_tokens(item));
|
||||||
|
entry.5 = entry
|
||||||
|
.5
|
||||||
|
.saturating_add(admin_usage_cache_creation_tokens(item));
|
||||||
entry.6 = entry
|
entry.6 = entry
|
||||||
.6
|
.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());
|
.saturating_add(item.response_time_ms.unwrap_or_default());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,6 +643,11 @@ pub fn admin_usage_aggregation_by_api_format_json(
|
|||||||
request_count,
|
request_count,
|
||||||
total_tokens,
|
total_tokens,
|
||||||
input_tokens,
|
input_tokens,
|
||||||
|
output_tokens,
|
||||||
|
effective_input_tokens,
|
||||||
|
cache_creation_tokens,
|
||||||
|
cache_creation_ephemeral_5m_tokens,
|
||||||
|
cache_creation_ephemeral_1h_tokens,
|
||||||
cache_read_tokens,
|
cache_read_tokens,
|
||||||
total_cost,
|
total_cost,
|
||||||
actual_cost,
|
actual_cost,
|
||||||
@@ -502,14 +663,24 @@ pub fn admin_usage_aggregation_by_api_format_json(
|
|||||||
"api_format": api_format,
|
"api_format": api_format,
|
||||||
"request_count": request_count,
|
"request_count": request_count,
|
||||||
"total_tokens": total_tokens,
|
"total_tokens": total_tokens,
|
||||||
"total_input_context": input_tokens.saturating_add(cache_read_tokens),
|
"effective_input_tokens": effective_input_tokens,
|
||||||
"output_tokens": total_tokens.saturating_sub(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),
|
"total_cost": round_to(total_cost, 6),
|
||||||
"actual_cost": round_to(actual_cost, 6),
|
"actual_cost": round_to(actual_cost, 6),
|
||||||
"avg_response_time_ms": avg_response_time_ms,
|
"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_read_tokens": cache_read_tokens,
|
||||||
"cache_creation_tokens": 0,
|
"cache_hit_rate": admin_usage_token_cache_hit_rate(
|
||||||
"cache_hit_rate": admin_usage_token_cache_hit_rate(input_tokens, cache_read_tokens),
|
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],
|
usage: &[StoredRequestUsageAudit],
|
||||||
) -> Response<Body> {
|
) -> Response<Body> {
|
||||||
let aggregate = aggregate_usage_stats(usage);
|
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()
|
.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();
|
.sum();
|
||||||
let cache_read_tokens: u64 = usage.iter().map(|item| item.cache_read_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();
|
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,
|
"error_rate": error_rate,
|
||||||
"cache_stats": {
|
"cache_stats": {
|
||||||
"cache_creation_tokens": cache_creation_tokens,
|
"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_read_tokens": cache_read_tokens,
|
||||||
"cache_creation_cost": round_to(cache_creation_cost, 6),
|
"cache_creation_cost": round_to(cache_creation_cost, 6),
|
||||||
"cache_read_cost": round_to(cache_read_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,
|
"id": item.id,
|
||||||
"status": item.status,
|
"status": item.status,
|
||||||
"input_tokens": item.input_tokens,
|
"input_tokens": item.input_tokens,
|
||||||
|
"effective_input_tokens": admin_usage_effective_input_tokens(item),
|
||||||
"output_tokens": item.output_tokens,
|
"output_tokens": item.output_tokens,
|
||||||
"cache_creation_input_tokens": item.cache_creation_input_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,
|
"cache_read_input_tokens": item.cache_read_input_tokens,
|
||||||
"cost": round_to(item.total_cost_usd, 6),
|
"cost": round_to(item.total_cost_usd, 6),
|
||||||
"actual_cost": round_to(item.actual_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) {
|
if let Some(account_id) = extract_codex_account_id(decrypted_auth_config_raw) {
|
||||||
provider_request_headers.insert("chatgpt-account-id".to_string(), account_id);
|
provider_request_headers.insert("chatgpt-account-id".to_string(), account_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
if !provider_request_headers
|
if !provider_request_headers
|
||||||
.get("x-client-request-id")
|
.get("x-client-request-id")
|
||||||
.map(|value| !value.trim().is_empty())
|
.map(|value| !value.trim().is_empty())
|
||||||
@@ -200,6 +201,7 @@ pub fn apply_codex_openai_cli_special_headers(
|
|||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|value| !value.is_empty());
|
.filter(|value| !value.is_empty());
|
||||||
|
|
||||||
let Some(short_id) = prompt_cache_key.and_then(build_short_codex_header_id) else {
|
let Some(short_id) = prompt_cache_key.and_then(build_short_codex_header_id) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -213,6 +215,7 @@ pub fn apply_codex_openai_cli_special_headers(
|
|||||||
.eq_ignore_ascii_case("openai:compact")
|
.eq_ignore_ascii_case("openai:compact")
|
||||||
&& !header_map_has_non_empty_value(original_headers, "conversation_id")
|
&& !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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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 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]
|
#[test]
|
||||||
fn builds_openai_chat_cross_format_request_body_from_openai_cli_source() {
|
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");
|
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]
|
#[test]
|
||||||
fn builds_streaming_local_openai_chat_request_body_with_include_usage() {
|
fn builds_streaming_local_openai_chat_request_body_with_include_usage() {
|
||||||
let body_json = json!({
|
let body_json = json!({
|
||||||
|
|||||||
@@ -50,6 +50,14 @@ impl DefaultBillingRuleGenerator {
|
|||||||
"cache_creation_price_per_1m".to_string(),
|
"cache_creation_price_per_1m".to_string(),
|
||||||
json!(base_cache_creation_price),
|
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(
|
variables.insert(
|
||||||
"cache_read_price_per_1m".to_string(),
|
"cache_read_price_per_1m".to_string(),
|
||||||
json!(base_cache_read_price),
|
json!(base_cache_read_price),
|
||||||
@@ -61,6 +69,21 @@ impl DefaultBillingRuleGenerator {
|
|||||||
("input_tokens", "input_tokens", json!(0)),
|
("input_tokens", "input_tokens", json!(0)),
|
||||||
("output_tokens", "output_tokens", json!(0)),
|
("output_tokens", "output_tokens", json!(0)),
|
||||||
("cache_creation_tokens", "cache_creation_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)),
|
("cache_read_tokens", "cache_read_tokens", json!(0)),
|
||||||
("request_count", "request_count", json!(1)),
|
("request_count", "request_count", json!(1)),
|
||||||
] {
|
] {
|
||||||
@@ -83,8 +106,16 @@ impl DefaultBillingRuleGenerator {
|
|||||||
"output_tokens * output_price_per_1m / 1000000",
|
"output_tokens * output_price_per_1m / 1000000",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"cache_creation_cost",
|
"cache_creation_uncategorized_cost",
|
||||||
"cache_creation_tokens * cache_creation_price_per_1m / 1000000",
|
"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",
|
"cache_read_cost",
|
||||||
@@ -136,6 +167,30 @@ impl DefaultBillingRuleGenerator {
|
|||||||
"default": base_cache_creation_price,
|
"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(
|
dimension_mappings.insert(
|
||||||
"cache_read_price_per_1m".to_string(),
|
"cache_read_price_per_1m".to_string(),
|
||||||
json!({
|
json!({
|
||||||
@@ -154,9 +209,7 @@ impl DefaultBillingRuleGenerator {
|
|||||||
id: "__default__".to_string(),
|
id: "__default__".to_string(),
|
||||||
name: format!("Default rule for {}", pricing.global_model_name),
|
name: format!("Default rule for {}", pricing.global_model_name),
|
||||||
task_type: normalize_task_type(task_type).to_string(),
|
task_type: normalize_task_type(task_type).to_string(),
|
||||||
expression:
|
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(),
|
||||||
"input_cost + output_cost + cache_creation_cost + cache_read_cost + request_cost"
|
|
||||||
.to_string(),
|
|
||||||
variables,
|
variables,
|
||||||
dimension_mappings,
|
dimension_mappings,
|
||||||
scope: "default".to_string(),
|
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,
|
input_tokens: event.data.input_tokens.unwrap_or_default() as i64,
|
||||||
output_tokens: event.data.output_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_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_read_tokens: event.data.cache_read_input_tokens.unwrap_or_default() as i64,
|
||||||
cache_ttl_minutes: pricing.provider_api_key_cache_ttl_minutes,
|
cache_ttl_minutes: pricing.provider_api_key_cache_ttl_minutes,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ pub struct BillingUsageInput {
|
|||||||
pub input_tokens: i64,
|
pub input_tokens: i64,
|
||||||
pub output_tokens: i64,
|
pub output_tokens: i64,
|
||||||
pub cache_creation_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_read_tokens: i64,
|
||||||
pub cache_ttl_minutes: Option<i64>,
|
pub cache_ttl_minutes: Option<i64>,
|
||||||
}
|
}
|
||||||
@@ -79,6 +81,8 @@ impl BillingUsageInput {
|
|||||||
input_tokens: 0,
|
input_tokens: 0,
|
||||||
output_tokens: 0,
|
output_tokens: 0,
|
||||||
cache_creation_tokens: 0,
|
cache_creation_tokens: 0,
|
||||||
|
cache_creation_ephemeral_5m_tokens: 0,
|
||||||
|
cache_creation_ephemeral_1h_tokens: 0,
|
||||||
cache_read_tokens: 0,
|
cache_read_tokens: 0,
|
||||||
cache_ttl_minutes: None,
|
cache_ttl_minutes: None,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,6 +129,13 @@ fn build_dimensions(input: &BillingUsageInput) -> BTreeMap<String, Value> {
|
|||||||
input.input_tokens,
|
input.input_tokens,
|
||||||
input.cache_read_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
|
let total_input_context = input
|
||||||
.input_tokens
|
.input_tokens
|
||||||
.saturating_add(input.cache_creation_tokens)
|
.saturating_add(input.cache_creation_tokens)
|
||||||
@@ -141,6 +148,18 @@ fn build_dimensions(input: &BillingUsageInput) -> BTreeMap<String, Value> {
|
|||||||
"cache_creation_tokens".to_string(),
|
"cache_creation_tokens".to_string(),
|
||||||
json!(input.cache_creation_tokens),
|
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(),
|
"cache_read_tokens".to_string(),
|
||||||
json!(input.cache_read_tokens),
|
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 {
|
if let Some(cache_ttl_minutes) = input.cache_ttl_minutes {
|
||||||
out.insert(
|
out.insert(
|
||||||
"cache_ttl_minutes".to_string(),
|
"cache_ttl_minutes".to_string(),
|
||||||
@@ -223,6 +251,8 @@ mod tests {
|
|||||||
input_tokens: 1_000,
|
input_tokens: 1_000,
|
||||||
output_tokens: 500,
|
output_tokens: 500,
|
||||||
cache_creation_tokens: 0,
|
cache_creation_tokens: 0,
|
||||||
|
cache_creation_ephemeral_5m_tokens: 0,
|
||||||
|
cache_creation_ephemeral_1h_tokens: 0,
|
||||||
cache_read_tokens: 100,
|
cache_read_tokens: 100,
|
||||||
cache_ttl_minutes: Some(60),
|
cache_ttl_minutes: Some(60),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ pub struct StoredRequestUsageAudit {
|
|||||||
pub output_tokens: u64,
|
pub output_tokens: u64,
|
||||||
pub total_tokens: u64,
|
pub total_tokens: u64,
|
||||||
pub cache_creation_input_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_read_input_tokens: u64,
|
||||||
pub cache_creation_cost_usd: f64,
|
pub cache_creation_cost_usd: f64,
|
||||||
pub cache_read_cost_usd: f64,
|
pub cache_read_cost_usd: f64,
|
||||||
@@ -166,6 +168,8 @@ impl StoredRequestUsageAudit {
|
|||||||
output_tokens: parse_u64(output_tokens, "usage.output_tokens")?,
|
output_tokens: parse_u64(output_tokens, "usage.output_tokens")?,
|
||||||
total_tokens: parse_u64(total_tokens, "usage.total_tokens")?,
|
total_tokens: parse_u64(total_tokens, "usage.total_tokens")?,
|
||||||
cache_creation_input_tokens: 0,
|
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_read_input_tokens: 0,
|
||||||
cache_creation_cost_usd: 0.0,
|
cache_creation_cost_usd: 0.0,
|
||||||
cache_read_cost_usd: 0.0,
|
cache_read_cost_usd: 0.0,
|
||||||
@@ -346,6 +350,8 @@ pub struct UpsertUsageRecord {
|
|||||||
pub output_tokens: Option<u64>,
|
pub output_tokens: Option<u64>,
|
||||||
pub total_tokens: Option<u64>,
|
pub total_tokens: Option<u64>,
|
||||||
pub cache_creation_input_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_read_input_tokens: Option<u64>,
|
||||||
pub cache_creation_cost_usd: Option<f64>,
|
pub cache_creation_cost_usd: Option<f64>,
|
||||||
pub cache_read_cost_usd: Option<f64>,
|
pub cache_read_cost_usd: Option<f64>,
|
||||||
@@ -604,6 +610,8 @@ mod tests {
|
|||||||
output_tokens: Some(20),
|
output_tokens: Some(20),
|
||||||
total_tokens: Some(30),
|
total_tokens: Some(30),
|
||||||
cache_creation_input_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_read_input_tokens: None,
|
||||||
cache_creation_cost_usd: None,
|
cache_creation_cost_usd: None,
|
||||||
cache_read_cost_usd: None,
|
cache_read_cost_usd: None,
|
||||||
|
|||||||
@@ -20,5 +20,6 @@ sha2.workspace = true
|
|||||||
sqlx = { workspace = true, features = ["migrate", "macros"] }
|
sqlx = { workspace = true, features = ["migrate", "macros"] }
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
url.workspace = true
|
url.workspace = true
|
||||||
uuid.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/`.
|
/// Run all pending migrations embedded at compile time from `migrations/`.
|
||||||
pub async fn run_migrations(pool: &PgPool) -> Result<(), sqlx::migrate::MigrateError> {
|
pub async fn run_migrations(pool: &PgPool) -> Result<(), MigrateError> {
|
||||||
sqlx::migrate!("./migrations").run(pool).await
|
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)
|
.map(|existing| existing.cache_creation_input_tokens)
|
||||||
.unwrap_or_default()
|
.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(|| {
|
cache_read_input_tokens: usage.cache_read_input_tokens.unwrap_or_else(|| {
|
||||||
existing
|
existing
|
||||||
.map(|existing| existing.cache_read_input_tokens)
|
.map(|existing| existing.cache_read_input_tokens)
|
||||||
@@ -427,6 +441,8 @@ mod tests {
|
|||||||
output_tokens: Some(20),
|
output_tokens: Some(20),
|
||||||
total_tokens: None,
|
total_tokens: None,
|
||||||
cache_creation_input_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_read_input_tokens: None,
|
||||||
cache_creation_cost_usd: None,
|
cache_creation_cost_usd: None,
|
||||||
cache_read_cost_usd: None,
|
cache_read_cost_usd: None,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use futures_util::future::BoxFuture;
|
use futures_util::future::BoxFuture;
|
||||||
|
use serde_json::Value;
|
||||||
use sqlx::{PgPool, Postgres, QueryBuilder, Row};
|
use sqlx::{PgPool, Postgres, QueryBuilder, Row};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -37,6 +38,8 @@ SELECT
|
|||||||
output_tokens,
|
output_tokens,
|
||||||
total_tokens,
|
total_tokens,
|
||||||
COALESCE(cache_creation_input_tokens, 0) AS cache_creation_input_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(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_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,
|
COALESCE(CAST(cache_read_cost_usd AS DOUBLE PRECISION), 0) AS cache_read_cost_usd,
|
||||||
@@ -94,6 +97,8 @@ SELECT
|
|||||||
output_tokens,
|
output_tokens,
|
||||||
total_tokens,
|
total_tokens,
|
||||||
COALESCE(cache_creation_input_tokens, 0) AS cache_creation_input_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(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_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,
|
COALESCE(CAST(cache_read_cost_usd AS DOUBLE PRECISION), 0) AS cache_read_cost_usd,
|
||||||
@@ -181,6 +186,8 @@ SELECT
|
|||||||
output_tokens,
|
output_tokens,
|
||||||
total_tokens,
|
total_tokens,
|
||||||
COALESCE(cache_creation_input_tokens, 0) AS cache_creation_input_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(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_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,
|
COALESCE(CAST(cache_read_cost_usd AS DOUBLE PRECISION), 0) AS cache_read_cost_usd,
|
||||||
@@ -194,15 +201,15 @@ SELECT
|
|||||||
first_byte_time_ms,
|
first_byte_time_ms,
|
||||||
status,
|
status,
|
||||||
billing_status,
|
billing_status,
|
||||||
NULL::jsonb AS request_headers,
|
NULL::json AS request_headers,
|
||||||
NULL::jsonb AS request_body,
|
NULL::json AS request_body,
|
||||||
NULL::jsonb AS provider_request_headers,
|
NULL::json AS provider_request_headers,
|
||||||
NULL::jsonb AS provider_request_body,
|
NULL::json AS provider_request_body,
|
||||||
NULL::jsonb AS response_headers,
|
NULL::json AS response_headers,
|
||||||
NULL::jsonb AS response_body,
|
NULL::json AS response_body,
|
||||||
NULL::jsonb AS client_response_headers,
|
NULL::json AS client_response_headers,
|
||||||
NULL::jsonb AS client_response_body,
|
NULL::json AS client_response_body,
|
||||||
NULL::jsonb AS request_metadata,
|
NULL::json AS request_metadata,
|
||||||
CAST(EXTRACT(EPOCH FROM created_at) AS BIGINT) AS created_at_unix_ms,
|
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 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
|
CAST(EXTRACT(EPOCH FROM finalized_at) AS BIGINT) AS finalized_at_unix_secs
|
||||||
@@ -236,6 +243,8 @@ SELECT
|
|||||||
output_tokens,
|
output_tokens,
|
||||||
total_tokens,
|
total_tokens,
|
||||||
COALESCE(cache_creation_input_tokens, 0) AS cache_creation_input_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(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_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,
|
COALESCE(CAST(cache_read_cost_usd AS DOUBLE PRECISION), 0) AS cache_read_cost_usd,
|
||||||
@@ -249,15 +258,15 @@ SELECT
|
|||||||
first_byte_time_ms,
|
first_byte_time_ms,
|
||||||
status,
|
status,
|
||||||
billing_status,
|
billing_status,
|
||||||
NULL::jsonb AS request_headers,
|
NULL::json AS request_headers,
|
||||||
NULL::jsonb AS request_body,
|
NULL::json AS request_body,
|
||||||
NULL::jsonb AS provider_request_headers,
|
NULL::json AS provider_request_headers,
|
||||||
NULL::jsonb AS provider_request_body,
|
NULL::json AS provider_request_body,
|
||||||
NULL::jsonb AS response_headers,
|
NULL::json AS response_headers,
|
||||||
NULL::jsonb AS response_body,
|
NULL::json AS response_body,
|
||||||
NULL::jsonb AS client_response_headers,
|
NULL::json AS client_response_headers,
|
||||||
NULL::jsonb AS client_response_body,
|
NULL::json AS client_response_body,
|
||||||
NULL::jsonb AS request_metadata,
|
NULL::json AS request_metadata,
|
||||||
CAST(EXTRACT(EPOCH FROM created_at) AS BIGINT) AS created_at_unix_ms,
|
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 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
|
CAST(EXTRACT(EPOCH FROM finalized_at) AS BIGINT) AS finalized_at_unix_secs
|
||||||
@@ -291,6 +300,8 @@ INSERT INTO "usage" (
|
|||||||
output_tokens,
|
output_tokens,
|
||||||
total_tokens,
|
total_tokens,
|
||||||
cache_creation_input_tokens,
|
cache_creation_input_tokens,
|
||||||
|
cache_creation_input_tokens_5m,
|
||||||
|
cache_creation_input_tokens_1h,
|
||||||
cache_read_input_tokens,
|
cache_read_input_tokens,
|
||||||
cache_creation_cost_usd,
|
cache_creation_cost_usd,
|
||||||
cache_read_cost_usd,
|
cache_read_cost_usd,
|
||||||
@@ -344,10 +355,10 @@ INSERT INTO "usage" (
|
|||||||
COALESCE($26, 0),
|
COALESCE($26, 0),
|
||||||
COALESCE($27, 0),
|
COALESCE($27, 0),
|
||||||
COALESCE($28, 0),
|
COALESCE($28, 0),
|
||||||
$29,
|
COALESCE($29, 0),
|
||||||
COALESCE($30, 0),
|
$30,
|
||||||
COALESCE($31, 0),
|
COALESCE($31, 0),
|
||||||
$32,
|
COALESCE($32, 0),
|
||||||
$33,
|
$33,
|
||||||
$34,
|
$34,
|
||||||
$35,
|
$35,
|
||||||
@@ -356,18 +367,20 @@ INSERT INTO "usage" (
|
|||||||
$38,
|
$38,
|
||||||
$39,
|
$39,
|
||||||
$40,
|
$40,
|
||||||
$41,
|
$41::json,
|
||||||
$42,
|
$42::json,
|
||||||
$43,
|
$43::json,
|
||||||
$44,
|
$44::json,
|
||||||
$45,
|
$45::json,
|
||||||
$46,
|
$46::json,
|
||||||
$47,
|
$47::json,
|
||||||
|
$48::json,
|
||||||
|
$49::json,
|
||||||
CASE
|
CASE
|
||||||
WHEN $48 IS NULL THEN NULL
|
WHEN $50 IS NULL THEN NULL
|
||||||
ELSE TO_TIMESTAMP($48::double precision)
|
ELSE TO_TIMESTAMP($50::double precision)
|
||||||
END,
|
END,
|
||||||
COALESCE(TO_TIMESTAMP($49::double precision), NOW())
|
COALESCE(TO_TIMESTAMP($51::double precision), NOW())
|
||||||
)
|
)
|
||||||
ON CONFLICT (request_id)
|
ON CONFLICT (request_id)
|
||||||
DO UPDATE SET
|
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,
|
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,
|
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 = 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_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_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,
|
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,
|
output_tokens,
|
||||||
total_tokens,
|
total_tokens,
|
||||||
COALESCE(cache_creation_input_tokens, 0) AS cache_creation_input_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(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_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,
|
COALESCE(CAST(cache_read_cost_usd AS DOUBLE PRECISION), 0) AS cache_read_cost_usd,
|
||||||
@@ -653,6 +670,19 @@ impl SqlxUsageReadRepository {
|
|||||||
self.tx_runner
|
self.tx_runner
|
||||||
.run_read_write(|tx| {
|
.run_read_write(|tx| {
|
||||||
Box::pin(async move {
|
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)
|
let row = sqlx::query(UPSERT_SQL)
|
||||||
.bind(Uuid::new_v4().to_string())
|
.bind(Uuid::new_v4().to_string())
|
||||||
.bind(&usage.request_id)
|
.bind(&usage.request_id)
|
||||||
@@ -690,6 +720,18 @@ impl SqlxUsageReadRepository {
|
|||||||
.transpose()?,
|
.transpose()?,
|
||||||
)
|
)
|
||||||
.bind(usage.cache_creation_input_tokens.map(to_i32).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_read_input_tokens.map(to_i32).transpose()?)
|
||||||
.bind(usage.cache_creation_cost_usd)
|
.bind(usage.cache_creation_cost_usd)
|
||||||
.bind(usage.cache_read_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.first_byte_time_ms.map(to_i32).transpose()?)
|
||||||
.bind(&usage.status)
|
.bind(&usage.status)
|
||||||
.bind(&usage.billing_status)
|
.bind(&usage.billing_status)
|
||||||
.bind(&usage.request_headers)
|
.bind(&request_headers_json)
|
||||||
.bind(&usage.request_body)
|
.bind(&request_body_json)
|
||||||
.bind(&usage.provider_request_headers)
|
.bind(&provider_request_headers_json)
|
||||||
.bind(&usage.provider_request_body)
|
.bind(&provider_request_body_json)
|
||||||
.bind(&usage.response_headers)
|
.bind(&response_headers_json)
|
||||||
.bind(&usage.response_body)
|
.bind(&response_body_json)
|
||||||
.bind(&usage.client_response_headers)
|
.bind(&client_response_headers_json)
|
||||||
.bind(&usage.client_response_body)
|
.bind(&client_response_body_json)
|
||||||
.bind(&usage.request_metadata)
|
.bind(&request_metadata_json)
|
||||||
.bind(usage.finalized_at_unix_secs.map(|value| value as f64))
|
.bind(usage.finalized_at_unix_secs.map(|value| value as f64))
|
||||||
.bind(usage.created_at_unix_ms.map(|value| value as f64))
|
.bind(usage.created_at_unix_ms.map(|value| value as f64))
|
||||||
.fetch_one(&mut **tx)
|
.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"))
|
.map(|value| to_u64(value, "usage.cache_creation_input_tokens"))
|
||||||
.transpose()?
|
.transpose()?
|
||||||
.unwrap_or_default();
|
.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
|
usage.cache_read_input_tokens = row
|
||||||
.try_get::<Option<i32>, _>("cache_read_input_tokens")
|
.try_get::<Option<i32>, _>("cache_read_input_tokens")
|
||||||
.map_postgres_err()?
|
.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}")))
|
.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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::SqlxUsageReadRepository;
|
use super::SqlxUsageReadRepository;
|
||||||
@@ -930,6 +994,8 @@ mod tests {
|
|||||||
output_tokens: Some(20),
|
output_tokens: Some(20),
|
||||||
total_tokens: Some(30),
|
total_tokens: Some(30),
|
||||||
cache_creation_input_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_read_input_tokens: None,
|
||||||
cache_creation_cost_usd: None,
|
cache_creation_cost_usd: None,
|
||||||
cache_read_cost_usd: None,
|
cache_read_cost_usd: None,
|
||||||
@@ -980,10 +1046,32 @@ mod tests {
|
|||||||
assert!(super::LIST_RECENT_USAGE_AUDITS_PREFIX.contains("FROM \"usage\""));
|
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]
|
#[test]
|
||||||
fn usage_sql_insert_values_aligns_request_metadata_and_timestamps() {
|
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("\n $48::json,\n $49::json,\n CASE"));
|
||||||
assert!(super::UPSERT_SQL.contains("WHEN $48 IS NULL THEN NULL"));
|
assert!(super::UPSERT_SQL.contains("WHEN $50 IS NULL THEN NULL"));
|
||||||
assert!(super::UPSERT_SQL.contains("TO_TIMESTAMP($49::double precision)"));
|
assert!(super::UPSERT_SQL.contains("TO_TIMESTAMP($51::double precision)"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
use std::collections::BTreeMap;
|
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;
|
use super::snapshot::GatewayProviderTransportSnapshot;
|
||||||
|
|
||||||
|
const DEFAULT_ANTHROPIC_VERSION: &str = "2023-06-01";
|
||||||
|
|
||||||
fn collect_passthrough_headers(
|
fn collect_passthrough_headers(
|
||||||
headers: &http::HeaderMap,
|
headers: &http::HeaderMap,
|
||||||
extra_headers: &BTreeMap<String, String>,
|
extra_headers: &BTreeMap<String, String>,
|
||||||
@@ -35,6 +39,38 @@ fn collect_passthrough_headers(
|
|||||||
out
|
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(
|
pub fn build_passthrough_headers(
|
||||||
headers: &http::HeaderMap,
|
headers: &http::HeaderMap,
|
||||||
extra_headers: &BTreeMap<String, String>,
|
extra_headers: &BTreeMap<String, String>,
|
||||||
@@ -64,6 +100,76 @@ pub fn build_openai_passthrough_headers(
|
|||||||
out
|
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(
|
pub fn build_passthrough_headers_with_auth(
|
||||||
headers: &http::HeaderMap,
|
headers: &http::HeaderMap,
|
||||||
auth_header: &str,
|
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(
|
pub fn resolve_local_openai_chat_auth(
|
||||||
transport: &GatewayProviderTransportSnapshot,
|
transport: &GatewayProviderTransportSnapshot,
|
||||||
) -> Option<(String, String)> {
|
) -> Option<(String, String)> {
|
||||||
@@ -142,3 +272,112 @@ pub fn resolve_local_gemini_auth(
|
|||||||
_ => None,
|
_ => 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)
|
) || 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::should_skip_upstream_passthrough_header;
|
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,
|
failed_requests BIGINT NOT NULL DEFAULT 0,
|
||||||
dns_failures BIGINT NOT NULL DEFAULT 0,
|
dns_failures BIGINT NOT NULL DEFAULT 0,
|
||||||
stream_errors BIGINT NOT NULL DEFAULT 0,
|
stream_errors BIGINT NOT NULL DEFAULT 0,
|
||||||
proxy_metadata JSONB NULL,
|
proxy_metadata JSON NULL,
|
||||||
hardware_info JSONB NULL,
|
hardware_info JSON NULL,
|
||||||
estimated_max_concurrency INTEGER NULL,
|
estimated_max_concurrency INTEGER NULL,
|
||||||
tunnel_mode BOOLEAN NOT NULL DEFAULT FALSE,
|
tunnel_mode BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
tunnel_connected BOOLEAN NOT NULL DEFAULT FALSE,
|
tunnel_connected BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
tunnel_connected_at TIMESTAMPTZ NULL,
|
tunnel_connected_at TIMESTAMPTZ NULL,
|
||||||
remote_config JSONB NULL,
|
remote_config JSON NULL,
|
||||||
config_version INTEGER NOT NULL DEFAULT 1,
|
config_version INTEGER NOT NULL DEFAULT 1,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_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")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub cache_creation_input_tokens: Option<u64>,
|
pub cache_creation_input_tokens: Option<u64>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[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>,
|
pub cache_read_input_tokens: Option<u64>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub cache_creation_cost_usd: Option<f64>,
|
pub cache_creation_cost_usd: Option<f64>,
|
||||||
|
|||||||
@@ -41,5 +41,6 @@ pub use write::{
|
|||||||
build_pending_usage_record, build_stream_terminal_usage_event,
|
build_pending_usage_record, build_stream_terminal_usage_event,
|
||||||
build_stream_terminal_usage_outcome, build_streaming_usage_record,
|
build_stream_terminal_usage_outcome, build_streaming_usage_record,
|
||||||
build_sync_terminal_usage_event, build_sync_terminal_usage_outcome,
|
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,
|
output_tokens: data.output_tokens,
|
||||||
total_tokens: data.total_tokens,
|
total_tokens: data.total_tokens,
|
||||||
cache_creation_input_tokens: data.cache_creation_input_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_read_input_tokens: data.cache_read_input_tokens,
|
||||||
cache_creation_cost_usd: data.cache_creation_cost_usd,
|
cache_creation_cost_usd: data.cache_creation_cost_usd,
|
||||||
cache_read_cost_usd: data.cache_read_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)
|
async fn enqueue_or_write_terminal<T>(&self, data: &T, event: UsageEvent)
|
||||||
where
|
where
|
||||||
T: UsageRuntimeAccess,
|
T: UsageRuntimeAccess,
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ pub struct StandardizedUsage {
|
|||||||
pub input_tokens: i64,
|
pub input_tokens: i64,
|
||||||
pub output_tokens: i64,
|
pub output_tokens: i64,
|
||||||
pub cache_creation_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_read_tokens: i64,
|
||||||
pub reasoning_tokens: i64,
|
pub reasoning_tokens: i64,
|
||||||
pub cache_storage_token_hours: f64,
|
pub cache_storage_token_hours: f64,
|
||||||
@@ -25,6 +27,12 @@ impl StandardizedUsage {
|
|||||||
"input_tokens" => Some(serde_json::json!(self.input_tokens)),
|
"input_tokens" => Some(serde_json::json!(self.input_tokens)),
|
||||||
"output_tokens" => Some(serde_json::json!(self.output_tokens)),
|
"output_tokens" => Some(serde_json::json!(self.output_tokens)),
|
||||||
"cache_creation_tokens" => Some(serde_json::json!(self.cache_creation_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)),
|
"cache_read_tokens" => Some(serde_json::json!(self.cache_read_tokens)),
|
||||||
"reasoning_tokens" => Some(serde_json::json!(self.reasoning_tokens)),
|
"reasoning_tokens" => Some(serde_json::json!(self.reasoning_tokens)),
|
||||||
"cache_storage_token_hours" => Some(serde_json::json!(self.cache_storage_token_hours)),
|
"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),
|
"input_tokens" => self.input_tokens = as_i64(&value, 0),
|
||||||
"output_tokens" => self.output_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_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),
|
"cache_read_tokens" => self.cache_read_tokens = as_i64(&value, 0),
|
||||||
"reasoning_tokens" => self.reasoning_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),
|
"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 {
|
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 {
|
pub fn map_from_response(response: &serde_json::Value, api_format: &str) -> StandardizedUsage {
|
||||||
let family = api_family(api_format);
|
let family = api_family(api_format);
|
||||||
let usage_value = if family == "gemini" {
|
let Some(usage_value) = resolve_usage_value(response, family.as_str()) else {
|
||||||
response
|
return StandardizedUsage::new();
|
||||||
.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)
|
|
||||||
};
|
};
|
||||||
Self::map(usage_value, api_format, None)
|
Self::map(usage_value, api_format, None)
|
||||||
}
|
}
|
||||||
@@ -74,14 +64,32 @@ fn base_mapping(api_format: &str) -> BTreeMap<String, String> {
|
|||||||
"openai" => {
|
"openai" => {
|
||||||
mapping.insert("prompt_tokens".to_string(), "input_tokens".to_string());
|
mapping.insert("prompt_tokens".to_string(), "input_tokens".to_string());
|
||||||
mapping.insert("completion_tokens".to_string(), "output_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(
|
mapping.insert(
|
||||||
"prompt_tokens_details.cached_tokens".to_string(),
|
"prompt_tokens_details.cached_tokens".to_string(),
|
||||||
"cache_read_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(
|
mapping.insert(
|
||||||
"completion_tokens_details.reasoning_tokens".to_string(),
|
"completion_tokens_details.reasoning_tokens".to_string(),
|
||||||
"reasoning_tokens".to_string(),
|
"reasoning_tokens".to_string(),
|
||||||
);
|
);
|
||||||
|
mapping.insert(
|
||||||
|
"output_tokens_details.reasoning_tokens".to_string(),
|
||||||
|
"reasoning_tokens".to_string(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
"gemini" => {
|
"gemini" => {
|
||||||
mapping.insert("promptTokenCount".to_string(), "input_tokens".to_string());
|
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(),
|
"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("input_tokens".to_string(), "input_tokens".to_string());
|
||||||
mapping.insert("output_tokens".to_string(), "output_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)
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{map_usage, map_usage_from_response};
|
use super::{map_usage, map_usage_from_response};
|
||||||
@@ -140,7 +209,10 @@ mod tests {
|
|||||||
&serde_json::json!({
|
&serde_json::json!({
|
||||||
"prompt_tokens": 12,
|
"prompt_tokens": 12,
|
||||||
"completion_tokens": 8,
|
"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 }
|
"completion_tokens_details": { "reasoning_tokens": 3 }
|
||||||
}),
|
}),
|
||||||
"openai:chat",
|
"openai:chat",
|
||||||
@@ -148,10 +220,81 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(usage.input_tokens, 12);
|
assert_eq!(usage.input_tokens, 12);
|
||||||
assert_eq!(usage.output_tokens, 8);
|
assert_eq!(usage.output_tokens, 8);
|
||||||
|
assert_eq!(usage.cache_creation_tokens, 1);
|
||||||
assert_eq!(usage.cache_read_tokens, 2);
|
assert_eq!(usage.cache_read_tokens, 2);
|
||||||
assert_eq!(usage.reasoning_tokens, 3);
|
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]
|
#[test]
|
||||||
fn maps_claude_usage() {
|
fn maps_claude_usage() {
|
||||||
let usage = map_usage(
|
let usage = map_usage(
|
||||||
@@ -170,6 +313,30 @@ mod tests {
|
|||||||
assert_eq!(usage.cache_read_tokens, 1);
|
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]
|
#[test]
|
||||||
fn maps_gemini_usage_from_response() {
|
fn maps_gemini_usage_from_response() {
|
||||||
let usage = map_usage_from_response(
|
let usage = map_usage_from_response(
|
||||||
|
|||||||
@@ -409,6 +409,8 @@ fn build_upsert_usage_record(
|
|||||||
output_tokens: data.output_tokens,
|
output_tokens: data.output_tokens,
|
||||||
total_tokens: data.total_tokens,
|
total_tokens: data.total_tokens,
|
||||||
cache_creation_input_tokens: data.cache_creation_input_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_read_input_tokens: data.cache_read_input_tokens,
|
||||||
cache_creation_cost_usd: data.cache_creation_cost_usd,
|
cache_creation_cost_usd: data.cache_creation_cost_usd,
|
||||||
cache_read_cost_usd: data.cache_read_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 context = report_context.and_then(Value::as_object);
|
||||||
let api_format = context_string(context, "client_api_format")
|
let api_format = context_string(context, "client_api_format")
|
||||||
.or_else(|| non_empty_string(Some(plan.client_api_format.clone())));
|
.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 {
|
fn merge_usage_data(base: UsageEventData, override_data: UsageEventData) -> UsageEventData {
|
||||||
UsageEventData {
|
UsageEventData {
|
||||||
user_id: override_data.user_id.or(base.user_id),
|
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: override_data
|
||||||
.cache_creation_input_tokens
|
.cache_creation_input_tokens
|
||||||
.or(base.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: override_data
|
||||||
.cache_read_input_tokens
|
.cache_read_input_tokens
|
||||||
.or(base.cache_read_input_tokens),
|
.or(base.cache_read_input_tokens),
|
||||||
@@ -668,6 +683,14 @@ fn apply_standardized_usage(
|
|||||||
if usage.cache_creation_tokens > 0 {
|
if usage.cache_creation_tokens > 0 {
|
||||||
data.cache_creation_input_tokens = Some(usage.cache_creation_tokens as u64);
|
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 {
|
if usage.cache_read_tokens > 0 {
|
||||||
data.cache_read_input_tokens = Some(usage.cache_read_tokens as u64);
|
data.cache_read_input_tokens = Some(usage.cache_read_tokens as u64);
|
||||||
}
|
}
|
||||||
@@ -1167,7 +1190,7 @@ mod tests {
|
|||||||
"event: response.output_text.delta\n",
|
"event: response.output_text.delta\n",
|
||||||
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"Hello from CLI stream\"}\n\n",
|
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"Hello from CLI stream\"}\n\n",
|
||||||
"event: response.completed\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",
|
"data: [DONE]\n",
|
||||||
);
|
);
|
||||||
let payload = GatewayStreamReportRequest {
|
let payload = GatewayStreamReportRequest {
|
||||||
@@ -1191,6 +1214,8 @@ mod tests {
|
|||||||
assert_eq!(event.data.input_tokens, Some(3));
|
assert_eq!(event.data.input_tokens, Some(3));
|
||||||
assert_eq!(event.data.output_tokens, Some(5));
|
assert_eq!(event.data.output_tokens, Some(5));
|
||||||
assert_eq!(event.data.total_tokens, Some(8));
|
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!(
|
assert_eq!(
|
||||||
event.data.response_body,
|
event.data.response_body,
|
||||||
Some(json!({
|
Some(json!({
|
||||||
@@ -1229,7 +1254,14 @@ mod tests {
|
|||||||
],
|
],
|
||||||
"usage": {
|
"usage": {
|
||||||
"input_tokens": 3,
|
"input_tokens": 3,
|
||||||
|
"input_tokens_details": {
|
||||||
|
"cached_tokens": 2,
|
||||||
|
"cached_creation_tokens": 1
|
||||||
|
},
|
||||||
"output_tokens": 5,
|
"output_tokens": 5,
|
||||||
|
"output_tokens_details": {
|
||||||
|
"reasoning_tokens": 1
|
||||||
|
},
|
||||||
"total_tokens": 8
|
"total_tokens": 8
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ export interface RequestDetail {
|
|||||||
}
|
}
|
||||||
provider: string
|
provider: string
|
||||||
api_format?: string
|
api_format?: string
|
||||||
|
endpoint_api_format?: string
|
||||||
model: string
|
model: string
|
||||||
target_model?: string | null // 映射后的目标模型名
|
target_model?: string | null // 映射后的目标模型名
|
||||||
tokens: {
|
tokens: {
|
||||||
@@ -142,6 +143,7 @@ export interface RequestDetail {
|
|||||||
}
|
}
|
||||||
// Additional token fields
|
// Additional token fields
|
||||||
input_tokens?: number
|
input_tokens?: number
|
||||||
|
effective_input_tokens?: number
|
||||||
output_tokens?: number
|
output_tokens?: number
|
||||||
total_tokens?: number
|
total_tokens?: number
|
||||||
cache_creation_input_tokens?: number
|
cache_creation_input_tokens?: number
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export interface UsageRecordDetail {
|
|||||||
provider: string
|
provider: string
|
||||||
model: string
|
model: string
|
||||||
input_tokens: number
|
input_tokens: number
|
||||||
|
effective_input_tokens?: number
|
||||||
output_tokens: number
|
output_tokens: number
|
||||||
total_tokens: number
|
total_tokens: number
|
||||||
cost: number // 官方费率
|
cost: number // 官方费率
|
||||||
@@ -80,9 +81,12 @@ export interface ModelSummary {
|
|||||||
model: string
|
model: string
|
||||||
requests: number
|
requests: number
|
||||||
input_tokens: number
|
input_tokens: number
|
||||||
|
effective_input_tokens?: number
|
||||||
output_tokens: number
|
output_tokens: number
|
||||||
total_tokens: number
|
total_tokens: number
|
||||||
cache_read_tokens?: number
|
cache_read_tokens?: number
|
||||||
|
cache_creation_tokens?: number
|
||||||
|
total_input_context?: number
|
||||||
cache_hit_rate?: number
|
cache_hit_rate?: number
|
||||||
total_cost_usd: number
|
total_cost_usd: number
|
||||||
actual_total_cost_usd?: number // 倍率消耗(仅管理员可见)
|
actual_total_cost_usd?: number // 倍率消耗(仅管理员可见)
|
||||||
@@ -92,8 +96,12 @@ export interface ModelSummary {
|
|||||||
export interface ProviderSummary {
|
export interface ProviderSummary {
|
||||||
provider: string
|
provider: string
|
||||||
requests: number
|
requests: number
|
||||||
|
effective_input_tokens?: number
|
||||||
total_tokens: number
|
total_tokens: number
|
||||||
|
output_tokens?: number
|
||||||
cache_read_tokens?: number
|
cache_read_tokens?: number
|
||||||
|
cache_creation_tokens?: number
|
||||||
|
total_input_context?: number
|
||||||
cache_hit_rate?: number
|
cache_hit_rate?: number
|
||||||
total_cost_usd: number
|
total_cost_usd: number
|
||||||
success_rate: number | null
|
success_rate: number | null
|
||||||
@@ -104,8 +112,12 @@ export interface ProviderSummary {
|
|||||||
export interface ApiFormatSummary {
|
export interface ApiFormatSummary {
|
||||||
api_format: string
|
api_format: string
|
||||||
request_count: number
|
request_count: number
|
||||||
|
effective_input_tokens?: number
|
||||||
total_tokens: number
|
total_tokens: number
|
||||||
|
output_tokens?: number
|
||||||
cache_read_tokens: number
|
cache_read_tokens: number
|
||||||
|
cache_creation_tokens?: number
|
||||||
|
total_input_context?: number
|
||||||
cache_hit_rate: number
|
cache_hit_rate: number
|
||||||
total_cost_usd: number
|
total_cost_usd: number
|
||||||
avg_response_time_ms: number
|
avg_response_time_ms: number
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface UsageRecord {
|
|||||||
provider_name?: string
|
provider_name?: string
|
||||||
model: string
|
model: string
|
||||||
input_tokens: number
|
input_tokens: number
|
||||||
|
effective_input_tokens?: number
|
||||||
output_tokens: number
|
output_tokens: number
|
||||||
cache_creation_input_tokens?: number
|
cache_creation_input_tokens?: number
|
||||||
cache_read_input_tokens?: number
|
cache_read_input_tokens?: number
|
||||||
@@ -38,6 +39,10 @@ export interface UsageByModel {
|
|||||||
model: string
|
model: string
|
||||||
request_count: number
|
request_count: number
|
||||||
total_tokens: number
|
total_tokens: number
|
||||||
|
effective_input_tokens?: number
|
||||||
|
total_input_context?: number
|
||||||
|
output_tokens?: number
|
||||||
|
cache_creation_tokens?: number
|
||||||
total_cost: number
|
total_cost: number
|
||||||
avg_response_time?: number
|
avg_response_time?: number
|
||||||
cache_read_tokens?: number
|
cache_read_tokens?: number
|
||||||
@@ -58,6 +63,10 @@ export interface UsageByProvider {
|
|||||||
provider: string
|
provider: string
|
||||||
request_count: number
|
request_count: number
|
||||||
total_tokens: number
|
total_tokens: number
|
||||||
|
effective_input_tokens?: number
|
||||||
|
total_input_context?: number
|
||||||
|
output_tokens?: number
|
||||||
|
cache_creation_tokens?: number
|
||||||
total_cost: number
|
total_cost: number
|
||||||
actual_cost: number
|
actual_cost: number
|
||||||
avg_response_time_ms: number
|
avg_response_time_ms: number
|
||||||
@@ -71,6 +80,10 @@ export interface UsageByApiFormat {
|
|||||||
api_format: string
|
api_format: string
|
||||||
request_count: number
|
request_count: number
|
||||||
total_tokens: number
|
total_tokens: number
|
||||||
|
effective_input_tokens?: number
|
||||||
|
total_input_context?: number
|
||||||
|
output_tokens?: number
|
||||||
|
cache_creation_tokens?: number
|
||||||
total_cost: number
|
total_cost: number
|
||||||
actual_cost: number
|
actual_cost: number
|
||||||
avg_response_time_ms: number
|
avg_response_time_ms: number
|
||||||
@@ -276,6 +289,7 @@ export const usageApi = {
|
|||||||
id: string
|
id: string
|
||||||
status: 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
|
status: 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
|
||||||
input_tokens: number
|
input_tokens: number
|
||||||
|
effective_input_tokens?: number | null
|
||||||
output_tokens: number
|
output_tokens: number
|
||||||
cache_creation_input_tokens?: number | null
|
cache_creation_input_tokens?: number | null
|
||||||
cache_read_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">
|
<div class="text-xs text-muted-foreground flex items-center gap-2 flex-wrap">
|
||||||
<span class="font-medium text-foreground">Token 计费</span>
|
<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
|
<Badge
|
||||||
v-if="displayTiers.length > 1"
|
v-if="displayTiers.length > 1"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -260,7 +260,7 @@
|
|||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="flex items-center flex-1">
|
<div class="flex items-center flex-1">
|
||||||
<span class="text-xs text-muted-foreground w-[56px]">输入</span>
|
<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>
|
<span class="text-xs font-mono">${{ (detail.cost?.input || detail.input_cost || 0).toFixed(6) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<Separator
|
<Separator
|
||||||
@@ -701,6 +701,7 @@ import { dashboardApi, type RequestDetail } from '@/api/dashboard'
|
|||||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||||
import { formatShortRequestId } from '@/utils/format'
|
import { formatShortRequestId } from '@/utils/format'
|
||||||
import { log } from '@/utils/logger'
|
import { log } from '@/utils/logger'
|
||||||
|
import { getEffectiveInputTokens } from '../token-normalization'
|
||||||
|
|
||||||
// 子组件
|
// 子组件
|
||||||
import RequestHeadersContent from './RequestDetailDrawer/RequestHeadersContent.vue'
|
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 fullRequestId = computed(() => detail.value?.request_id || detail.value?.id || '-')
|
||||||
const displayRequestId = computed(() => formatShortRequestId(fullRequestId.value))
|
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) => {
|
watch(activeTab, (newTab) => {
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="text-right py-2 px-2">
|
<TableCell class="text-right py-2 px-2">
|
||||||
<div class="flex flex-col items-end text-xs gap-0.5 whitespace-nowrap">
|
<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>
|
<span class="text-muted-foreground">{{ formatTokens((item.cache_read_tokens || 0) + (item.cache_creation_tokens || 0)) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="text-right py-2 px-2">
|
<TableCell class="text-right py-2 px-2">
|
||||||
<div class="flex flex-col items-end text-xs gap-0.5 whitespace-nowrap">
|
<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>
|
<span class="text-muted-foreground">{{ formatTokens(model.cache_read_tokens || 0) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="text-right py-2 px-2">
|
<TableCell class="text-right py-2 px-2">
|
||||||
<div class="flex flex-col items-end text-xs gap-0.5 whitespace-nowrap">
|
<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>
|
<span class="text-muted-foreground">{{ formatTokens((provider.cacheReadTokens || 0) + (provider.cacheCreationTokens || 0)) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -273,7 +273,7 @@
|
|||||||
>-</span>
|
>-</span>
|
||||||
<span class="text-muted-foreground/50">|</span>
|
<span class="text-muted-foreground/50">|</span>
|
||||||
<!-- Tokens -->
|
<!-- 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -552,7 +552,7 @@
|
|||||||
<TableCell class="text-right py-4 w-[140px]">
|
<TableCell class="text-right py-4 w-[140px]">
|
||||||
<div class="flex flex-col items-end text-xs gap-0.5">
|
<div class="flex flex-col items-end text-xs gap-0.5">
|
||||||
<div class="flex items-center gap-1">
|
<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 class="text-muted-foreground">/</span>
|
||||||
<span>{{ formatTokens(record.output_tokens || 0) }}</span>
|
<span>{{ formatTokens(record.output_tokens || 0) }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -677,6 +677,7 @@ import {
|
|||||||
import { RefreshCcw, Search } from 'lucide-vue-next'
|
import { RefreshCcw, Search } from 'lucide-vue-next'
|
||||||
import { formatTokens, formatCurrency } from '@/utils/format'
|
import { formatTokens, formatCurrency } from '@/utils/format'
|
||||||
import { formatDateTime } from '../composables'
|
import { formatDateTime } from '../composables'
|
||||||
|
import { getEffectiveInputTokens } from '../token-normalization'
|
||||||
import { useRowClick } from '@/composables/useRowClick'
|
import { useRowClick } from '@/composables/useRowClick'
|
||||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||||
import type { DateRangeParams, UsageRecord } from '../types'
|
import type { DateRangeParams, UsageRecord } from '../types'
|
||||||
@@ -777,6 +778,10 @@ function handleRowClick(event: MouseEvent, id: string) {
|
|||||||
emit('showDetail', id)
|
emit('showDetail', id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getRecordEffectiveInputTokens(record: UsageRecord): number {
|
||||||
|
return getEffectiveInputTokens(record)
|
||||||
|
}
|
||||||
|
|
||||||
// useDebounceFn 自动处理清理,无需 onUnmounted
|
// useDebounceFn 自动处理清理,无需 onUnmounted
|
||||||
|
|
||||||
// 判断是否应该显示格式转换信息
|
// 判断是否应该显示格式转换信息
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ export function useUsageData(options: UseUsageDataOptions) {
|
|||||||
model: item.model,
|
model: item.model,
|
||||||
request_count: item.request_count || 0,
|
request_count: item.request_count || 0,
|
||||||
total_tokens: item.total_tokens || 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,
|
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,
|
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,
|
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,
|
provider: item.provider,
|
||||||
requests: item.request_count,
|
requests: item.request_count,
|
||||||
totalTokens: item.total_tokens || 0,
|
totalTokens: item.total_tokens || 0,
|
||||||
|
effectiveInputTokens: item.effective_input_tokens || 0,
|
||||||
totalInputContext: item.total_input_context || 0,
|
totalInputContext: item.total_input_context || 0,
|
||||||
outputTokens: item.output_tokens || 0,
|
outputTokens: item.output_tokens || 0,
|
||||||
cacheReadTokens: item.cache_read_tokens || 0,
|
cacheReadTokens: item.cache_read_tokens || 0,
|
||||||
@@ -151,6 +153,7 @@ export function useUsageData(options: UseUsageDataOptions) {
|
|||||||
api_format: item.api_format,
|
api_format: item.api_format,
|
||||||
request_count: item.request_count || 0,
|
request_count: item.request_count || 0,
|
||||||
total_tokens: item.total_tokens || 0,
|
total_tokens: item.total_tokens || 0,
|
||||||
|
effective_input_tokens: item.effective_input_tokens || 0,
|
||||||
total_input_context: item.total_input_context || 0,
|
total_input_context: item.total_input_context || 0,
|
||||||
output_tokens: item.output_tokens || 0,
|
output_tokens: item.output_tokens || 0,
|
||||||
cache_read_tokens: item.cache_read_tokens || 0,
|
cache_read_tokens: item.cache_read_tokens || 0,
|
||||||
@@ -188,6 +191,7 @@ export function useUsageData(options: UseUsageDataOptions) {
|
|||||||
model: item.model,
|
model: item.model,
|
||||||
request_count: item.requests || 0,
|
request_count: item.requests || 0,
|
||||||
total_tokens: item.total_tokens || 0,
|
total_tokens: item.total_tokens || 0,
|
||||||
|
effective_input_tokens: item.effective_input_tokens || 0,
|
||||||
total_input_context: item.total_input_context || 0,
|
total_input_context: item.total_input_context || 0,
|
||||||
output_tokens: item.output_tokens || 0,
|
output_tokens: item.output_tokens || 0,
|
||||||
cache_read_tokens: item.cache_read_tokens || 0,
|
cache_read_tokens: item.cache_read_tokens || 0,
|
||||||
@@ -201,6 +205,7 @@ export function useUsageData(options: UseUsageDataOptions) {
|
|||||||
provider: item.provider,
|
provider: item.provider,
|
||||||
requests: item.requests || 0,
|
requests: item.requests || 0,
|
||||||
totalTokens: item.total_tokens || 0,
|
totalTokens: item.total_tokens || 0,
|
||||||
|
effectiveInputTokens: item.effective_input_tokens || 0,
|
||||||
totalInputContext: item.total_input_context || 0,
|
totalInputContext: item.total_input_context || 0,
|
||||||
outputTokens: item.output_tokens || 0,
|
outputTokens: item.output_tokens || 0,
|
||||||
cacheReadTokens: item.cache_read_tokens || 0,
|
cacheReadTokens: item.cache_read_tokens || 0,
|
||||||
@@ -234,6 +239,7 @@ export function useUsageData(options: UseUsageDataOptions) {
|
|||||||
api_format: item.api_format,
|
api_format: item.api_format,
|
||||||
request_count: item.request_count || 0,
|
request_count: item.request_count || 0,
|
||||||
total_tokens: item.total_tokens || 0,
|
total_tokens: item.total_tokens || 0,
|
||||||
|
effective_input_tokens: item.effective_input_tokens || 0,
|
||||||
total_input_context: item.total_input_context || 0,
|
total_input_context: item.total_input_context || 0,
|
||||||
output_tokens: item.output_tokens || 0,
|
output_tokens: item.output_tokens || 0,
|
||||||
cache_read_tokens: item.cache_read_tokens || 0,
|
cache_read_tokens: item.cache_read_tokens || 0,
|
||||||
@@ -384,6 +390,7 @@ export function useUsageData(options: UseUsageDataOptions) {
|
|||||||
status: mergedStatus,
|
status: mergedStatus,
|
||||||
provider: protectProvider ? existing.provider : (record.provider || existing.provider),
|
provider: protectProvider ? existing.provider : (record.provider || existing.provider),
|
||||||
input_tokens: existing.input_tokens || record.input_tokens,
|
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,
|
output_tokens: existing.output_tokens || record.output_tokens,
|
||||||
cache_creation_input_tokens: existing.cache_creation_input_tokens ?? record.cache_creation_input_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,
|
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
|
model: string
|
||||||
request_count: number
|
request_count: number
|
||||||
total_tokens: number
|
total_tokens: number
|
||||||
|
effective_input_tokens?: number
|
||||||
total_input_context?: number
|
total_input_context?: number
|
||||||
output_tokens?: number
|
output_tokens?: number
|
||||||
cache_read_tokens?: number
|
cache_read_tokens?: number
|
||||||
@@ -41,6 +42,7 @@ export interface ProviderStatsItem {
|
|||||||
provider: string
|
provider: string
|
||||||
requests: number
|
requests: number
|
||||||
totalTokens: number
|
totalTokens: number
|
||||||
|
effectiveInputTokens?: number
|
||||||
totalInputContext?: number
|
totalInputContext?: number
|
||||||
outputTokens?: number
|
outputTokens?: number
|
||||||
cacheReadTokens?: number
|
cacheReadTokens?: number
|
||||||
@@ -57,6 +59,7 @@ export interface ApiFormatStatsItem {
|
|||||||
api_format: string
|
api_format: string
|
||||||
request_count: number
|
request_count: number
|
||||||
total_tokens: number
|
total_tokens: number
|
||||||
|
effective_input_tokens?: number
|
||||||
total_input_context?: number
|
total_input_context?: number
|
||||||
output_tokens?: number
|
output_tokens?: number
|
||||||
cache_read_tokens?: number
|
cache_read_tokens?: number
|
||||||
@@ -92,6 +95,7 @@ export interface UsageRecord {
|
|||||||
endpoint_api_format?: string // 端点原生格式
|
endpoint_api_format?: string // 端点原生格式
|
||||||
has_format_conversion?: boolean // 是否发生了格式转换
|
has_format_conversion?: boolean // 是否发生了格式转换
|
||||||
input_tokens: number
|
input_tokens: number
|
||||||
|
effective_input_tokens?: number
|
||||||
output_tokens: number
|
output_tokens: number
|
||||||
cache_creation_input_tokens?: number
|
cache_creation_input_tokens?: number
|
||||||
cache_read_input_tokens?: number
|
cache_read_input_tokens?: number
|
||||||
|
|||||||
@@ -391,6 +391,7 @@ async function pollActiveRequests() {
|
|||||||
if (shouldApply) {
|
if (shouldApply) {
|
||||||
// 进行中状态也需要持续更新(provider/key/TTFB 可能在 streaming 后才落库)
|
// 进行中状态也需要持续更新(provider/key/TTFB 可能在 streaming 后才落库)
|
||||||
record.input_tokens = update.input_tokens
|
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.output_tokens = update.output_tokens
|
||||||
record.cache_creation_input_tokens = update.cache_creation_input_tokens ?? undefined
|
record.cache_creation_input_tokens = update.cache_creation_input_tokens ?? undefined
|
||||||
record.cache_read_input_tokens = update.cache_read_input_tokens ?? undefined
|
record.cache_read_input_tokens = update.cache_read_input_tokens ?? undefined
|
||||||
|
|||||||
Reference in New Issue
Block a user