mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-12 14:10:19 +08:00
feat(gateway): add Codex Live and OpenAI Realtime
Implement preflighted Live/Realtime WebSocket transports, protocol-aware authentication, usage auditing, UI filtering, and legacy Codex permission migration.
This commit is contained in:
+190
@@ -0,0 +1,190 @@
|
||||
-- Preserve the explicit access granted by the original #741 implementation,
|
||||
-- which represented Codex Live as openai:responses. Runtime permission
|
||||
-- matching remains strict after this one-time data upgrade.
|
||||
|
||||
UPDATE users
|
||||
SET allowed_api_formats = json_insert(allowed_api_formats, '$[#]', 'codex:live')
|
||||
WHERE LOWER(TRIM(COALESCE(allowed_api_formats_mode, ''))) = 'specific'
|
||||
AND json_valid(allowed_api_formats)
|
||||
AND json_type(CASE WHEN json_valid(allowed_api_formats) THEN allowed_api_formats END) = 'array'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM json_each(
|
||||
CASE WHEN json_valid(users.allowed_api_formats) THEN users.allowed_api_formats ELSE '[]' END
|
||||
)
|
||||
WHERE value = 'openai:responses'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM json_each(
|
||||
CASE WHEN json_valid(users.allowed_api_formats) THEN users.allowed_api_formats ELSE '[]' END
|
||||
)
|
||||
WHERE value = 'codex:live'
|
||||
);
|
||||
|
||||
UPDATE user_groups
|
||||
SET allowed_api_formats = json_insert(allowed_api_formats, '$[#]', 'codex:live')
|
||||
WHERE LOWER(TRIM(COALESCE(allowed_api_formats_mode, ''))) = 'specific'
|
||||
AND json_valid(allowed_api_formats)
|
||||
AND json_type(CASE WHEN json_valid(allowed_api_formats) THEN allowed_api_formats END) = 'array'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM json_each(
|
||||
CASE WHEN json_valid(user_groups.allowed_api_formats) THEN user_groups.allowed_api_formats ELSE '[]' END
|
||||
)
|
||||
WHERE value = 'openai:responses'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM json_each(
|
||||
CASE WHEN json_valid(user_groups.allowed_api_formats) THEN user_groups.allowed_api_formats ELSE '[]' END
|
||||
)
|
||||
WHERE value = 'codex:live'
|
||||
);
|
||||
|
||||
UPDATE api_keys
|
||||
SET allowed_api_formats = json_insert(allowed_api_formats, '$[#]', 'codex:live')
|
||||
WHERE json_valid(allowed_api_formats)
|
||||
AND json_type(CASE WHEN json_valid(allowed_api_formats) THEN allowed_api_formats END) = 'array'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM json_each(
|
||||
CASE WHEN json_valid(api_keys.allowed_api_formats) THEN api_keys.allowed_api_formats ELSE '[]' END
|
||||
)
|
||||
WHERE value = 'openai:responses'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM json_each(
|
||||
CASE WHEN json_valid(api_keys.allowed_api_formats) THEN api_keys.allowed_api_formats ELSE '[]' END
|
||||
)
|
||||
WHERE value = 'codex:live'
|
||||
);
|
||||
|
||||
UPDATE provider_api_keys
|
||||
SET
|
||||
api_formats = json_insert(api_formats, '$[#]', 'codex:live'),
|
||||
updated_at = CAST(strftime('%s', 'now') AS INTEGER)
|
||||
WHERE provider_id IN (
|
||||
SELECT id FROM providers
|
||||
WHERE LOWER(TRIM(COALESCE(provider_type, ''))) = 'codex'
|
||||
)
|
||||
AND json_valid(api_formats)
|
||||
AND json_type(CASE WHEN json_valid(api_formats) THEN api_formats END) = 'array'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM json_each(
|
||||
CASE WHEN json_valid(provider_api_keys.api_formats) THEN provider_api_keys.api_formats ELSE '[]' END
|
||||
)
|
||||
WHERE value = 'openai:responses'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM json_each(
|
||||
CASE WHEN json_valid(provider_api_keys.api_formats) THEN provider_api_keys.api_formats ELSE '[]' END
|
||||
)
|
||||
WHERE value = 'codex:live'
|
||||
);
|
||||
|
||||
UPDATE provider_api_keys
|
||||
SET
|
||||
auth_type_by_format = json_set(
|
||||
auth_type_by_format,
|
||||
'$."codex:live"',
|
||||
json_extract(auth_type_by_format, '$."openai:responses"')
|
||||
),
|
||||
updated_at = CAST(strftime('%s', 'now') AS INTEGER)
|
||||
WHERE provider_id IN (
|
||||
SELECT id FROM providers
|
||||
WHERE LOWER(TRIM(COALESCE(provider_type, ''))) = 'codex'
|
||||
)
|
||||
AND json_valid(auth_type_by_format)
|
||||
AND json_type(CASE WHEN json_valid(auth_type_by_format) THEN auth_type_by_format END) = 'object'
|
||||
AND json_type(
|
||||
CASE WHEN json_valid(auth_type_by_format) THEN auth_type_by_format END,
|
||||
'$."openai:responses"'
|
||||
) IS NOT NULL
|
||||
AND json_type(
|
||||
CASE WHEN json_valid(auth_type_by_format) THEN auth_type_by_format END,
|
||||
'$."codex:live"'
|
||||
) IS NULL;
|
||||
|
||||
UPDATE provider_api_keys
|
||||
SET
|
||||
allow_auth_channel_mismatch_formats = json_insert(
|
||||
allow_auth_channel_mismatch_formats,
|
||||
'$[#]',
|
||||
'codex:live'
|
||||
),
|
||||
updated_at = CAST(strftime('%s', 'now') AS INTEGER)
|
||||
WHERE provider_id IN (
|
||||
SELECT id FROM providers
|
||||
WHERE LOWER(TRIM(COALESCE(provider_type, ''))) = 'codex'
|
||||
)
|
||||
AND json_valid(allow_auth_channel_mismatch_formats)
|
||||
AND json_type(
|
||||
CASE
|
||||
WHEN json_valid(allow_auth_channel_mismatch_formats)
|
||||
THEN allow_auth_channel_mismatch_formats
|
||||
END
|
||||
) = 'array'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM json_each(
|
||||
CASE
|
||||
WHEN json_valid(provider_api_keys.allow_auth_channel_mismatch_formats)
|
||||
THEN provider_api_keys.allow_auth_channel_mismatch_formats
|
||||
ELSE '[]'
|
||||
END
|
||||
)
|
||||
WHERE value = 'openai:responses'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM json_each(
|
||||
CASE
|
||||
WHEN json_valid(provider_api_keys.allow_auth_channel_mismatch_formats)
|
||||
THEN provider_api_keys.allow_auth_channel_mismatch_formats
|
||||
ELSE '[]'
|
||||
END
|
||||
)
|
||||
WHERE value = 'codex:live'
|
||||
);
|
||||
|
||||
UPDATE provider_api_keys
|
||||
SET
|
||||
rate_multipliers = json_set(
|
||||
rate_multipliers,
|
||||
'$."codex:live"',
|
||||
json_extract(rate_multipliers, '$."openai:responses"')
|
||||
),
|
||||
updated_at = CAST(strftime('%s', 'now') AS INTEGER)
|
||||
WHERE provider_id IN (
|
||||
SELECT id FROM providers
|
||||
WHERE LOWER(TRIM(COALESCE(provider_type, ''))) = 'codex'
|
||||
)
|
||||
AND json_valid(rate_multipliers)
|
||||
AND json_type(CASE WHEN json_valid(rate_multipliers) THEN rate_multipliers END) = 'object'
|
||||
AND json_type(
|
||||
CASE WHEN json_valid(rate_multipliers) THEN rate_multipliers END,
|
||||
'$."openai:responses"'
|
||||
) IS NOT NULL
|
||||
AND json_type(
|
||||
CASE WHEN json_valid(rate_multipliers) THEN rate_multipliers END,
|
||||
'$."codex:live"'
|
||||
) IS NULL;
|
||||
|
||||
UPDATE provider_api_keys
|
||||
SET
|
||||
global_priority_by_format = json_set(
|
||||
global_priority_by_format,
|
||||
'$."codex:live"',
|
||||
json_extract(global_priority_by_format, '$."openai:responses"')
|
||||
),
|
||||
updated_at = CAST(strftime('%s', 'now') AS INTEGER)
|
||||
WHERE provider_id IN (
|
||||
SELECT id FROM providers
|
||||
WHERE LOWER(TRIM(COALESCE(provider_type, ''))) = 'codex'
|
||||
)
|
||||
AND json_valid(global_priority_by_format)
|
||||
AND json_type(
|
||||
CASE WHEN json_valid(global_priority_by_format) THEN global_priority_by_format END
|
||||
) = 'object'
|
||||
AND json_type(
|
||||
CASE WHEN json_valid(global_priority_by_format) THEN global_priority_by_format END,
|
||||
'$."openai:responses"'
|
||||
) IS NOT NULL
|
||||
AND json_type(
|
||||
CASE WHEN json_valid(global_priority_by_format) THEN global_priority_by_format END,
|
||||
'$."codex:live"'
|
||||
) IS NULL;
|
||||
@@ -4,10 +4,11 @@ use async_trait::async_trait;
|
||||
use sqlx::{sqlite::SqliteRow, QueryBuilder, Row, Sqlite};
|
||||
|
||||
use aether_data_contracts::repository::candidate_selection::{
|
||||
MinimalCandidateSelectionReadRepository, StoredApiFormatCandidateRowsQuery,
|
||||
StoredMinimalCandidateSelectionRow, StoredPoolKeyCandidateOrder,
|
||||
StoredPoolKeyCandidateRowsByKeyIdsQuery, StoredPoolKeyCandidateRowsQuery,
|
||||
StoredProviderModelMapping, StoredRequestedModelCandidateRowsQuery,
|
||||
provider_model_mapping_api_format_covers, MinimalCandidateSelectionReadRepository,
|
||||
StoredApiFormatCandidateRowsQuery, StoredMinimalCandidateSelectionRow,
|
||||
StoredPoolKeyCandidateOrder, StoredPoolKeyCandidateRowsByKeyIdsQuery,
|
||||
StoredPoolKeyCandidateRowsQuery, StoredProviderModelMapping,
|
||||
StoredRequestedModelCandidateRowsQuery,
|
||||
};
|
||||
use aether_data_contracts::DataLayerError;
|
||||
|
||||
@@ -605,7 +606,7 @@ fn push_key_auth_channel_sql_filter(
|
||||
);
|
||||
builder.push_bind(api_format.clone());
|
||||
builder.push(
|
||||
r#" IN ('openai:responses', 'openai:responses:compact', 'openai:search', 'openai:image')
|
||||
r#" IN ('openai:responses', 'openai:responses:compact', 'openai:search', 'openai:image', 'codex:live')
|
||||
)
|
||||
OR (
|
||||
LOWER(TRIM(p.provider_type)) = 'chatgpt_web'
|
||||
@@ -945,9 +946,9 @@ fn mapping_scope_matches(
|
||||
api_format: &str,
|
||||
) -> bool {
|
||||
mapping.api_formats.as_ref().is_none_or(|formats| {
|
||||
formats
|
||||
.iter()
|
||||
.any(|value| api_format_scope_covers(value, api_format))
|
||||
formats.iter().any(|value| {
|
||||
provider_model_mapping_api_format_covers(&row.provider_type, value, api_format)
|
||||
})
|
||||
}) && mapping.endpoint_ids.as_ref().is_none_or(|endpoint_ids| {
|
||||
endpoint_ids
|
||||
.iter()
|
||||
@@ -968,6 +969,7 @@ fn key_auth_channel_matches(row: &CandidateSelectionRow, api_format: &str) -> bo
|
||||
| "openai:responses:compact"
|
||||
| "openai:search"
|
||||
| "openai:image"
|
||||
| "codex:live"
|
||||
)
|
||||
}
|
||||
"chatgpt_web" => {
|
||||
@@ -1326,10 +1328,6 @@ fn api_format_matches(left: &str, right: &str) -> bool {
|
||||
aether_ai_formats::api_format_alias_matches(left, right)
|
||||
}
|
||||
|
||||
fn api_format_scope_covers(allowed: &str, requested: &str) -> bool {
|
||||
aether_ai_formats::api_format_permission_covers(allowed, requested)
|
||||
}
|
||||
|
||||
fn sql_match_aliases(api_formats: &[String]) -> Vec<String> {
|
||||
api_formats
|
||||
.iter()
|
||||
@@ -1340,9 +1338,9 @@ fn sql_match_aliases(api_formats: &[String]) -> Vec<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
push_key_auth_channel_sql_filter, push_pool_key_order, vertex_key_auth_channel_matches,
|
||||
ExactPageAccumulator, SqliteMinimalCandidateSelectionReadRepository,
|
||||
REQUESTED_MODEL_RAW_SCAN_LIMIT,
|
||||
provider_model_mapping_api_format_covers, push_key_auth_channel_sql_filter,
|
||||
push_pool_key_order, vertex_key_auth_channel_matches, ExactPageAccumulator,
|
||||
SqliteMinimalCandidateSelectionReadRepository, REQUESTED_MODEL_RAW_SCAN_LIMIT,
|
||||
};
|
||||
use crate::run_migrations;
|
||||
use aether_data_contracts::repository::candidate_selection::{
|
||||
@@ -1380,6 +1378,47 @@ mod tests {
|
||||
assert!(vertex_clause.contains("gemini:embedding"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_auth_sql_allows_live_for_oauth_keys() {
|
||||
let mut builder = sqlx::QueryBuilder::<sqlx::Sqlite>::new("SELECT 1 WHERE 1 = 1");
|
||||
push_key_auth_channel_sql_filter(&mut builder, "codex:live");
|
||||
let sql = builder.sql();
|
||||
let codex_clause = sql
|
||||
.split_once("LOWER(TRIM(p.provider_type)) = 'codex'")
|
||||
.and_then(|(_, suffix)| {
|
||||
suffix.split_once("LOWER(TRIM(p.provider_type)) = 'chatgpt_web'")
|
||||
})
|
||||
.map(|(clause, _)| clause)
|
||||
.expect("Codex auth clause should exist");
|
||||
|
||||
assert!(codex_clause.contains("LOWER(TRIM(pak.auth_type)) = 'oauth'"));
|
||||
assert!(codex_clause.contains("'codex:live'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_mapping_scope_keeps_legacy_responses_compatibility_codex_only() {
|
||||
assert!(provider_model_mapping_api_format_covers(
|
||||
"codex",
|
||||
"openai:responses",
|
||||
"codex:live"
|
||||
));
|
||||
assert!(!provider_model_mapping_api_format_covers(
|
||||
"openai",
|
||||
"openai:responses",
|
||||
"codex:live"
|
||||
));
|
||||
assert!(!provider_model_mapping_api_format_covers(
|
||||
"custom",
|
||||
"openai:responses",
|
||||
"codex:live"
|
||||
));
|
||||
assert!(!provider_model_mapping_api_format_covers(
|
||||
"codex",
|
||||
"openai:chat",
|
||||
"codex:live"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_page_accumulator_continues_after_coarse_false_positives() {
|
||||
let mut accumulator = ExactPageAccumulator::new(1, 2);
|
||||
|
||||
@@ -678,6 +678,158 @@ ORDER BY id
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codex_live_permission_migration_is_scoped_and_idempotent() {
|
||||
const MIGRATION_VERSION: i64 = 20260821000000;
|
||||
|
||||
let pool = sqlx::sqlite::SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.expect("in-memory sqlite pool");
|
||||
run_migrations(&pool).await.expect("run sqlite migrations");
|
||||
|
||||
sqlx::raw_sql(
|
||||
r#"
|
||||
INSERT INTO users (
|
||||
id, username, allowed_api_formats, allowed_api_formats_mode, created_at, updated_at
|
||||
) VALUES
|
||||
('user-specific', 'user-specific', '["openai:responses"]', 'specific', 1, 1),
|
||||
('user-unrestricted', 'user-unrestricted', '["openai:responses"]', 'unrestricted', 1, 1);
|
||||
|
||||
INSERT INTO api_keys (
|
||||
id, user_id, key_hash, allowed_api_formats, created_at, updated_at
|
||||
) VALUES (
|
||||
'api-key-specific', 'user-specific', 'api-key-specific-hash',
|
||||
'["openai:responses"]', 1, 1
|
||||
);
|
||||
|
||||
INSERT INTO user_groups (
|
||||
id, name, normalized_name, allowed_api_formats, allowed_api_formats_mode,
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
'group-specific', 'Specific', 'specific', '["openai:responses"]', 'specific', 1, 1
|
||||
);
|
||||
|
||||
INSERT INTO providers (id, name, provider_type, created_at, updated_at) VALUES
|
||||
('provider-codex', 'Codex migration fixture', 'codex', 1, 1),
|
||||
('provider-openai', 'OpenAI migration fixture', 'openai', 1, 1);
|
||||
|
||||
INSERT INTO provider_api_keys (
|
||||
id, provider_id, name, api_formats, auth_type_by_format,
|
||||
allow_auth_channel_mismatch_formats, rate_multipliers,
|
||||
global_priority_by_format, created_at, updated_at
|
||||
) VALUES
|
||||
(
|
||||
'provider-key-codex', 'provider-codex', 'Codex key',
|
||||
'["openai:responses"]', '{"openai:responses":"oauth"}',
|
||||
'["openai:responses"]', '{"openai:responses":1.25}',
|
||||
'{"openai:responses":17}', 1, 1
|
||||
),
|
||||
(
|
||||
'provider-key-openai', 'provider-openai', 'OpenAI key',
|
||||
'["openai:responses"]', '{"openai:responses":"api_key"}',
|
||||
'["openai:responses"]', '{"openai:responses":2.0}',
|
||||
'{"openai:responses":23}', 1, 1
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("legacy Codex Live fixtures should insert");
|
||||
|
||||
let migration = MIGRATOR
|
||||
.iter()
|
||||
.find(|migration| migration.version == MIGRATION_VERSION)
|
||||
.expect("Codex Live permission migration should be embedded");
|
||||
for _ in 0..2 {
|
||||
sqlx::raw_sql(migration.sql.as_ref())
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("Codex Live permission migration should run idempotently");
|
||||
}
|
||||
|
||||
let user_specific: String =
|
||||
sqlx::query_scalar("SELECT allowed_api_formats FROM users WHERE id = 'user-specific'")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("specific user formats should load");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&user_specific).unwrap(),
|
||||
serde_json::json!(["openai:responses", "codex:live"])
|
||||
);
|
||||
|
||||
let user_unrestricted: String = sqlx::query_scalar(
|
||||
"SELECT allowed_api_formats FROM users WHERE id = 'user-unrestricted'",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("unrestricted user formats should load");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&user_unrestricted).unwrap(),
|
||||
serde_json::json!(["openai:responses"])
|
||||
);
|
||||
|
||||
for (table, id_column, id) in [
|
||||
("api_keys", "id", "api-key-specific"),
|
||||
("user_groups", "id", "group-specific"),
|
||||
] {
|
||||
let sql = format!("SELECT allowed_api_formats FROM {table} WHERE {id_column} = ?");
|
||||
let formats: String = sqlx::query_scalar(sql.as_str())
|
||||
.bind(id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("migrated downstream formats should load");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&formats).unwrap(),
|
||||
serde_json::json!(["openai:responses", "codex:live"])
|
||||
);
|
||||
}
|
||||
|
||||
let codex_key = sqlx::query_as::<_, (String, String, String, String, String)>(
|
||||
r#"
|
||||
SELECT api_formats, auth_type_by_format, allow_auth_channel_mismatch_formats,
|
||||
rate_multipliers, global_priority_by_format
|
||||
FROM provider_api_keys
|
||||
WHERE id = 'provider-key-codex'
|
||||
"#,
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("migrated Codex key should load");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&codex_key.0).unwrap(),
|
||||
serde_json::json!(["openai:responses", "codex:live"])
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&codex_key.1).unwrap(),
|
||||
serde_json::json!({"openai:responses": "oauth", "codex:live": "oauth"})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&codex_key.2).unwrap(),
|
||||
serde_json::json!(["openai:responses", "codex:live"])
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&codex_key.3).unwrap(),
|
||||
serde_json::json!({"openai:responses": 1.25, "codex:live": 1.25})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&codex_key.4).unwrap(),
|
||||
serde_json::json!({"openai:responses": 17, "codex:live": 17})
|
||||
);
|
||||
|
||||
let openai_formats: String = sqlx::query_scalar(
|
||||
"SELECT api_formats FROM provider_api_keys WHERE id = 'provider-key-openai'",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("non-Codex key formats should load");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&openai_formats).unwrap(),
|
||||
serde_json::json!(["openai:responses"])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_and_startup_preparation_reject_dirty_migration_state() {
|
||||
let pool = sqlx::sqlite::SqlitePoolOptions::new()
|
||||
|
||||
@@ -885,6 +885,12 @@ AND LOWER(TRIM(COALESCE(provider_name, ''))) NOT IN ('unknown', 'unknow'))",
|
||||
.push("is_stream = ")
|
||||
.push_bind(if is_stream { 1_i64 } else { 0_i64 });
|
||||
}
|
||||
if let Some(is_websocket) = query.is_websocket {
|
||||
push_sqlite_usage_where(builder, has_where);
|
||||
builder
|
||||
.push("COALESCE(CAST(json_extract(request_metadata, '$.websocket_mode') AS INTEGER), 0) = ")
|
||||
.push_bind(if is_websocket { 1_i64 } else { 0_i64 });
|
||||
}
|
||||
if query.error_only {
|
||||
push_sqlite_usage_where(builder, has_where);
|
||||
builder.push(
|
||||
@@ -931,6 +937,7 @@ fn push_sqlite_usage_keyword_filters(
|
||||
statuses: query.statuses.clone(),
|
||||
exclude_status_codes: query.exclude_status_codes.clone(),
|
||||
is_stream: query.is_stream,
|
||||
is_websocket: query.is_websocket,
|
||||
error_only: query.error_only,
|
||||
limit: None,
|
||||
offset: None,
|
||||
|
||||
@@ -2,11 +2,12 @@ use super::{SqliteUsageReadRepository, SqliteUsageWriteRepository};
|
||||
use crate::run_migrations;
|
||||
use aether_data_contracts::repository::usage::{
|
||||
ProviderApiKeyWindowUsageRequest, UpsertUsageRecord, UsageAuditAggregationGroupBy,
|
||||
UsageAuditAggregationQuery, UsageAuditListQuery, UsageAuditSummaryQuery, UsageBodyCaptureState,
|
||||
UsageBreakdownGroupBy, UsageBreakdownSummaryQuery, UsageCleanupExecutionMode,
|
||||
UsageCleanupTargets, UsageCleanupWindow, UsageDailyHeatmapQuery,
|
||||
UsageDashboardDailyBreakdownQuery, UsageDashboardSummaryQuery, UsageProviderPerformanceQuery,
|
||||
UsageReadRepository, UsageTimeSeriesGranularity, UsageWriteRepository,
|
||||
UsageAuditAggregationQuery, UsageAuditKeywordSearchQuery, UsageAuditListQuery,
|
||||
UsageAuditSummaryQuery, UsageBodyCaptureState, UsageBreakdownGroupBy,
|
||||
UsageBreakdownSummaryQuery, UsageCleanupExecutionMode, UsageCleanupTargets, UsageCleanupWindow,
|
||||
UsageDailyHeatmapQuery, UsageDashboardDailyBreakdownQuery, UsageDashboardSummaryQuery,
|
||||
UsageProviderPerformanceQuery, UsageReadRepository, UsageTimeSeriesGranularity,
|
||||
UsageWriteRepository,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
@@ -1745,6 +1746,105 @@ WHERE request_id = 'request-1'
|
||||
assert_eq!(summary.total_tokens, 10);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_usage_websocket_filter_applies_to_list_count_and_keyword_search() {
|
||||
let pool = sqlx::sqlite::SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.expect("sqlite pool should connect");
|
||||
run_migrations(&pool)
|
||||
.await
|
||||
.expect("sqlite migrations should run");
|
||||
seed_stats_targets(&pool).await;
|
||||
|
||||
let writer = SqliteUsageWriteRepository::new(pool.clone());
|
||||
writer
|
||||
.upsert(sample_usage("request-http", "completed", "settled", 1_000))
|
||||
.await
|
||||
.expect("HTTP usage should upsert");
|
||||
let mut websocket = sample_usage("request-ws", "completed", "void", 1_001);
|
||||
websocket.request_metadata = Some(serde_json::json!({
|
||||
"websocket_mode": true,
|
||||
"websocket_transport": "codex_live_direct",
|
||||
"usage_available": false,
|
||||
}));
|
||||
websocket.input_tokens = None;
|
||||
websocket.output_tokens = None;
|
||||
websocket.total_tokens = None;
|
||||
websocket.cache_creation_input_tokens = None;
|
||||
websocket.cache_creation_ephemeral_5m_input_tokens = None;
|
||||
websocket.cache_creation_ephemeral_1h_input_tokens = None;
|
||||
websocket.cache_read_input_tokens = None;
|
||||
websocket.cache_creation_cost_usd = None;
|
||||
websocket.cache_read_cost_usd = None;
|
||||
websocket.total_cost_usd = None;
|
||||
websocket.actual_total_cost_usd = None;
|
||||
writer
|
||||
.upsert(websocket)
|
||||
.await
|
||||
.expect("WebSocket usage should upsert");
|
||||
|
||||
let reader = SqliteUsageReadRepository::new(pool);
|
||||
let list_query = UsageAuditListQuery {
|
||||
is_websocket: Some(true),
|
||||
..UsageAuditListQuery::default()
|
||||
};
|
||||
let listed = reader
|
||||
.list_usage_audits(&list_query)
|
||||
.await
|
||||
.expect("WebSocket list should load");
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(listed[0].request_id, "request-ws");
|
||||
assert_eq!(
|
||||
reader
|
||||
.count_usage_audits(&list_query)
|
||||
.await
|
||||
.expect("WebSocket count should load"),
|
||||
1
|
||||
);
|
||||
|
||||
let keyword_query = UsageAuditKeywordSearchQuery {
|
||||
is_websocket: Some(true),
|
||||
keywords: vec!["model-1".to_string()],
|
||||
..UsageAuditKeywordSearchQuery::default()
|
||||
};
|
||||
let keyword_matches = reader
|
||||
.list_usage_audits_by_keyword_search(&keyword_query)
|
||||
.await
|
||||
.expect("WebSocket keyword list should load");
|
||||
assert_eq!(keyword_matches.len(), 1);
|
||||
assert_eq!(keyword_matches[0].request_id, "request-ws");
|
||||
assert_eq!(
|
||||
reader
|
||||
.count_usage_audits_by_keyword_search(&keyword_query)
|
||||
.await
|
||||
.expect("WebSocket keyword count should load"),
|
||||
1
|
||||
);
|
||||
|
||||
let summary = reader
|
||||
.summarize_usage_audits(&UsageAuditSummaryQuery {
|
||||
created_from_unix_secs: 0,
|
||||
created_until_unix_secs: 2_000,
|
||||
..UsageAuditSummaryQuery::default()
|
||||
})
|
||||
.await
|
||||
.expect("lifecycle summary should load");
|
||||
assert_eq!(summary.total_requests, 2);
|
||||
assert_eq!(summary.recorded_total_tokens, 5);
|
||||
|
||||
let provider_key_summaries = reader
|
||||
.summarize_usage_by_provider_api_key_ids(&["provider-key-1".to_string()])
|
||||
.await
|
||||
.expect("provider key lifecycle summary should load");
|
||||
let provider_key_summary = provider_key_summaries
|
||||
.get("provider-key-1")
|
||||
.expect("provider key summary");
|
||||
assert_eq!(provider_key_summary.request_count, 2);
|
||||
assert_eq!(provider_key_summary.total_tokens, 5);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_usage_daily_heatmap_reads_imported_daily_aggregates() {
|
||||
let pool = sqlx::sqlite::SqlitePoolOptions::new()
|
||||
|
||||
Reference in New Issue
Block a user