Merge remote-tracking branch 'origin/pr/377' into codex/pr-376-377-383-384-combined

# Conflicts:
#	apps/aether-gateway/src/tests/frontdoor/public_support/dashboard.rs
#	crates/aether-data/src/lifecycle/backfill.rs
#	crates/aether-data/src/repository/usage/postgres/mod.rs
This commit is contained in:
fawney19
2026-05-06 02:36:57 +08:00
42 changed files with 1063 additions and 70 deletions

View File

@@ -877,6 +877,7 @@ async fn gateway_handles_admin_stats_provider_performance_locally_with_trusted_a
); );
row.response_time_ms = Some((index * 100) as u64); row.response_time_ms = Some((index * 100) as u64);
row.first_byte_time_ms = Some((index * 10) as u64); row.first_byte_time_ms = Some((index * 10) as u64);
row.request_metadata = Some(json!({ "upstream_is_stream": true }));
row row
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@@ -956,7 +957,7 @@ async fn gateway_handles_admin_stats_provider_performance_locally_with_trusted_a
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["summary"]["request_count"], 12); assert_eq!(payload["summary"]["request_count"], 12);
assert_eq!(payload["summary"]["success_rate"], 91.67); assert_eq!(payload["summary"]["success_rate"], 91.67);
assert_eq!(payload["summary"]["avg_output_tps"], 18.46); assert_eq!(payload["summary"]["avg_output_tps"], 20.17);
assert_eq!(payload["summary"]["avg_first_byte_time_ms"], 55.0); assert_eq!(payload["summary"]["avg_first_byte_time_ms"], 55.0);
assert_eq!(payload["summary"]["avg_response_time_ms"], 590.91); assert_eq!(payload["summary"]["avg_response_time_ms"], 590.91);
@@ -968,7 +969,7 @@ async fn gateway_handles_admin_stats_provider_performance_locally_with_trusted_a
assert_eq!(payload["providers"][0]["error_count"], 1); assert_eq!(payload["providers"][0]["error_count"], 1);
assert_eq!(payload["providers"][0]["success_rate"], 90.91); assert_eq!(payload["providers"][0]["success_rate"], 90.91);
assert_eq!(payload["providers"][0]["output_tokens"], 199); assert_eq!(payload["providers"][0]["output_tokens"], 199);
assert_eq!(payload["providers"][0]["avg_output_tps"], 18.18); assert_eq!(payload["providers"][0]["avg_output_tps"], 20.2);
assert_eq!(payload["providers"][0]["avg_first_byte_time_ms"], 55.0); assert_eq!(payload["providers"][0]["avg_first_byte_time_ms"], 55.0);
assert_eq!(payload["providers"][0]["avg_response_time_ms"], 550.0); assert_eq!(payload["providers"][0]["avg_response_time_ms"], 550.0);
assert_eq!(payload["providers"][0]["p90_response_time_ms"], 910); assert_eq!(payload["providers"][0]["p90_response_time_ms"], 910);
@@ -991,7 +992,7 @@ async fn gateway_handles_admin_stats_provider_performance_locally_with_trusted_a
assert_eq!(payload["timeline"].as_array().map(Vec::len), Some(2)); assert_eq!(payload["timeline"].as_array().map(Vec::len), Some(2));
assert_eq!(payload["timeline"][0]["date"], "2024-03-21T05:00:00+00:00"); assert_eq!(payload["timeline"][0]["date"], "2024-03-21T05:00:00+00:00");
assert_eq!(payload["timeline"][0]["provider_id"], "provider-1"); assert_eq!(payload["timeline"][0]["provider_id"], "provider-1");
assert_eq!(payload["timeline"][0]["avg_output_tps"], 18.18); assert_eq!(payload["timeline"][0]["avg_output_tps"], 20.2);
assert_eq!(payload["timeline"][0]["success_rate"], 90.91); assert_eq!(payload["timeline"][0]["success_rate"], 90.91);
assert_eq!(payload["timeline"][1]["provider_id"], "provider-2"); assert_eq!(payload["timeline"][1]["provider_id"], "provider-2");
assert_eq!( assert_eq!(

View File

@@ -23,7 +23,7 @@ async fn gateway_executes_gemini_files_download_via_local_decision_gate_with_loc
auth_header_value: String, auth_header_value: String,
endpoint_tag: String, endpoint_tag: String,
proxy_node_id: String, proxy_node_id: String,
tls_profile: String, transport_profile_id: String,
} }
let decision_hits = Arc::new(Mutex::new(0usize)); let decision_hits = Arc::new(Mutex::new(0usize));
@@ -126,8 +126,9 @@ async fn gateway_executes_gemini_files_download_via_local_decision_gate_with_loc
.and_then(|value| value.as_str()) .and_then(|value| value.as_str())
.unwrap_or_default() .unwrap_or_default()
.to_string(), .to_string(),
tls_profile: payload transport_profile_id: payload
.get("tls_profile") .get("transport_profile")
.and_then(|value| value.get("profile_id"))
.and_then(|value| value.as_str()) .and_then(|value| value.as_str())
.unwrap_or_default() .unwrap_or_default()
.to_string(), .to_string(),
@@ -258,7 +259,10 @@ async fn gateway_executes_gemini_files_download_via_local_decision_gate_with_loc
seen_execution_runtime_request.proxy_node_id, seen_execution_runtime_request.proxy_node_id,
"proxy-node-gemini-files-download-local" "proxy-node-gemini-files-download-local"
); );
assert_eq!(seen_execution_runtime_request.tls_profile, "chrome_136"); assert_eq!(
seen_execution_runtime_request.transport_profile_id,
"chrome_136"
);
let stored_candidates = request_candidate_repository let stored_candidates = request_candidate_repository
.list_by_request_id("trace-gemini-files-download-local-123") .list_by_request_id("trace-gemini-files-download-local-123")

View File

@@ -21,7 +21,7 @@ async fn gateway_executes_gemini_files_upload_via_local_decision_gate_with_local
body_bytes_b64: String, body_bytes_b64: String,
endpoint_tag: String, endpoint_tag: String,
proxy_node_id: String, proxy_node_id: String,
tls_profile: String, transport_profile_id: String,
} }
let decision_hits = Arc::new(Mutex::new(0usize)); let decision_hits = Arc::new(Mutex::new(0usize));
@@ -154,8 +154,9 @@ async fn gateway_executes_gemini_files_upload_via_local_decision_gate_with_local
.and_then(|value| value.as_str()) .and_then(|value| value.as_str())
.unwrap_or_default() .unwrap_or_default()
.to_string(), .to_string(),
tls_profile: payload transport_profile_id: payload
.get("tls_profile") .get("transport_profile")
.and_then(|value| value.get("profile_id"))
.and_then(|value| value.as_str()) .and_then(|value| value.as_str())
.unwrap_or_default() .unwrap_or_default()
.to_string(), .to_string(),
@@ -277,7 +278,10 @@ async fn gateway_executes_gemini_files_upload_via_local_decision_gate_with_local
seen_execution_runtime_request.proxy_node_id, seen_execution_runtime_request.proxy_node_id,
"proxy-node-gemini-files-upload-local" "proxy-node-gemini-files-upload-local"
); );
assert_eq!(seen_execution_runtime_request.tls_profile, "chrome_136"); assert_eq!(
seen_execution_runtime_request.transport_profile_id,
"chrome_136"
);
let stored_candidates = request_candidate_repository let stored_candidates = request_candidate_repository
.list_by_request_id("trace-gemini-files-upload-local-123") .list_by_request_id("trace-gemini-files-upload-local-123")

View File

@@ -42,7 +42,7 @@ async fn gateway_executes_gemini_video_create_via_local_decision_gate_with_local
metadata_source: String, metadata_source: String,
store_present: bool, store_present: bool,
proxy_node_id: String, proxy_node_id: String,
tls_profile: String, transport_profile_id: String,
} }
fn hash_api_key(value: &str) -> String { fn hash_api_key(value: &str) -> String {
@@ -350,8 +350,9 @@ async fn gateway_executes_gemini_video_create_via_local_decision_gate_with_local
.and_then(|value| value.as_str()) .and_then(|value| value.as_str())
.unwrap_or_default() .unwrap_or_default()
.to_string(), .to_string(),
tls_profile: payload transport_profile_id: payload
.get("tls_profile") .get("transport_profile")
.and_then(|value| value.get("profile_id"))
.and_then(|value| value.as_str()) .and_then(|value| value.as_str())
.unwrap_or_default() .unwrap_or_default()
.to_string(), .to_string(),
@@ -465,7 +466,10 @@ async fn gateway_executes_gemini_video_create_via_local_decision_gate_with_local
seen_execution_runtime_request.proxy_node_id, seen_execution_runtime_request.proxy_node_id,
"proxy-node-gemini-video-local" "proxy-node-gemini-video-local"
); );
assert_eq!(seen_execution_runtime_request.tls_profile, "chrome_136"); assert_eq!(
seen_execution_runtime_request.transport_profile_id,
"chrome_136"
);
let stored_candidates = request_candidate_repository let stored_candidates = request_candidate_repository
.list_by_request_id("trace-gemini-video-local-123") .list_by_request_id("trace-gemini-video-local-123")

View File

@@ -47,7 +47,7 @@ async fn gateway_executes_openai_video_create_via_local_decision_gate_with_local
metadata_source: String, metadata_source: String,
store_present: bool, store_present: bool,
proxy_node_id: String, proxy_node_id: String,
tls_profile: String, transport_profile_id: String,
} }
fn hash_api_key(value: &str) -> String { fn hash_api_key(value: &str) -> String {
@@ -362,8 +362,9 @@ async fn gateway_executes_openai_video_create_via_local_decision_gate_with_local
.and_then(|value| value.as_str()) .and_then(|value| value.as_str())
.unwrap_or_default() .unwrap_or_default()
.to_string(), .to_string(),
tls_profile: payload transport_profile_id: payload
.get("tls_profile") .get("transport_profile")
.and_then(|value| value.get("profile_id"))
.and_then(|value| value.as_str()) .and_then(|value| value.as_str())
.unwrap_or_default() .unwrap_or_default()
.to_string(), .to_string(),
@@ -479,7 +480,10 @@ async fn gateway_executes_openai_video_create_via_local_decision_gate_with_local
seen_execution_runtime_request.proxy_node_id, seen_execution_runtime_request.proxy_node_id,
"proxy-node-openai-video-local" "proxy-node-openai-video-local"
); );
assert_eq!(seen_execution_runtime_request.tls_profile, "chrome_136"); assert_eq!(
seen_execution_runtime_request.transport_profile_id,
"chrome_136"
);
let stored_candidates = request_candidate_repository let stored_candidates = request_candidate_repository
.list_by_request_id("trace-openai-video-local-123") .list_by_request_id("trace-openai-video-local-123")

View File

@@ -0,0 +1,25 @@
WITH aggregated AS (
SELECT
usage.model,
COUNT(*)::BIGINT AS usage_count
FROM usage_billing_facts AS usage
WHERE usage.model IS NOT NULL
AND BTRIM(usage.model) <> ''
AND usage.status NOT IN ('pending', 'streaming')
GROUP BY usage.model
),
refreshed AS (
SELECT
gm.id,
COALESCE(aggregated.usage_count, 0)::BIGINT AS usage_count
FROM global_models AS gm
LEFT JOIN aggregated
ON aggregated.model = gm.name
)
UPDATE global_models AS gm
SET
usage_count = refreshed.usage_count,
updated_at = NOW()
FROM refreshed
WHERE gm.id = refreshed.id
AND COALESCE(gm.usage_count, 0) <> refreshed.usage_count;

View File

@@ -118,7 +118,6 @@ CREATE TABLE IF NOT EXISTS management_tokens (
UNIQUE KEY uq_management_tokens_user_name (user_id, name), UNIQUE KEY uq_management_tokens_user_name (user_id, name),
KEY management_tokens_user_id_idx (user_id) KEY management_tokens_user_id_idx (user_id)
); );
CREATE TABLE IF NOT EXISTS billing_rules ( CREATE TABLE IF NOT EXISTS billing_rules (
id VARCHAR(64) PRIMARY KEY, id VARCHAR(64) PRIMARY KEY,
global_model_id VARCHAR(64), global_model_id VARCHAR(64),
@@ -414,7 +413,6 @@ CREATE TABLE IF NOT EXISTS global_models (
updated_at BIGINT NOT NULL, updated_at BIGINT NOT NULL,
UNIQUE KEY global_models_name_key (name) UNIQUE KEY global_models_name_key (name)
); );
CREATE TABLE IF NOT EXISTS system_configs ( CREATE TABLE IF NOT EXISTS system_configs (
id VARCHAR(64) PRIMARY KEY, id VARCHAR(64) PRIMARY KEY,
`key` VARCHAR(255) NOT NULL, `key` VARCHAR(255) NOT NULL,
@@ -484,7 +482,6 @@ CREATE TABLE IF NOT EXISTS user_oauth_links (
KEY user_oauth_links_provider_type_idx (provider_type), KEY user_oauth_links_provider_type_idx (provider_type),
KEY user_oauth_links_user_id_idx (user_id) KEY user_oauth_links_user_id_idx (user_id)
); );
CREATE TABLE IF NOT EXISTS proxy_nodes ( CREATE TABLE IF NOT EXISTS proxy_nodes (
id VARCHAR(64) PRIMARY KEY, id VARCHAR(64) PRIMARY KEY,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
@@ -524,7 +521,6 @@ CREATE TABLE IF NOT EXISTS proxy_node_events (
detail VARCHAR(500), detail VARCHAR(500),
created_at BIGINT NOT NULL created_at BIGINT NOT NULL
); );
CREATE TABLE IF NOT EXISTS wallets ( CREATE TABLE IF NOT EXISTS wallets (
id VARCHAR(64) PRIMARY KEY, id VARCHAR(64) PRIMARY KEY,
user_id VARCHAR(64), user_id VARCHAR(64),
@@ -708,7 +704,6 @@ CREATE TABLE IF NOT EXISTS redeem_codes (
KEY idx_redeem_codes_redeemed_user (redeemed_by_user_id, redeemed_at), KEY idx_redeem_codes_redeemed_user (redeemed_by_user_id, redeemed_at),
KEY idx_redeem_codes_redeemed_order (redeemed_payment_order_id) KEY idx_redeem_codes_redeemed_order (redeemed_payment_order_id)
); );
CREATE TABLE IF NOT EXISTS `usage` ( CREATE TABLE IF NOT EXISTS `usage` (
request_id VARCHAR(128) PRIMARY KEY, request_id VARCHAR(128) PRIMARY KEY,
id VARCHAR(128), id VARCHAR(128),

View File

@@ -4620,7 +4620,6 @@ EXCEPTION
WHEN duplicate_table THEN NULL; WHEN duplicate_table THEN NULL;
WHEN invalid_table_definition THEN NULL; WHEN invalid_table_definition THEN NULL;
END $mig$; END $mig$;
-- Restore a normal lookup path before sqlx records this migration in the -- Restore a normal lookup path before sqlx records this migration in the
-- same transaction. sqlx inserts into `_sqlx_migrations` unqualified. -- same transaction. sqlx inserts into `_sqlx_migrations` unqualified.
SELECT pg_catalog.set_config('search_path', 'public', true); SELECT pg_catalog.set_config('search_path', 'public', true);

View File

@@ -0,0 +1,233 @@
ALTER TABLE IF EXISTS public.usage
ADD COLUMN IF NOT EXISTS upstream_is_stream boolean;
UPDATE public.usage
SET upstream_is_stream = COALESCE(
CASE
WHEN (request_metadata->>'upstream_is_stream') IN ('true', 'false')
THEN (request_metadata->>'upstream_is_stream')::boolean
ELSE NULL
END,
COALESCE(is_stream, FALSE)
)
WHERE upstream_is_stream IS NULL;
ANALYZE public.usage;
COMMENT ON COLUMN public.usage.upstream_is_stream IS
'Resolved upstream stream mode from request_metadata.upstream_is_stream, falling back to is_stream for legacy rows.';
CREATE OR REPLACE VIEW public.usage_billing_facts AS
SELECT
usage_rows.id,
usage_rows.request_id,
usage_rows.user_id,
usage_rows.api_key_id,
usage_rows.username,
usage_rows.api_key_name,
usage_rows.provider_name,
usage_rows.model,
usage_rows.target_model,
usage_rows.provider_id,
usage_rows.provider_endpoint_id,
usage_rows.provider_api_key_id,
usage_rows.request_type,
usage_rows.api_format,
usage_rows.api_family,
usage_rows.endpoint_kind,
usage_rows.endpoint_api_format,
usage_rows.provider_api_family,
usage_rows.provider_endpoint_kind,
COALESCE(usage_rows.has_format_conversion, FALSE) AS has_format_conversion,
COALESCE(usage_rows.is_stream, FALSE) AS is_stream,
usage_rows.status_code,
usage_rows.error_message,
usage_rows.error_category,
usage_rows.response_time_ms,
usage_rows.first_byte_time_ms,
usage_rows.status,
COALESCE(settlement.billing_status, usage_rows.billing_status) AS billing_status,
usage_rows.created_at,
COALESCE(settlement.finalized_at, usage_rows.finalized_at) AS finalized_at,
GREATEST(COALESCE(settlement.billing_input_tokens, usage_rows.input_tokens, 0), 0)::bigint
AS input_tokens,
GREATEST(
COALESCE(
settlement.billing_effective_input_tokens,
CASE
WHEN GREATEST(COALESCE(usage_rows.input_tokens, 0), 0) <= 0 THEN 0
WHEN GREATEST(COALESCE(usage_rows.cache_read_input_tokens, 0), 0) <= 0
THEN GREATEST(COALESCE(usage_rows.input_tokens, 0), 0)
WHEN split_part(lower(COALESCE(COALESCE(usage_rows.endpoint_api_format, usage_rows.api_format), '')), ':', 1)
IN ('openai', 'gemini', 'google')
THEN GREATEST(
GREATEST(COALESCE(usage_rows.input_tokens, 0), 0)
- GREATEST(COALESCE(usage_rows.cache_read_input_tokens, 0), 0),
0
)
ELSE GREATEST(COALESCE(usage_rows.input_tokens, 0), 0)
END
),
0
)::bigint AS effective_input_tokens,
GREATEST(COALESCE(settlement.billing_output_tokens, usage_rows.output_tokens, 0), 0)::bigint
AS output_tokens,
GREATEST(
COALESCE(
settlement.billing_cache_creation_tokens,
CASE
WHEN COALESCE(usage_rows.cache_creation_input_tokens, 0) = 0
AND (
COALESCE(usage_rows.cache_creation_input_tokens_5m, 0)
+ COALESCE(usage_rows.cache_creation_input_tokens_1h, 0)
) > 0
THEN COALESCE(usage_rows.cache_creation_input_tokens_5m, 0)
+ COALESCE(usage_rows.cache_creation_input_tokens_1h, 0)
ELSE COALESCE(usage_rows.cache_creation_input_tokens, 0)
END,
0
),
0
)::bigint AS cache_creation_input_tokens,
GREATEST(
COALESCE(
settlement.billing_cache_creation_5m_tokens,
usage_rows.cache_creation_input_tokens_5m,
0
),
0
)::bigint AS cache_creation_input_tokens_5m,
GREATEST(
COALESCE(
settlement.billing_cache_creation_1h_tokens,
usage_rows.cache_creation_input_tokens_1h,
0
),
0
)::bigint AS cache_creation_input_tokens_1h,
GREATEST(COALESCE(settlement.billing_cache_read_tokens, usage_rows.cache_read_input_tokens, 0), 0)::bigint
AS cache_read_input_tokens,
GREATEST(
COALESCE(
CASE
WHEN settlement.billing_input_tokens IS NOT NULL
OR settlement.billing_output_tokens IS NOT NULL
OR settlement.billing_cache_creation_tokens IS NOT NULL
OR settlement.billing_cache_creation_5m_tokens IS NOT NULL
OR settlement.billing_cache_creation_1h_tokens IS NOT NULL
OR settlement.billing_cache_read_tokens IS NOT NULL
THEN COALESCE(settlement.billing_input_tokens, 0)
+ COALESCE(settlement.billing_output_tokens, 0)
+ COALESCE(
settlement.billing_cache_creation_tokens,
COALESCE(settlement.billing_cache_creation_5m_tokens, 0)
+ COALESCE(settlement.billing_cache_creation_1h_tokens, 0),
0
)
+ COALESCE(settlement.billing_cache_read_tokens, 0)
END,
usage_rows.total_tokens,
0
),
0
)::bigint AS total_tokens,
GREATEST(
COALESCE(
settlement.billing_total_input_context,
CASE
WHEN split_part(lower(COALESCE(COALESCE(usage_rows.endpoint_api_format, usage_rows.api_format), '')), ':', 1)
IN ('claude', 'anthropic')
THEN GREATEST(COALESCE(usage_rows.input_tokens, 0), 0)
+ CASE
WHEN COALESCE(usage_rows.cache_creation_input_tokens, 0) = 0
AND (
COALESCE(usage_rows.cache_creation_input_tokens_5m, 0)
+ COALESCE(usage_rows.cache_creation_input_tokens_1h, 0)
) > 0
THEN COALESCE(usage_rows.cache_creation_input_tokens_5m, 0)
+ COALESCE(usage_rows.cache_creation_input_tokens_1h, 0)
ELSE COALESCE(usage_rows.cache_creation_input_tokens, 0)
END
+ GREATEST(COALESCE(usage_rows.cache_read_input_tokens, 0), 0)
WHEN split_part(lower(COALESCE(COALESCE(usage_rows.endpoint_api_format, usage_rows.api_format), '')), ':', 1)
IN ('openai', 'gemini', 'google')
THEN CASE
WHEN GREATEST(COALESCE(usage_rows.input_tokens, 0), 0) <= 0 THEN 0
WHEN GREATEST(COALESCE(usage_rows.cache_read_input_tokens, 0), 0) <= 0
THEN GREATEST(COALESCE(usage_rows.input_tokens, 0), 0)
ELSE GREATEST(
GREATEST(COALESCE(usage_rows.input_tokens, 0), 0)
- GREATEST(COALESCE(usage_rows.cache_read_input_tokens, 0), 0),
0
)
END
+ GREATEST(COALESCE(usage_rows.cache_read_input_tokens, 0), 0)
ELSE GREATEST(COALESCE(usage_rows.input_tokens, 0), 0)
+ CASE
WHEN COALESCE(usage_rows.cache_creation_input_tokens, 0) = 0
AND (
COALESCE(usage_rows.cache_creation_input_tokens_5m, 0)
+ COALESCE(usage_rows.cache_creation_input_tokens_1h, 0)
) > 0
THEN COALESCE(usage_rows.cache_creation_input_tokens_5m, 0)
+ COALESCE(usage_rows.cache_creation_input_tokens_1h, 0)
ELSE COALESCE(usage_rows.cache_creation_input_tokens, 0)
END
+ GREATEST(COALESCE(usage_rows.cache_read_input_tokens, 0), 0)
END,
0
),
0
)::bigint AS total_input_context,
COALESCE(CAST(usage_rows.input_cost_usd AS DOUBLE PRECISION), 0) AS input_cost_usd,
COALESCE(CAST(usage_rows.output_cost_usd AS DOUBLE PRECISION), 0) AS output_cost_usd,
COALESCE(
CAST(settlement.billing_cache_creation_cost_usd AS DOUBLE PRECISION),
CAST(usage_rows.cache_creation_cost_usd AS DOUBLE PRECISION),
0
) AS cache_creation_cost_usd,
COALESCE(
CAST(settlement.billing_cache_read_cost_usd AS DOUBLE PRECISION),
CAST(usage_rows.cache_read_cost_usd AS DOUBLE PRECISION),
0
) AS cache_read_cost_usd,
COALESCE(
CAST(settlement.billing_total_cost_usd AS DOUBLE PRECISION),
CAST(usage_rows.total_cost_usd AS DOUBLE PRECISION),
0
) AS total_cost_usd,
COALESCE(
CAST(settlement.billing_actual_total_cost_usd AS DOUBLE PRECISION),
CAST(usage_rows.actual_total_cost_usd AS DOUBLE PRECISION),
0
) AS actual_total_cost_usd,
COALESCE(
CAST(settlement.output_price_per_1m AS DOUBLE PRECISION),
CAST(usage_rows.output_price_per_1m AS DOUBLE PRECISION)
) AS output_price_per_1m,
COALESCE(
CAST(settlement.input_price_per_1m AS DOUBLE PRECISION),
CAST(usage_rows.input_price_per_1m AS DOUBLE PRECISION)
) AS input_price_per_1m,
COALESCE(
CAST(settlement.cache_creation_price_per_1m AS DOUBLE PRECISION),
CAST(usage_rows.cache_creation_price_per_1m AS DOUBLE PRECISION)
) AS cache_creation_price_per_1m,
COALESCE(
CAST(settlement.cache_read_price_per_1m AS DOUBLE PRECISION),
CAST(usage_rows.cache_read_price_per_1m AS DOUBLE PRECISION)
) AS cache_read_price_per_1m,
COALESCE(
CAST(settlement.price_per_request AS DOUBLE PRECISION),
CAST(usage_rows.price_per_request AS DOUBLE PRECISION)
) AS price_per_request,
settlement.billing_pricing_source,
settlement.billing_rule_id,
settlement.billing_rule_version,
COALESCE(usage_rows.upstream_is_stream, COALESCE(usage_rows.is_stream, FALSE)) AS upstream_is_stream
FROM public."usage" AS usage_rows
LEFT JOIN public.usage_settlement_snapshots AS settlement
ON settlement.request_id = usage_rows.request_id;
COMMENT ON COLUMN public.usage_billing_facts.upstream_is_stream IS
'Resolved upstream stream mode from public.usage.upstream_is_stream, falling back to usage.is_stream for legacy rows.';

View File

@@ -114,7 +114,6 @@ CREATE TABLE IF NOT EXISTS management_tokens (
UNIQUE (user_id, name) UNIQUE (user_id, name)
); );
CREATE INDEX IF NOT EXISTS management_tokens_user_id_idx ON management_tokens (user_id); CREATE INDEX IF NOT EXISTS management_tokens_user_id_idx ON management_tokens (user_id);
CREATE TABLE IF NOT EXISTS billing_rules ( CREATE TABLE IF NOT EXISTS billing_rules (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
global_model_id TEXT, global_model_id TEXT,
@@ -414,7 +413,6 @@ CREATE TABLE IF NOT EXISTS global_models (
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL updated_at INTEGER NOT NULL
); );
CREATE TABLE IF NOT EXISTS system_configs ( CREATE TABLE IF NOT EXISTS system_configs (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
key TEXT NOT NULL UNIQUE, key TEXT NOT NULL UNIQUE,
@@ -482,7 +480,6 @@ CREATE TABLE IF NOT EXISTS user_oauth_links (
); );
CREATE INDEX IF NOT EXISTS user_oauth_links_provider_type_idx ON user_oauth_links (provider_type); CREATE INDEX IF NOT EXISTS user_oauth_links_provider_type_idx ON user_oauth_links (provider_type);
CREATE INDEX IF NOT EXISTS user_oauth_links_user_id_idx ON user_oauth_links (user_id); CREATE INDEX IF NOT EXISTS user_oauth_links_user_id_idx ON user_oauth_links (user_id);
CREATE TABLE IF NOT EXISTS proxy_nodes ( CREATE TABLE IF NOT EXISTS proxy_nodes (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
@@ -522,7 +519,6 @@ CREATE TABLE IF NOT EXISTS proxy_node_events (
detail TEXT, detail TEXT,
created_at INTEGER NOT NULL created_at INTEGER NOT NULL
); );
CREATE TABLE IF NOT EXISTS wallets ( CREATE TABLE IF NOT EXISTS wallets (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id TEXT UNIQUE, user_id TEXT UNIQUE,
@@ -725,7 +721,6 @@ CREATE INDEX IF NOT EXISTS idx_redeem_codes_redeemed_user
ON redeem_codes (redeemed_by_user_id, redeemed_at); ON redeem_codes (redeemed_by_user_id, redeemed_at);
CREATE INDEX IF NOT EXISTS idx_redeem_codes_redeemed_order CREATE INDEX IF NOT EXISTS idx_redeem_codes_redeemed_order
ON redeem_codes (redeemed_payment_order_id); ON redeem_codes (redeemed_payment_order_id);
CREATE TABLE IF NOT EXISTS "usage" ( CREATE TABLE IF NOT EXISTS "usage" (
request_id TEXT PRIMARY KEY, request_id TEXT PRIMARY KEY,
id TEXT, id TEXT,

View File

@@ -1083,6 +1083,7 @@ CREATE TABLE IF NOT EXISTS public.usage (
request_type character varying(50), request_type character varying(50),
api_format character varying(50), api_format character varying(50),
is_stream boolean DEFAULT false, is_stream boolean DEFAULT false,
upstream_is_stream boolean,
status_code integer, status_code integer,
error_message text, error_message text,
response_time_ms integer, response_time_ms integer,

View File

@@ -849,4 +849,3 @@ EXCEPTION
WHEN duplicate_table THEN NULL; WHEN duplicate_table THEN NULL;
WHEN invalid_table_definition THEN NULL; WHEN invalid_table_definition THEN NULL;
END $mig$; END $mig$;

View File

@@ -7,4 +7,3 @@ SELECT pg_catalog.set_config('search_path', 'public', true);
-- --
-- PostgreSQL database dump complete -- PostgreSQL database dump complete
-- --

View File

@@ -351,7 +351,8 @@ SELECT
) AS price_per_request, ) AS price_per_request,
settlement.billing_pricing_source, settlement.billing_pricing_source,
settlement.billing_rule_id, settlement.billing_rule_id,
settlement.billing_rule_version settlement.billing_rule_version,
COALESCE(usage_rows.upstream_is_stream, COALESCE(usage_rows.is_stream, FALSE)) AS upstream_is_stream
FROM public."usage" AS usage_rows FROM public."usage" AS usage_rows
LEFT JOIN public.usage_settlement_snapshots AS settlement LEFT JOIN public.usage_settlement_snapshots AS settlement
ON settlement.request_id = usage_rows.request_id; ON settlement.request_id = usage_rows.request_id;
@@ -359,6 +360,9 @@ LEFT JOIN public.usage_settlement_snapshots AS settlement
COMMENT ON VIEW public.usage_billing_facts IS COMMENT ON VIEW public.usage_billing_facts IS
'Canonical billing read model. Token/cost fields prefer usage_settlement_snapshots.billing_* and fall back to deprecated usage mirrors for legacy rows.'; 'Canonical billing read model. Token/cost fields prefer usage_settlement_snapshots.billing_* and fall back to deprecated usage mirrors for legacy rows.';
COMMENT ON COLUMN public.usage_billing_facts.upstream_is_stream IS
'Resolved upstream stream mode from public.usage.upstream_is_stream, falling back to usage.is_stream for legacy rows.';
COMMENT ON COLUMN public.usage.input_tokens IS COMMENT ON COLUMN public.usage.input_tokens IS
'DEPRECATED: billing dimension mirror. Use public.usage_settlement_snapshots.billing_input_tokens or public.usage_billing_facts.input_tokens.'; 'DEPRECATED: billing dimension mirror. Use public.usage_settlement_snapshots.billing_input_tokens or public.usage_billing_facts.input_tokens.';
COMMENT ON COLUMN public.usage.output_tokens IS COMMENT ON COLUMN public.usage.output_tokens IS
@@ -440,4 +444,3 @@ COMMENT ON COLUMN public.usage.client_response_body IS
'DEPRECATED: HTTP body owner moved to public.usage_body_blobs plus public.usage_http_audits.client_response_body_ref. Legacy compatibility only; do not write new values.'; 'DEPRECATED: HTTP body owner moved to public.usage_body_blobs plus public.usage_http_audits.client_response_body_ref. Legacy compatibility only; do not write new values.';
COMMENT ON COLUMN public.usage.client_response_body_compressed IS COMMENT ON COLUMN public.usage.client_response_body_compressed IS
'DEPRECATED: HTTP body owner moved to public.usage_body_blobs plus public.usage_http_audits.client_response_body_ref. Legacy compatibility only; do not write new values.'; 'DEPRECATED: HTTP body owner moved to public.usage_body_blobs plus public.usage_http_audits.client_response_body_ref. Legacy compatibility only; do not write new values.';

View File

@@ -130,4 +130,3 @@ ALTER TABLE public.stats_hourly_user
ADD COLUMN IF NOT EXISTS actual_total_cost numeric(20,8) DEFAULT '0'::double precision NOT NULL, ADD COLUMN IF NOT EXISTS actual_total_cost numeric(20,8) DEFAULT '0'::double precision NOT NULL,
ADD COLUMN IF NOT EXISTS response_time_sum_ms double precision DEFAULT '0'::double precision NOT NULL, ADD COLUMN IF NOT EXISTS response_time_sum_ms double precision DEFAULT '0'::double precision NOT NULL,
ADD COLUMN IF NOT EXISTS response_time_samples bigint DEFAULT 0 NOT NULL; ADD COLUMN IF NOT EXISTS response_time_samples bigint DEFAULT 0 NOT NULL;

View File

@@ -322,4 +322,3 @@ ALTER TABLE public.stats_hourly_user
ADD COLUMN IF NOT EXISTS settled_cache_read_tokens bigint DEFAULT 0 NOT NULL, ADD COLUMN IF NOT EXISTS settled_cache_read_tokens bigint DEFAULT 0 NOT NULL,
ADD COLUMN IF NOT EXISTS settled_first_finalized_at_unix_secs bigint, ADD COLUMN IF NOT EXISTS settled_first_finalized_at_unix_secs bigint,
ADD COLUMN IF NOT EXISTS settled_last_finalized_at_unix_secs bigint; ADD COLUMN IF NOT EXISTS settled_last_finalized_at_unix_secs bigint;

View File

@@ -118,4 +118,3 @@ CREATE TABLE IF NOT EXISTS management_tokens (
UNIQUE KEY uq_management_tokens_user_name (user_id, name), UNIQUE KEY uq_management_tokens_user_name (user_id, name),
KEY management_tokens_user_id_idx (user_id) KEY management_tokens_user_id_idx (user_id)
); );

View File

@@ -293,4 +293,3 @@ CREATE TABLE IF NOT EXISTS global_models (
updated_at BIGINT NOT NULL, updated_at BIGINT NOT NULL,
UNIQUE KEY global_models_name_key (name) UNIQUE KEY global_models_name_key (name)
); );

View File

@@ -67,4 +67,3 @@ CREATE TABLE IF NOT EXISTS user_oauth_links (
KEY user_oauth_links_provider_type_idx (provider_type), KEY user_oauth_links_provider_type_idx (provider_type),
KEY user_oauth_links_user_id_idx (user_id) KEY user_oauth_links_user_id_idx (user_id)
); );

View File

@@ -37,4 +37,3 @@ CREATE TABLE IF NOT EXISTS proxy_node_events (
detail VARCHAR(500), detail VARCHAR(500),
created_at BIGINT NOT NULL created_at BIGINT NOT NULL
); );

View File

@@ -181,4 +181,3 @@ CREATE TABLE IF NOT EXISTS redeem_codes (
KEY idx_redeem_codes_redeemed_user (redeemed_by_user_id, redeemed_at), KEY idx_redeem_codes_redeemed_user (redeemed_by_user_id, redeemed_at),
KEY idx_redeem_codes_redeemed_order (redeemed_payment_order_id) KEY idx_redeem_codes_redeemed_order (redeemed_payment_order_id)
); );

View File

@@ -849,4 +849,3 @@ EXCEPTION
WHEN duplicate_table THEN NULL; WHEN duplicate_table THEN NULL;
WHEN invalid_table_definition THEN NULL; WHEN invalid_table_definition THEN NULL;
END $mig$; END $mig$;

View File

@@ -114,4 +114,3 @@ CREATE TABLE IF NOT EXISTS management_tokens (
UNIQUE (user_id, name) UNIQUE (user_id, name)
); );
CREATE INDEX IF NOT EXISTS management_tokens_user_id_idx ON management_tokens (user_id); CREATE INDEX IF NOT EXISTS management_tokens_user_id_idx ON management_tokens (user_id);

View File

@@ -297,4 +297,3 @@ CREATE TABLE IF NOT EXISTS global_models (
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL updated_at INTEGER NOT NULL
); );

View File

@@ -65,4 +65,3 @@ CREATE TABLE IF NOT EXISTS user_oauth_links (
); );
CREATE INDEX IF NOT EXISTS user_oauth_links_provider_type_idx ON user_oauth_links (provider_type); CREATE INDEX IF NOT EXISTS user_oauth_links_provider_type_idx ON user_oauth_links (provider_type);
CREATE INDEX IF NOT EXISTS user_oauth_links_user_id_idx ON user_oauth_links (user_id); CREATE INDEX IF NOT EXISTS user_oauth_links_user_id_idx ON user_oauth_links (user_id);

View File

@@ -37,4 +37,3 @@ CREATE TABLE IF NOT EXISTS proxy_node_events (
detail TEXT, detail TEXT,
created_at INTEGER NOT NULL created_at INTEGER NOT NULL
); );

View File

@@ -200,4 +200,3 @@ CREATE INDEX IF NOT EXISTS idx_redeem_codes_redeemed_user
ON redeem_codes (redeemed_by_user_id, redeemed_at); ON redeem_codes (redeemed_by_user_id, redeemed_at);
CREATE INDEX IF NOT EXISTS idx_redeem_codes_redeemed_order CREATE INDEX IF NOT EXISTS idx_redeem_codes_redeemed_order
ON redeem_codes (redeemed_payment_order_id); ON redeem_codes (redeemed_payment_order_id);

View File

@@ -308,7 +308,12 @@ mod tests {
.collect::<Vec<_>>(); .collect::<Vec<_>>();
assert_eq!( assert_eq!(
versions, versions,
vec![20260422110000, 20260422120000, 20260504120000] vec![
20260422110000,
20260422120000,
20260504120000,
20260505120000
]
); );
} }
@@ -321,7 +326,10 @@ mod tests {
.into_iter() .into_iter()
.map(|item| item.version) .map(|item| item.version)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
assert_eq!(versions, vec![20260422120000, 20260504120000]); assert_eq!(
versions,
vec![20260422120000, 20260504120000, 20260505120000]
);
} }
#[tokio::test] #[tokio::test]
@@ -604,6 +612,25 @@ mod tests {
.await .await
.expect("api key fixture should insert"); .expect("api key fixture should insert");
query(
r#"
INSERT INTO public.global_models (
id,
name,
display_name,
usage_count
) VALUES (
'global-model-backfill-1',
'gpt-4o-mini',
'GPT-4o Mini',
77
)
"#,
)
.execute(&pool)
.await
.expect("global model fixture should insert");
query( query(
r#" r#"
INSERT INTO public.usage ( INSERT INTO public.usage (
@@ -670,10 +697,11 @@ mod tests {
let pending_before = pending_backfills(&pool) let pending_before = pending_backfills(&pool)
.await .await
.expect("pending backfills should load"); .expect("pending backfills should load");
assert_eq!(pending_before.len(), 3); assert_eq!(pending_before.len(), 4);
assert_eq!(pending_before[0].version, 20260422110000); assert_eq!(pending_before[0].version, 20260422110000);
assert_eq!(pending_before[1].version, 20260422120000); assert_eq!(pending_before[1].version, 20260422120000);
assert_eq!(pending_before[2].version, 20260504120000); assert_eq!(pending_before[2].version, 20260504120000);
assert_eq!(pending_before[3].version, 20260505120000);
run_backfills(&pool) run_backfills(&pool)
.await .await
@@ -691,7 +719,12 @@ mod tests {
.expect("applied backfill versions should load"); .expect("applied backfill versions should load");
assert_eq!( assert_eq!(
applied_versions, applied_versions,
vec![20260422110000, 20260422120000, 20260504120000] vec![
20260422110000,
20260422120000,
20260504120000,
20260505120000
]
); );
let api_key_total_requests: i64 = query_scalar( let api_key_total_requests: i64 = query_scalar(
@@ -702,6 +735,14 @@ mod tests {
.expect("api key total requests should load"); .expect("api key total requests should load");
assert_eq!(api_key_total_requests, 1); assert_eq!(api_key_total_requests, 1);
let global_model_usage_count: i64 = query_scalar(
"SELECT COALESCE(usage_count, 0)::BIGINT FROM public.global_models WHERE name = 'gpt-4o-mini'",
)
.fetch_one(&pool)
.await
.expect("global model usage count should load");
assert_eq!(global_model_usage_count, 1);
let api_key_total_tokens: i64 = query_scalar( let api_key_total_tokens: i64 = query_scalar(
"SELECT COALESCE(total_tokens, 0)::BIGINT FROM public.api_keys WHERE id = 'api-key-backfill-1'", "SELECT COALESCE(total_tokens, 0)::BIGINT FROM public.api_keys WHERE id = 'api-key-backfill-1'",
) )

View File

@@ -7,7 +7,7 @@ use tracing::info;
// Generated by build.rs from schema/bootstrap/postgres. // Generated by build.rs from schema/bootstrap/postgres.
pub(crate) static EMPTY_DATABASE_SNAPSHOT_SQL: &str = pub(crate) static EMPTY_DATABASE_SNAPSHOT_SQL: &str =
include_str!(concat!(env!("OUT_DIR"), "/empty_database_snapshot.sql")); include_str!(concat!(env!("OUT_DIR"), "/empty_database_snapshot.sql"));
pub(crate) const EMPTY_DATABASE_SNAPSHOT_CUTOFF_VERSION: i64 = 20260505000000; pub(crate) const EMPTY_DATABASE_SNAPSHOT_CUTOFF_VERSION: i64 = 20260505130000;
const PUBLIC_BASE_TABLE_COUNT_SQL: &str = r#" const PUBLIC_BASE_TABLE_COUNT_SQL: &str = r#"
SELECT COUNT(*)::BIGINT SELECT COUNT(*)::BIGINT

View File

@@ -290,6 +290,7 @@ fn empty_database_snapshot_covers_current_cutoff_versions() {
20260428000000, 20260428000000,
20260502000000, 20260502000000,
20260505000000, 20260505000000,
20260505130000,
] ]
); );
} }
@@ -989,6 +990,7 @@ fn pending_migrations_from_applied_skips_versions_already_applied() {
20260428000000, 20260428000000,
20260502000000, 20260502000000,
20260505000000, 20260505000000,
20260505130000,
] ]
); );
} }

View File

@@ -1205,7 +1205,10 @@ fn map_provider_active_global_model_row(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{SqlxGlobalModelReadRepository, LIST_ADMIN_PROVIDER_MODELS_PREFIX}; use super::{
SqlxGlobalModelReadRepository, LIST_ADMIN_GLOBAL_MODELS_PREFIX,
LIST_ADMIN_PROVIDER_MODELS_PREFIX,
};
use crate::driver::postgres::{PostgresPoolConfig, PostgresPoolFactory}; use crate::driver::postgres::{PostgresPoolConfig, PostgresPoolFactory};
const ADMIN_PROVIDER_MODEL_REQUIRED_COLUMNS: &[&str] = &[ const ADMIN_PROVIDER_MODEL_REQUIRED_COLUMNS: &[&str] = &[
@@ -1241,6 +1244,66 @@ mod tests {
); );
} }
#[test]
fn admin_global_model_sql_projects_usage_count_from_billing_facts() {
let source = include_str!("postgres.rs");
assert!(
!LIST_ADMIN_GLOBAL_MODELS_PREFIX.contains("usage_billing_facts"),
"admin global model list should read the maintained usage_count field"
);
assert!(
source.contains("WHERE usage.model = gm.name"),
"admin global model detail lookups should count usage facts for the selected model"
);
assert!(
source.contains("AND usage.status NOT IN ('pending', 'streaming')"),
"admin global model detail usage_count should use the maintained read-model status scope"
);
assert!(
source.contains("COALESCE(usage_stats.usage_count, gm.usage_count, 0)::bigint AS usage_count"),
"admin global model usage_count should prefer actual usage facts with stored-count fallback"
);
}
#[test]
fn admin_global_model_list_sql_counts_usage_without_full_fact_aggregate() {
assert!(
LIST_ADMIN_GLOBAL_MODELS_PREFIX.contains("COALESCE(gm.usage_count, 0)::bigint AS usage_count"),
"admin global model list should read maintained usage_count without per-request fact scans"
);
assert!(
!LIST_ADMIN_GLOBAL_MODELS_PREFIX.contains("GROUP BY usage.model"),
"admin global model list must not aggregate the full usage fact table before pagination"
);
}
#[test]
fn global_model_usage_count_read_model_has_backfill_and_delta_maintenance() {
let backfill_sql = include_str!(
"../../../backfills/postgres/20260505120000_rebuild_global_model_usage_count.sql"
);
let delta_sql =
include_str!("../usage/postgres/queries/apply_global_model_usage_delta_sql.sql");
assert!(
backfill_sql.contains("UPDATE global_models AS gm"),
"global model usage_count backfill should refresh the read model"
);
assert!(
backfill_sql.contains("FROM usage_billing_facts AS usage"),
"global model usage_count backfill should rebuild from canonical usage facts"
);
assert!(
delta_sql.contains("UPDATE global_models"),
"usage writes should maintain the global model usage_count read model"
);
assert!(
delta_sql.contains("WHERE name = $1"),
"global model usage_count delta should target models by canonical model name"
);
}
#[tokio::test] #[tokio::test]
async fn repository_constructs_from_lazy_pool() { async fn repository_constructs_from_lazy_pool() {
let factory = PostgresPoolFactory::new(PostgresPoolConfig { let factory = PostgresPoolFactory::new(PostgresPoolConfig {

View File

@@ -902,6 +902,32 @@ fn usage_is_success(item: &StoredRequestUsageAudit) -> bool {
) && item.status_code.is_none_or(|code| code < 400) ) && item.status_code.is_none_or(|code| code < 400)
} }
fn usage_output_tps_uses_generation_time(item: &StoredRequestUsageAudit) -> bool {
item.request_metadata
.as_ref()
.and_then(Value::as_object)
.and_then(|metadata| metadata.get("upstream_is_stream"))
.and_then(Value::as_bool)
.unwrap_or(item.is_stream)
}
fn usage_output_tps_duration_ms(item: &StoredRequestUsageAudit) -> Option<u64> {
let response_time_ms = item.response_time_ms?;
if response_time_ms == 0 {
return None;
}
if !usage_output_tps_uses_generation_time(item) {
return Some(response_time_ms);
}
let first_byte_time_ms = item.first_byte_time_ms?;
if first_byte_time_ms >= response_time_ms {
return None;
}
Some(response_time_ms - first_byte_time_ms)
}
fn usage_provider_display_name(item: &StoredRequestUsageAudit) -> Option<String> { fn usage_provider_display_name(item: &StoredRequestUsageAudit) -> Option<String> {
let provider_name = item.provider_name.trim(); let provider_name = item.provider_name.trim();
if provider_name.is_empty() || matches!(provider_name, "unknown" | "pending") { if provider_name.is_empty() || matches!(provider_name, "unknown" | "pending") {
@@ -1720,13 +1746,15 @@ impl UsageReadRepository for InMemoryUsageReadRepository {
self.response_time_sample_count = self.response_time_sample_count =
self.response_time_sample_count.saturating_add(1); self.response_time_sample_count.saturating_add(1);
self.response_times.push(response_time_ms); self.response_times.push(response_time_ms);
if response_time_ms > 0 && item.output_tokens > 0 { if let Some(output_tps_duration_ms) = usage_output_tps_duration_ms(item) {
self.tps_output_tokens = if item.output_tokens > 0 {
self.tps_output_tokens.saturating_add(item.output_tokens); self.tps_output_tokens =
self.tps_response_time_ms_sum = self self.tps_output_tokens.saturating_add(item.output_tokens);
.tps_response_time_ms_sum self.tps_response_time_ms_sum = self
.saturating_add(response_time_ms); .tps_response_time_ms_sum
self.tps_sample_count = self.tps_sample_count.saturating_add(1); .saturating_add(output_tps_duration_ms);
self.tps_sample_count = self.tps_sample_count.saturating_add(1);
}
} }
} }
if let Some(first_byte_time_ms) = item.first_byte_time_ms { if let Some(first_byte_time_ms) = item.first_byte_time_ms {
@@ -4822,6 +4850,7 @@ mod tests {
second.output_tokens = 40; second.output_tokens = 40;
second.response_time_ms = Some(1000); second.response_time_ms = Some(1000);
second.first_byte_time_ms = Some(200); second.first_byte_time_ms = Some(200);
second.request_metadata = Some(json!({ "upstream_is_stream": true }));
let mut failed = sample_usage("req-provider-perf-failed", 1_711_000_400); let mut failed = sample_usage("req-provider-perf-failed", 1_711_000_400);
failed.output_tokens = 999; failed.output_tokens = 999;
@@ -4852,7 +4881,7 @@ mod tests {
assert_eq!(summary.summary.request_count, 4); assert_eq!(summary.summary.request_count, 4);
assert_eq!(summary.summary.success_count, 3); assert_eq!(summary.summary.success_count, 3);
assert!((summary.summary.avg_output_tps.expect("summary tps") - 18.571_428).abs() < 0.001); assert!((summary.summary.avg_output_tps.expect("summary tps") - 19.117_647).abs() < 0.001);
assert_eq!(summary.summary.avg_first_byte_time_ms, Some(150.0)); assert_eq!(summary.summary.avg_first_byte_time_ms, Some(150.0));
assert!( assert!(
(summary (summary
@@ -4870,7 +4899,7 @@ mod tests {
assert_eq!(provider.request_count, 3); assert_eq!(provider.request_count, 3);
assert_eq!(provider.success_count, 2); assert_eq!(provider.success_count, 2);
assert_eq!(provider.output_tokens, 1099); assert_eq!(provider.output_tokens, 1099);
assert_eq!(provider.avg_output_tps, Some(25.0)); assert!((provider.avg_output_tps.expect("provider tps") - 26.315_789).abs() < 0.001);
assert_eq!(provider.avg_first_byte_time_ms, Some(150.0)); assert_eq!(provider.avg_first_byte_time_ms, Some(150.0));
assert_eq!(provider.avg_response_time_ms, Some(2000.0)); assert_eq!(provider.avg_response_time_ms, Some(2000.0));
assert_eq!(provider.p90_response_time_ms, None); assert_eq!(provider.p90_response_time_ms, None);
@@ -4880,6 +4909,8 @@ mod tests {
assert_eq!(summary.timeline.len(), 1); assert_eq!(summary.timeline.len(), 1);
assert_eq!(summary.timeline[0].date, "2024-03-21T05:00:00+00:00"); assert_eq!(summary.timeline[0].date, "2024-03-21T05:00:00+00:00");
assert_eq!(summary.timeline[0].provider_id, "provider-1"); assert_eq!(summary.timeline[0].provider_id, "provider-1");
assert_eq!(summary.timeline[0].avg_output_tps, Some(25.0)); assert!(
(summary.timeline[0].avg_output_tps.expect("timeline tps") - 26.315_789).abs() < 0.001
);
} }
} }

View File

@@ -444,6 +444,41 @@ impl ApiKeyUsageDelta {
} }
} }
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(crate) struct ModelUsageContribution {
pub model: String,
pub request_count: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(crate) struct ModelUsageDelta {
pub request_count: i64,
}
impl ModelUsageDelta {
pub(crate) fn between(before: &ModelUsageContribution, after: &ModelUsageContribution) -> Self {
Self {
request_count: after.request_count - before.request_count,
}
}
pub(crate) fn addition(after: &ModelUsageContribution) -> Self {
Self {
request_count: after.request_count,
}
}
pub(crate) fn removal(before: &ModelUsageContribution) -> Self {
Self {
request_count: -before.request_count,
}
}
pub(crate) fn is_noop(&self) -> bool {
self.request_count == 0
}
}
#[derive(Debug, Clone, PartialEq, Default)] #[derive(Debug, Clone, PartialEq, Default)]
pub(crate) struct ProviderApiKeyUsageContribution { pub(crate) struct ProviderApiKeyUsageContribution {
pub key_id: String, pub key_id: String,
@@ -620,6 +655,23 @@ pub(crate) fn provider_api_key_usage_contribution(
}) })
} }
pub(crate) fn model_usage_contribution(
usage: &StoredRequestUsageAudit,
) -> Option<ModelUsageContribution> {
if matches!(usage.status.as_str(), "pending" | "streaming") {
return None;
}
let model = usage.model.trim();
if model.is_empty() {
return None;
}
Some(ModelUsageContribution {
model: model.to_string(),
request_count: 1,
})
}
pub(crate) fn api_key_usage_contribution( pub(crate) fn api_key_usage_contribution(
usage: &StoredRequestUsageAudit, usage: &StoredRequestUsageAudit,
) -> Option<ApiKeyUsageContribution> { ) -> Option<ApiKeyUsageContribution> {
@@ -647,9 +699,10 @@ pub(crate) fn api_key_usage_contribution(
mod tests { mod tests {
use super::{ use super::{
api_key_usage_contribution, incoming_usage_can_recover_terminal_failure, api_key_usage_contribution, incoming_usage_can_recover_terminal_failure,
provider_api_key_usage_contribution, provider_api_key_usage_is_error, model_usage_contribution, provider_api_key_usage_contribution,
provider_api_key_usage_is_success, strip_deprecated_usage_display_fields, provider_api_key_usage_is_error, provider_api_key_usage_is_success,
usage_can_recover_terminal_failure, StoredRequestUsageAudit, UpsertUsageRecord, strip_deprecated_usage_display_fields, usage_can_recover_terminal_failure, ModelUsageDelta,
StoredRequestUsageAudit, UpsertUsageRecord,
}; };
#[test] #[test]
@@ -925,4 +978,75 @@ mod tests {
assert_eq!(contribution.total_cost_usd, 0.25); assert_eq!(contribution.total_cost_usd, 0.25);
assert_eq!(contribution.last_used_at_unix_secs, Some(123)); assert_eq!(contribution.last_used_at_unix_secs, Some(123));
} }
#[test]
fn model_usage_contribution_tracks_terminal_requests_only() {
let completed = StoredRequestUsageAudit::new(
"usage-1".to_string(),
"request-1".to_string(),
Some("user-1".to_string()),
Some("api-key-1".to_string()),
None,
None,
"OpenAI".to_string(),
" gpt-5.5 ".to_string(),
None,
Some("provider-1".to_string()),
None,
None,
None,
None,
None,
None,
None,
None,
None,
false,
false,
12,
8,
20,
0.25,
0.25,
Some(200),
None,
None,
Some(120),
None,
"completed".to_string(),
"settled".to_string(),
123,
124,
Some(125),
)
.expect("usage should build");
let contribution =
model_usage_contribution(&completed).expect("completed usage should count");
assert_eq!(contribution.model, "gpt-5.5");
assert_eq!(contribution.request_count, 1);
let mut streaming = completed.clone();
streaming.status = "streaming".to_string();
assert!(model_usage_contribution(&streaming).is_none());
let mut pending = completed;
pending.status = "pending".to_string();
assert!(model_usage_contribution(&pending).is_none());
}
#[test]
fn model_usage_delta_handles_model_changes() {
let before = super::ModelUsageContribution {
model: "gpt-5.4".to_string(),
request_count: 1,
};
let after = super::ModelUsageContribution {
model: "gpt-5.5".to_string(),
request_count: 1,
};
assert_eq!(ModelUsageDelta::removal(&before).request_count, -1);
assert_eq!(ModelUsageDelta::addition(&after).request_count, 1);
assert!(ModelUsageDelta::between(&before, &before).is_noop());
}
} }

View File

@@ -36,7 +36,8 @@ use uuid::Uuid;
use super::{ use super::{
api_key_usage_contribution, incoming_usage_can_recover_terminal_failure, api_key_usage_contribution, incoming_usage_can_recover_terminal_failure,
provider_api_key_usage_contribution, strip_deprecated_usage_display_fields, ApiKeyUsageDelta, model_usage_contribution, provider_api_key_usage_contribution,
strip_deprecated_usage_display_fields, ApiKeyUsageDelta, ModelUsageDelta,
PendingUsageCleanupSummary, ProviderApiKeyUsageDelta, StoredProviderApiKeyUsageSummary, PendingUsageCleanupSummary, ProviderApiKeyUsageDelta, StoredProviderApiKeyUsageSummary,
StoredProviderUsageSummary, StoredRequestUsageAudit, StoredUsageDailySummary, StoredProviderUsageSummary, StoredRequestUsageAudit, StoredUsageDailySummary,
UpsertUsageRecord, UsageAuditListQuery, UsageDailyHeatmapQuery, UsageReadRepository, UpsertUsageRecord, UsageAuditListQuery, UsageDailyHeatmapQuery, UsageReadRepository,
@@ -885,6 +886,67 @@ fn decode_usage_audit_summary_row(row: &PgRow) -> Result<StoredUsageAuditSummary
}) })
} }
fn decode_usage_audit_aggregation_row(
row: &PgRow,
) -> Result<StoredUsageAuditAggregation, DataLayerError> {
Ok(StoredUsageAuditAggregation {
group_key: row.try_get::<String, _>("group_key").map_postgres_err()?,
display_name: row
.try_get::<Option<String>, _>("display_name")
.map_postgres_err()?,
secondary_name: row
.try_get::<Option<String>, _>("secondary_name")
.map_postgres_err()?,
request_count: row
.try_get::<i64, _>("request_count")
.map_postgres_err()?
.max(0) as u64,
total_tokens: row
.try_get::<i64, _>("total_tokens")
.map_postgres_err()?
.max(0) as u64,
output_tokens: row
.try_get::<i64, _>("output_tokens")
.map_postgres_err()?
.max(0) as u64,
effective_input_tokens: row
.try_get::<i64, _>("effective_input_tokens")
.map_postgres_err()?
.max(0) as u64,
total_input_context: row
.try_get::<i64, _>("total_input_context")
.map_postgres_err()?
.max(0) as u64,
cache_creation_tokens: row
.try_get::<i64, _>("cache_creation_tokens")
.map_postgres_err()?
.max(0) as u64,
cache_creation_ephemeral_5m_tokens: row
.try_get::<i64, _>("cache_creation_ephemeral_5m_tokens")
.map_postgres_err()?
.max(0) as u64,
cache_creation_ephemeral_1h_tokens: row
.try_get::<i64, _>("cache_creation_ephemeral_1h_tokens")
.map_postgres_err()?
.max(0) as u64,
cache_read_tokens: row
.try_get::<i64, _>("cache_read_tokens")
.map_postgres_err()?
.max(0) as u64,
total_cost_usd: row.try_get::<f64, _>("total_cost_usd").map_postgres_err()?,
actual_total_cost_usd: row
.try_get::<f64, _>("actual_total_cost_usd")
.map_postgres_err()?,
avg_response_time_ms: row
.try_get::<Option<f64>, _>("avg_response_time_ms")
.map_postgres_err()?,
success_count: row
.try_get::<Option<i64>, _>("success_count")
.map_postgres_err()?
.map(|value| value.max(0) as u64),
})
}
fn decode_usage_error_distribution_row( fn decode_usage_error_distribution_row(
row: &PgRow, row: &PgRow,
) -> Result<StoredUsageErrorDistributionRow, DataLayerError> { ) -> Result<StoredUsageErrorDistributionRow, DataLayerError> {
@@ -1213,6 +1275,9 @@ const SUMMARIZE_USAGE_BY_PROVIDER_API_KEY_IDS_SQL: &str =
const APPLY_API_KEY_USAGE_DELTA_SQL: &str = const APPLY_API_KEY_USAGE_DELTA_SQL: &str =
include_str!("queries/apply_api_key_usage_delta_sql.sql"); include_str!("queries/apply_api_key_usage_delta_sql.sql");
const APPLY_GLOBAL_MODEL_USAGE_DELTA_SQL: &str =
include_str!("queries/apply_global_model_usage_delta_sql.sql");
const RESET_API_KEY_USAGE_STATS_SQL: &str = const RESET_API_KEY_USAGE_STATS_SQL: &str =
include_str!("queries/reset_api_key_usage_stats_sql.sql"); include_str!("queries/reset_api_key_usage_stats_sql.sql");
@@ -4789,6 +4854,17 @@ WITH filtered_usage AS (
GREATEST(COALESCE("usage".first_byte_time_ms, 0), 0) AS first_byte_time_ms, GREATEST(COALESCE("usage".first_byte_time_ms, 0), 0) AS first_byte_time_ms,
"usage".response_time_ms IS NOT NULL AS has_response_time, "usage".response_time_ms IS NOT NULL AS has_response_time,
"usage".first_byte_time_ms IS NOT NULL AS has_first_byte_time, "usage".first_byte_time_ms IS NOT NULL AS has_first_byte_time,
CASE
WHEN COALESCE("usage".upstream_is_stream, "usage".is_stream, false)
THEN CASE
WHEN "usage".response_time_ms IS NOT NULL
AND "usage".first_byte_time_ms IS NOT NULL
AND GREATEST(COALESCE("usage".response_time_ms, 0), 0) > GREATEST(COALESCE("usage".first_byte_time_ms, 0), 0)
THEN GREATEST(COALESCE("usage".response_time_ms, 0), 0) - GREATEST(COALESCE("usage".first_byte_time_ms, 0), 0)
ELSE 0
END
ELSE GREATEST(COALESCE("usage".response_time_ms, 0), 0)
END AS output_tps_duration_ms,
CASE CASE
WHEN lower(COALESCE("usage".status, '')) IN ('completed', 'success', 'ok', 'billed', 'settled') WHEN lower(COALESCE("usage".status, '')) IN ('completed', 'success', 'ok', 'billed', 'settled')
AND ("usage".status_code IS NULL OR "usage".status_code < 400) AND ("usage".status_code IS NULL OR "usage".status_code < 400)
@@ -4862,6 +4938,17 @@ WITH filtered_usage AS (
GREATEST(COALESCE("usage".first_byte_time_ms, 0), 0) AS first_byte_time_ms, GREATEST(COALESCE("usage".first_byte_time_ms, 0), 0) AS first_byte_time_ms,
"usage".response_time_ms IS NOT NULL AS has_response_time, "usage".response_time_ms IS NOT NULL AS has_response_time,
"usage".first_byte_time_ms IS NOT NULL AS has_first_byte_time, "usage".first_byte_time_ms IS NOT NULL AS has_first_byte_time,
CASE
WHEN COALESCE("usage".upstream_is_stream, "usage".is_stream, false)
THEN CASE
WHEN "usage".response_time_ms IS NOT NULL
AND "usage".first_byte_time_ms IS NOT NULL
AND GREATEST(COALESCE("usage".response_time_ms, 0), 0) > GREATEST(COALESCE("usage".first_byte_time_ms, 0), 0)
THEN GREATEST(COALESCE("usage".response_time_ms, 0), 0) - GREATEST(COALESCE("usage".first_byte_time_ms, 0), 0)
ELSE 0
END
ELSE GREATEST(COALESCE("usage".response_time_ms, 0), 0)
END AS output_tps_duration_ms,
CASE CASE
WHEN lower(COALESCE("usage".status, '')) IN ('completed', 'success', 'ok', 'billed', 'settled') WHEN lower(COALESCE("usage".status, '')) IN ('completed', 'success', 'ok', 'billed', 'settled')
AND ("usage".status_code IS NULL OR "usage".status_code < 400) AND ("usage".status_code IS NULL OR "usage".status_code < 400)
@@ -4977,6 +5064,17 @@ ORDER BY request_count DESC, provider_id ASC
GREATEST(COALESCE("usage".first_byte_time_ms, 0), 0) AS first_byte_time_ms, GREATEST(COALESCE("usage".first_byte_time_ms, 0), 0) AS first_byte_time_ms,
"usage".response_time_ms IS NOT NULL AS has_response_time, "usage".response_time_ms IS NOT NULL AS has_response_time,
"usage".first_byte_time_ms IS NOT NULL AS has_first_byte_time, "usage".first_byte_time_ms IS NOT NULL AS has_first_byte_time,
CASE
WHEN COALESCE("usage".upstream_is_stream, "usage".is_stream, false)
THEN CASE
WHEN "usage".response_time_ms IS NOT NULL
AND "usage".first_byte_time_ms IS NOT NULL
AND GREATEST(COALESCE("usage".response_time_ms, 0), 0) > GREATEST(COALESCE("usage".first_byte_time_ms, 0), 0)
THEN GREATEST(COALESCE("usage".response_time_ms, 0), 0) - GREATEST(COALESCE("usage".first_byte_time_ms, 0), 0)
ELSE 0
END
ELSE GREATEST(COALESCE("usage".response_time_ms, 0), 0)
END AS output_tps_duration_ms,
CASE CASE
WHEN lower(COALESCE("usage".status, '')) IN ('completed', 'success', 'ok', 'billed', 'settled') WHEN lower(COALESCE("usage".status, '')) IN ('completed', 'success', 'ok', 'billed', 'settled')
AND ("usage".status_code IS NULL OR "usage".status_code < 400) AND ("usage".status_code IS NULL OR "usage".status_code < 400)
@@ -6216,6 +6314,11 @@ WHERE date >= $1
GROUP BY {group_column} GROUP BY {group_column}
ORDER BY request_count DESC, group_key ASC ORDER BY request_count DESC, group_key ASC
"#, "#,
group_column = group_column,
display_name_expr = display_name_expr,
avg_response_time_expr = avg_response_time_expr,
success_count_expr = success_count_expr,
table_name = table_name,
); );
let mut rows = sqlx::query(&sql) let mut rows = sqlx::query(&sql)
@@ -7326,6 +7429,40 @@ ORDER BY "usage".user_id ASC
} }
} }
let before_model_contribution =
previous_usage.as_ref().and_then(model_usage_contribution);
let after_model_contribution = model_usage_contribution(&stored);
match (
before_model_contribution.as_ref(),
after_model_contribution.as_ref(),
) {
(Some(before), Some(after)) if before.model == after.model => {
let delta = ModelUsageDelta::between(before, after);
apply_global_model_usage_delta_in_tx(tx, before.model.as_str(), &delta)
.await?;
}
_ => {
if let Some(before) = before_model_contribution.as_ref() {
let delta = ModelUsageDelta::removal(before);
apply_global_model_usage_delta_in_tx(
tx,
before.model.as_str(),
&delta,
)
.await?;
}
if let Some(after) = after_model_contribution.as_ref() {
let delta = ModelUsageDelta::addition(after);
apply_global_model_usage_delta_in_tx(
tx,
after.model.as_str(),
&delta,
)
.await?;
}
}
}
let before_provider_contribution = previous_usage let before_provider_contribution = previous_usage
.as_ref() .as_ref()
.and_then(provider_api_key_usage_contribution); .and_then(provider_api_key_usage_contribution);
@@ -7884,6 +8021,32 @@ async fn apply_api_key_usage_delta_in_tx(
Ok(()) Ok(())
} }
async fn apply_global_model_usage_delta_in_tx(
tx: &mut sqlx::Transaction<'_, Postgres>,
model: &str,
delta: &ModelUsageDelta,
) -> Result<(), DataLayerError> {
if model.trim().is_empty() {
return Ok(());
}
if delta.is_noop() {
return Ok(());
}
sqlx::query(APPLY_GLOBAL_MODEL_USAGE_DELTA_SQL)
.bind(model)
.bind(i32::try_from(delta.request_count).map_err(|_| {
DataLayerError::UnexpectedValue(format!(
"global_models.usage_count delta exceeds i32: {}",
delta.request_count
))
})?)
.execute(&mut **tx)
.await
.map_postgres_err()?;
Ok(())
}
async fn apply_provider_api_key_usage_delta_in_tx( async fn apply_provider_api_key_usage_delta_in_tx(
tx: &mut sqlx::Transaction<'_, Postgres>, tx: &mut sqlx::Transaction<'_, Postgres>,
key_id: &str, key_id: &str,

View File

@@ -0,0 +1,5 @@
UPDATE global_models
SET
usage_count = GREATEST(COALESCE(usage_count, 0) + $2, 0),
updated_at = NOW()
WHERE name = $1

View File

@@ -20,6 +20,7 @@ INSERT INTO "usage" (
provider_endpoint_kind, provider_endpoint_kind,
has_format_conversion, has_format_conversion,
is_stream, is_stream,
upstream_is_stream,
input_tokens, input_tokens,
output_tokens, output_tokens,
total_tokens, total_tokens,
@@ -76,6 +77,14 @@ INSERT INTO "usage" (
$19, $19,
COALESCE($20, FALSE), COALESCE($20, FALSE),
COALESCE($21, FALSE), COALESCE($21, FALSE),
COALESCE(
CASE
WHEN ($53::json->>'upstream_is_stream') IN ('true', 'false')
THEN ($53::json->>'upstream_is_stream')::boolean
ELSE NULL
END,
COALESCE($21, FALSE)
),
COALESCE($22, 0), COALESCE($22, 0),
COALESCE($23, 0), COALESCE($23, 0),
COALESCE($24, COALESCE($22, 0) + COALESCE($23, 0)), COALESCE($24, COALESCE($22, 0) + COALESCE($23, 0)),
@@ -135,6 +144,7 @@ DO UPDATE SET
provider_endpoint_kind = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.provider_endpoint_kind, "usage".provider_endpoint_kind) ELSE "usage".provider_endpoint_kind END, provider_endpoint_kind = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.provider_endpoint_kind, "usage".provider_endpoint_kind) ELSE "usage".provider_endpoint_kind END,
has_format_conversion = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.has_format_conversion, "usage".has_format_conversion) ELSE "usage".has_format_conversion END, has_format_conversion = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.has_format_conversion, "usage".has_format_conversion) ELSE "usage".has_format_conversion END,
is_stream = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.is_stream, "usage".is_stream) ELSE "usage".is_stream END, is_stream = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.is_stream, "usage".is_stream) ELSE "usage".is_stream END,
upstream_is_stream = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.upstream_is_stream, "usage".upstream_is_stream, "usage".is_stream, false) ELSE "usage".upstream_is_stream END,
input_tokens = CASE WHEN "usage".billing_status = 'pending' AND EXCLUDED.status IN ('completed', 'failed', 'cancelled') THEN GREATEST("usage".input_tokens, EXCLUDED.input_tokens) ELSE "usage".input_tokens END, input_tokens = CASE WHEN "usage".billing_status = 'pending' AND EXCLUDED.status IN ('completed', 'failed', 'cancelled') THEN GREATEST("usage".input_tokens, EXCLUDED.input_tokens) ELSE "usage".input_tokens END,
output_tokens = CASE WHEN "usage".billing_status = 'pending' AND EXCLUDED.status IN ('completed', 'failed', 'cancelled') THEN GREATEST("usage".output_tokens, EXCLUDED.output_tokens) ELSE "usage".output_tokens END, output_tokens = CASE WHEN "usage".billing_status = 'pending' AND EXCLUDED.status IN ('completed', 'failed', 'cancelled') THEN GREATEST("usage".output_tokens, EXCLUDED.output_tokens) ELSE "usage".output_tokens END,
total_tokens = CASE WHEN "usage".billing_status = 'pending' AND EXCLUDED.status IN ('completed', 'failed', 'cancelled') THEN GREATEST("usage".total_tokens, EXCLUDED.total_tokens) ELSE "usage".total_tokens END, total_tokens = CASE WHEN "usage".billing_status = 'pending' AND EXCLUDED.status IN ('completed', 'failed', 'cancelled') THEN GREATEST("usage".total_tokens, EXCLUDED.total_tokens) ELSE "usage".total_tokens END,

View File

@@ -443,6 +443,33 @@ fn usage_sql_raw_aggregates_use_canonical_billing_facts() {
.contains("FROM usage_billing_facts AS \"usage\"")); .contains("FROM usage_billing_facts AS \"usage\""));
} }
#[test]
fn usage_sql_provider_performance_reads_upstream_stream_from_billing_facts() {
let source = include_str!("mod.rs");
assert!(source.contains("\"usage\".upstream_is_stream"));
assert!(
!source.contains("usage_base.request_metadata->>'upstream_is_stream'"),
"provider performance queries should not rejoin public.usage to resolve upstream stream mode"
);
assert!(
!source.contains("LEFT JOIN public.usage AS usage_base"),
"provider performance queries should stay on usage_billing_facts to avoid an extra usage scan"
);
}
#[test]
fn usage_billing_facts_projects_upstream_stream_mode() {
let migration = include_str!(
"../../../../migrations/postgres/20260505130000_project_upstream_stream_in_usage_billing_facts.sql"
);
assert!(migration.contains("AS upstream_is_stream"));
assert!(migration.contains("COALESCE(usage_rows.upstream_is_stream"));
assert!(migration.contains("COALESCE(usage_rows.is_stream, FALSE)"));
assert!(migration.contains("ADD COLUMN IF NOT EXISTS upstream_is_stream boolean"));
assert!(migration.contains("request_metadata->>'upstream_is_stream'"));
}
#[test] #[test]
fn usage_sql_reads_http_audits_for_single_record_fetches() { fn usage_sql_reads_http_audits_for_single_record_fetches() {
assert!(super::FIND_BY_REQUEST_ID_SQL.contains("LEFT JOIN usage_http_audits")); assert!(super::FIND_BY_REQUEST_ID_SQL.contains("LEFT JOIN usage_http_audits"));
@@ -577,6 +604,14 @@ fn usage_sql_insert_values_aligns_request_metadata_and_timestamps() {
assert!(super::UPSERT_SQL.contains("TO_TIMESTAMP($55::double precision)")); assert!(super::UPSERT_SQL.contains("TO_TIMESTAMP($55::double precision)"));
} }
#[test]
fn usage_sql_upsert_materializes_upstream_stream_mode() {
assert!(super::UPSERT_SQL.contains("upstream_is_stream,"));
assert!(super::UPSERT_SQL.contains("$53::json->>'upstream_is_stream'"));
assert!(super::UPSERT_SQL.contains("COALESCE($21, FALSE)"));
assert!(super::UPSERT_SQL.contains("upstream_is_stream = CASE"));
}
#[test] #[test]
fn usage_sql_upsert_returning_includes_routing_placeholders() { fn usage_sql_upsert_returning_includes_routing_placeholders() {
assert!(super::UPSERT_SQL.contains("NULL::varchar AS http_request_body_state")); assert!(super::UPSERT_SQL.contains("NULL::varchar AS http_request_body_state"));

View File

@@ -207,6 +207,7 @@ export interface RequestDetail {
errors?: RequestErrorDomains | null errors?: RequestErrorDomains | null
error_flow?: RequestErrorFlow | null error_flow?: RequestErrorFlow | null
response_time_ms: number response_time_ms: number
first_byte_time_ms?: number | null
created_at: string created_at: string
request_headers?: Record<string, unknown> request_headers?: Record<string, unknown>
request_body?: Record<string, unknown> request_body?: Record<string, unknown>

View File

@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest'
import {
calculateOutputRate,
formatDurationMs,
formatOutputRate,
formatOutputRateValue,
getDisplayOutputRate,
getGenerationTimeMs,
getOutputRateDurationMs,
shouldHideOutputRate,
} from '../performance'
describe('usage performance metrics', () => {
it('calculates generation time after first byte', () => {
expect(getGenerationTimeMs({
response_time_ms: 1000,
first_byte_time_ms: 250,
})).toBe(750)
})
it('calculates stream output tokens per generated second after first byte', () => {
expect(calculateOutputRate({
output_tokens: 50,
response_time_ms: 1000,
first_byte_time_ms: 500,
upstream_is_stream: true,
})).toBe(100)
})
it('calculates standard output tokens per total response second', () => {
const timing = {
output_tokens: 50,
response_time_ms: 1000,
first_byte_time_ms: 500,
upstream_is_stream: false,
}
expect(getOutputRateDurationMs(timing)).toBe(1000)
expect(calculateOutputRate(timing)).toBe(50)
expect(getDisplayOutputRate(timing)).toBe(50)
})
it('does not calculate output rate without output tokens', () => {
expect(calculateOutputRate({
output_tokens: 0,
response_time_ms: 1000,
first_byte_time_ms: 500,
})).toBeNull()
})
it('does not calculate output rate when first byte is not before completion', () => {
expect(calculateOutputRate({
output_tokens: 50,
response_time_ms: 1000,
first_byte_time_ms: 1000,
upstream_is_stream: true,
})).toBeNull()
})
it('hides implausible rates from very short generation tails', () => {
const timing = {
output_tokens: 300,
response_time_ms: 1000,
first_byte_time_ms: 950,
upstream_is_stream: true,
}
const rate = calculateOutputRate(timing)
expect(rate).toBe(6000)
expect(shouldHideOutputRate(rate, timing)).toBe(true)
expect(getDisplayOutputRate(timing)).toBeNull()
})
it('keeps normal output rates visible', () => {
const timing = {
output_tokens: 50,
response_time_ms: 1000,
first_byte_time_ms: 500,
upstream_is_stream: true,
}
expect(getDisplayOutputRate(timing)).toBe(100)
})
it('calculates standard output rate without first byte timing', () => {
expect(getDisplayOutputRate({
output_tokens: 50,
response_time_ms: 1000,
upstream_is_stream: false,
})).toBe(50)
})
it('formats durations and output rates for compact UI display', () => {
expect(formatDurationMs(456)).toBe('456ms')
expect(formatDurationMs(1234)).toBe('1.23s')
expect(formatOutputRateValue(87.65)).toBe('87.7')
expect(formatOutputRate(87.65)).toBe('87.7 tps')
expect(formatOutputRate(1200)).toBe('1,200 tps')
})
})

View File

@@ -179,6 +179,47 @@
<span class="text-lg font-bold">{{ detail.response_time_ms ? formatResponseTime(detail.response_time_ms).value : 'N/A' }}</span> <span class="text-lg font-bold">{{ detail.response_time_ms ? formatResponseTime(detail.response_time_ms).value : 'N/A' }}</span>
<span class="text-sm text-muted-foreground ml-1">{{ detail.response_time_ms ? formatResponseTime(detail.response_time_ms).unit : '' }}</span> <span class="text-sm text-muted-foreground ml-1">{{ detail.response_time_ms ? formatResponseTime(detail.response_time_ms).unit : '' }}</span>
</div> </div>
<template v-if="detailOutputRate != null">
<Separator
orientation="vertical"
class="h-6 mx-6"
/>
<div class="flex items-center">
<span class="text-xs text-muted-foreground w-[56px]">输出速度</span>
<span class="text-lg font-bold text-primary">{{ formatOutputRateValue(detailOutputRate) }}</span>
<span class="text-sm text-muted-foreground ml-1">tps</span>
</div>
</template>
</div>
<div
v-if="hasDetailPerformanceBreakdown"
class="grid grid-cols-1 sm:grid-cols-3 gap-2 mb-4 text-xs"
>
<div class="rounded-md border border-border/50 bg-muted/20 px-3 py-2">
<div class="text-muted-foreground mb-1">
首字时间
</div>
<div class="font-mono text-foreground">
{{ formatDurationMs(detail.first_byte_time_ms) }}
</div>
</div>
<div class="rounded-md border border-border/50 bg-muted/20 px-3 py-2">
<div class="text-muted-foreground mb-1">
生成耗时
</div>
<div class="font-mono text-foreground">
{{ formatDurationMs(detailGenerationTimeMs) }}
</div>
</div>
<div class="rounded-md border border-border/50 bg-muted/20 px-3 py-2">
<div class="text-muted-foreground mb-1">
输出 Tokens
</div>
<div class="font-mono text-foreground">
{{ formatNumber(detailOutputTokens) }}
</div>
</div>
</div> </div>
<!-- 分隔线 --> <!-- 分隔线 -->
@@ -714,6 +755,13 @@ 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 { getEffectiveInputTokens } from '../token-normalization'
import {
formatDurationMs,
formatOutputRate,
formatOutputRateValue,
getDisplayOutputRate,
getGenerationTimeMs,
} from '../performance'
import { import {
formatUsageStreamLabel, formatUsageStreamLabel,
isUsageUpstreamStream, isUsageUpstreamStream,
@@ -892,6 +940,37 @@ const displayInputTokens = computed(() => {
}) })
}) })
const detailOutputTokens = computed(() => {
if (!detail.value) return 0
return detail.value.tokens?.output ?? detail.value.output_tokens ?? 0
})
const detailGenerationTimeMs = computed(() => {
if (!detail.value) return null
return getGenerationTimeMs({
response_time_ms: detail.value.response_time_ms,
first_byte_time_ms: detail.value.first_byte_time_ms,
is_stream: detail.value.is_stream,
upstream_is_stream: detail.value.upstream_is_stream,
})
})
const detailOutputRate = computed(() => {
if (!detail.value) return null
return getDisplayOutputRate({
output_tokens: detailOutputTokens.value,
response_time_ms: detail.value.response_time_ms,
first_byte_time_ms: detail.value.first_byte_time_ms,
is_stream: detail.value.is_stream,
upstream_is_stream: detail.value.upstream_is_stream,
})
})
const hasDetailPerformanceBreakdown = computed(() => {
if (!detail.value) return false
return detail.value.first_byte_time_ms != null || detailGenerationTimeMs.value != null || detailOutputTokens.value > 0
})
// 监听标签页切换 // 监听标签页切换
watch(activeTab, (newTab) => { watch(activeTab, (newTab) => {
if (!['request-headers', 'response-headers'].includes(newTab) && viewMode.value === 'compare') { if (!['request-headers', 'response-headers'].includes(newTab) && viewMode.value === 'compare') {

View File

@@ -0,0 +1,85 @@
export interface UsagePerformanceTiming {
output_tokens?: number | null
response_time_ms?: number | null
first_byte_time_ms?: number | null
is_stream?: boolean | null
upstream_is_stream?: boolean | null
}
export function getGenerationTimeMs(timing: UsagePerformanceTiming): number | null {
const responseTimeMs = timing.response_time_ms
const firstByteTimeMs = timing.first_byte_time_ms
if (responseTimeMs == null || firstByteTimeMs == null) return null
if (!Number.isFinite(responseTimeMs) || !Number.isFinite(firstByteTimeMs)) return null
if (responseTimeMs <= 0 || firstByteTimeMs < 0 || firstByteTimeMs >= responseTimeMs) return null
return responseTimeMs - firstByteTimeMs
}
export function isOutputRateUsingGenerationTime(timing: UsagePerformanceTiming): boolean {
return timing.upstream_is_stream ?? timing.is_stream ?? false
}
export function getOutputRateDurationMs(timing: UsagePerformanceTiming): number | null {
if (isOutputRateUsingGenerationTime(timing)) {
return getGenerationTimeMs(timing)
}
const responseTimeMs = timing.response_time_ms
if (responseTimeMs == null || !Number.isFinite(responseTimeMs) || responseTimeMs <= 0) return null
return responseTimeMs
}
export function calculateOutputRate(timing: UsagePerformanceTiming): number | null {
const outputTokens = timing.output_tokens ?? 0
const outputRateDurationMs = getOutputRateDurationMs(timing)
if (!Number.isFinite(outputTokens) || outputTokens <= 0 || outputRateDurationMs == null) return null
const outputRateSeconds = outputRateDurationMs / 1000
if (outputRateSeconds <= 0) return null
return outputTokens / outputRateSeconds
}
export function shouldHideOutputRate(
outputRate: number | null,
timing: UsagePerformanceTiming,
): boolean {
if (!isOutputRateUsingGenerationTime(timing)) return false
const responseTimeMs = timing.response_time_ms
const generationTimeMs = getGenerationTimeMs(timing)
if (outputRate == null || responseTimeMs == null || generationTimeMs == null) return false
if (!Number.isFinite(outputRate) || !Number.isFinite(responseTimeMs) || responseTimeMs <= 0) return true
return generationTimeMs / responseTimeMs < 0.1 && outputRate > 5000
}
export function getDisplayOutputRate(timing: UsagePerformanceTiming): number | null {
const outputRate = calculateOutputRate(timing)
if (shouldHideOutputRate(outputRate, timing)) return null
return outputRate
}
export function formatDurationMs(ms: number | null | undefined, fractionDigits = 2): string {
if (ms == null || !Number.isFinite(ms)) return '-'
if (ms >= 1000) return `${(ms / 1000).toFixed(fractionDigits)}s`
return `${Math.round(ms)}ms`
}
export function formatOutputRateValue(outputRate: number | null | undefined): string {
if (outputRate == null || !Number.isFinite(outputRate)) return '-'
if (outputRate >= 1000) return Math.round(outputRate).toLocaleString()
if (outputRate >= 100) return `${Math.round(outputRate)}`
if (outputRate >= 10) return outputRate.toFixed(1)
return outputRate.toFixed(2)
}
export function formatOutputRate(outputRate: number | null | undefined): string {
const value = formatOutputRateValue(outputRate)
if (value === '-') return value
return `${value} tps`
}

View File

@@ -594,7 +594,7 @@
{{ formatProviderPerformanceMetric(provider.success_rate, '%') }} {{ formatProviderPerformanceMetric(provider.success_rate, '%') }}
</td> </td>
<td class="px-3 py-2 text-right"> <td class="px-3 py-2 text-right">
{{ formatProviderPerformanceMetric(provider.avg_output_tps, '/s') }} {{ formatProviderPerformanceMetric(provider.avg_output_tps, ' tps') }}
</td> </td>
<td class="px-3 py-2 text-right"> <td class="px-3 py-2 text-right">
{{ formatProviderPerformanceMetric(provider.avg_first_byte_time_ms, 'ms') }} {{ formatProviderPerformanceMetric(provider.avg_first_byte_time_ms, 'ms') }}
@@ -1093,7 +1093,7 @@ const providerPerformanceSummaryCards = computed(() => {
return [ return [
{ {
title: '输出 TPS', title: '输出 TPS',
value: formatProviderPerformanceMetric(summary?.avg_output_tps, '/s'), value: formatProviderPerformanceMetric(summary?.avg_output_tps, ' tps'),
hint: `请求 ${formatMetricNumber(summary?.request_count)}`, hint: `请求 ${formatMetricNumber(summary?.request_count)}`,
icon: Zap, icon: Zap,
iconClass: 'text-amber-500', iconClass: 'text-amber-500',
@@ -1142,7 +1142,7 @@ const providerTpsChartOptions = computed(() => ({
scales: { scales: {
y: { y: {
ticks: { ticks: {
callback: (value: string | number) => `${value}/s`, callback: (value: string | number) => `${value} tps`,
}, },
}, },
}, },