mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat: provider api_formats 可空继承、OpenAI 图片 edit/variation 与用量配额多项补强
- 鉴权: provider_api_keys.api_formats 改为可空,OAuth 托管 key 自动继承 provider endpoints 激活格式,相关 handler/测试同步更新 - 图片 planner: OpenAI 图片路由新增 edit/variation 操作并完善参数校验、响应合并与流式处理 - 用量: user me usage 返回区分 client_requested_stream/upstream_is_stream,前端 usage 列表筛选与展示增强 - 统计: stats_daily_model 新增 cache_creation_ephemeral_5m/1h tokens 字段与回填链路 - 配额/observability: quota repository 新增内存与 SQL 扩展,admin observability usage 字段扩充 - 其它: OAuth 导入/轮询收敛、provider 汇总与 pool admin 读写链路小修、新增 system_config 缓存与 provider template handler Closes #318 Co-authored-by: Entropy.Xu <53283266+Entropy-Xu@users.noreply.github.com>
This commit is contained in:
@@ -495,7 +495,7 @@ CREATE TABLE IF NOT EXISTS public.provider_api_keys (
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
provider_id character varying(36) NOT NULL,
|
||||
api_formats json DEFAULT '[]'::json NOT NULL,
|
||||
api_formats json,
|
||||
rate_multipliers json,
|
||||
health_by_format jsonb,
|
||||
circuit_breaker_by_format jsonb,
|
||||
@@ -5158,6 +5158,10 @@ ALTER TABLE public.stats_user_daily
|
||||
ADD COLUMN IF NOT EXISTS cache_creation_ephemeral_5m_tokens bigint DEFAULT '0'::bigint NOT NULL,
|
||||
ADD COLUMN IF NOT EXISTS cache_creation_ephemeral_1h_tokens bigint DEFAULT '0'::bigint NOT NULL;
|
||||
|
||||
ALTER TABLE public.stats_daily_model
|
||||
ADD COLUMN IF NOT EXISTS cache_creation_ephemeral_5m_tokens bigint DEFAULT '0'::bigint NOT NULL,
|
||||
ADD COLUMN IF NOT EXISTS cache_creation_ephemeral_1h_tokens bigint DEFAULT '0'::bigint NOT NULL;
|
||||
|
||||
ALTER TABLE public.stats_daily
|
||||
ADD COLUMN IF NOT EXISTS cache_hit_total_requests bigint DEFAULT 0 NOT NULL,
|
||||
ADD COLUMN IF NOT EXISTS cache_hit_requests bigint DEFAULT 0 NOT NULL;
|
||||
|
||||
@@ -494,7 +494,7 @@ CREATE TABLE IF NOT EXISTS public.provider_api_keys (
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
provider_id character varying(36) NOT NULL,
|
||||
api_formats json DEFAULT '[]'::json NOT NULL,
|
||||
api_formats json,
|
||||
rate_multipliers json,
|
||||
health_by_format jsonb,
|
||||
circuit_breaker_by_format jsonb,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
ALTER TABLE public.provider_api_keys
|
||||
ALTER COLUMN api_formats DROP DEFAULT,
|
||||
ALTER COLUMN api_formats DROP NOT NULL;
|
||||
|
||||
UPDATE public.provider_api_keys AS pak
|
||||
SET
|
||||
api_formats = NULL,
|
||||
updated_at = NOW()
|
||||
FROM public.providers AS p
|
||||
WHERE p.id = pak.provider_id
|
||||
AND pak.api_formats IS NOT NULL
|
||||
AND pak.api_formats::jsonb = '[]'::jsonb
|
||||
AND (
|
||||
(
|
||||
LOWER(BTRIM(p.provider_type)) IN (
|
||||
'claude_code',
|
||||
'codex',
|
||||
'gemini_cli',
|
||||
'vertex_ai',
|
||||
'antigravity'
|
||||
)
|
||||
AND LOWER(BTRIM(pak.auth_type)) = 'oauth'
|
||||
)
|
||||
OR (
|
||||
LOWER(BTRIM(p.provider_type)) = 'kiro'
|
||||
AND (
|
||||
LOWER(BTRIM(pak.auth_type)) = 'oauth'
|
||||
OR (
|
||||
LOWER(BTRIM(pak.auth_type)) = 'bearer'
|
||||
AND COALESCE(BTRIM(pak.auth_config), '') <> ''
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE public.stats_daily_model
|
||||
ADD COLUMN IF NOT EXISTS cache_creation_ephemeral_5m_tokens bigint DEFAULT '0'::bigint NOT NULL,
|
||||
ADD COLUMN IF NOT EXISTS cache_creation_ephemeral_1h_tokens bigint DEFAULT '0'::bigint NOT NULL;
|
||||
@@ -8,7 +8,7 @@ use tracing::{error, info, warn};
|
||||
|
||||
static MIGRATOR: Migrator = sqlx::migrate!("./migrations");
|
||||
static BASELINE_V2_SQL: &str = include_str!("../bootstrap/20260413020000_baseline_v2.sql");
|
||||
const BASELINE_V2_CUTOFF_VERSION: i64 = 20260422120000;
|
||||
const BASELINE_V2_CUTOFF_VERSION: i64 = 20260423010000;
|
||||
const MIGRATIONS_TABLE_EXISTS_SQL: &str =
|
||||
"SELECT to_regclass('public._sqlx_migrations') IS NOT NULL";
|
||||
const PUBLIC_BASE_TABLE_COUNT_SQL: &str = r#"
|
||||
@@ -662,6 +662,8 @@ SELECT EXISTS (
|
||||
20260421000000,
|
||||
20260422110000,
|
||||
20260422120000,
|
||||
20260423000000,
|
||||
20260423010000,
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -708,6 +710,24 @@ SELECT EXISTS (
|
||||
));
|
||||
assert!(BASELINE_V2_SQL.contains("successful_response_time_sum_ms double precision"));
|
||||
assert!(BASELINE_V2_SQL.contains("cache_hit_total_requests bigint DEFAULT 0 NOT NULL"));
|
||||
assert!(BASELINE_V2_SQL.contains(
|
||||
"ALTER TABLE public.stats_daily_model\n ADD COLUMN IF NOT EXISTS cache_creation_ephemeral_5m_tokens bigint DEFAULT '0'::bigint NOT NULL,"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_api_keys_api_formats_remains_nullable_in_baselines() {
|
||||
let baseline_migration = MIGRATOR
|
||||
.iter()
|
||||
.find(|migration| migration.version == 20260403000000)
|
||||
.expect("baseline migration should be embedded");
|
||||
|
||||
assert!(baseline_migration.sql.contains("api_formats json,"));
|
||||
assert!(!baseline_migration
|
||||
.sql
|
||||
.contains("api_formats json DEFAULT '[]'::json NOT NULL"));
|
||||
assert!(BASELINE_V2_SQL.contains("api_formats json,"));
|
||||
assert!(!BASELINE_V2_SQL.contains("api_formats json DEFAULT '[]'::json NOT NULL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -790,6 +810,8 @@ SELECT EXISTS (
|
||||
20260421000000,
|
||||
20260422110000,
|
||||
20260422120000,
|
||||
20260423000000,
|
||||
20260423010000,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@ impl Default for PostgresPoolConfig {
|
||||
Self {
|
||||
database_url: String::new(),
|
||||
min_connections: 1,
|
||||
max_connections: 50,
|
||||
acquire_timeout_ms: 3_000,
|
||||
max_connections: 100,
|
||||
acquire_timeout_ms: 10_000,
|
||||
idle_timeout_ms: 60_000,
|
||||
max_lifetime_ms: 30 * 60_000,
|
||||
statement_cache_capacity: 100,
|
||||
|
||||
@@ -64,6 +64,27 @@ WHERE p.is_active = TRUE
|
||||
AND LOWER(pe.api_format) = LOWER($1)
|
||||
AND (
|
||||
pak.api_formats IS NULL
|
||||
OR (
|
||||
LOWER(BTRIM(p.provider_type)) IN (
|
||||
'claude_code',
|
||||
'codex',
|
||||
'gemini_cli',
|
||||
'vertex_ai',
|
||||
'antigravity'
|
||||
)
|
||||
AND LOWER(BTRIM(pak.auth_type)) = 'oauth'
|
||||
)
|
||||
OR (
|
||||
LOWER(BTRIM(p.provider_type)) = 'kiro'
|
||||
AND (
|
||||
LOWER(BTRIM(pak.auth_type)) = 'oauth'
|
||||
OR (
|
||||
LOWER(BTRIM(pak.auth_type)) = 'bearer'
|
||||
AND pak.auth_config IS NOT NULL
|
||||
AND BTRIM(pak.auth_config) <> ''
|
||||
)
|
||||
)
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM json_array_elements_text(pak.api_formats) AS fmt(value)
|
||||
@@ -137,6 +158,27 @@ WHERE p.is_active = TRUE
|
||||
AND gm.name = $2
|
||||
AND (
|
||||
pak.api_formats IS NULL
|
||||
OR (
|
||||
LOWER(BTRIM(p.provider_type)) IN (
|
||||
'claude_code',
|
||||
'codex',
|
||||
'gemini_cli',
|
||||
'vertex_ai',
|
||||
'antigravity'
|
||||
)
|
||||
AND LOWER(BTRIM(pak.auth_type)) = 'oauth'
|
||||
)
|
||||
OR (
|
||||
LOWER(BTRIM(p.provider_type)) = 'kiro'
|
||||
AND (
|
||||
LOWER(BTRIM(pak.auth_type)) = 'oauth'
|
||||
OR (
|
||||
LOWER(BTRIM(pak.auth_type)) = 'bearer'
|
||||
AND pak.auth_config IS NOT NULL
|
||||
AND BTRIM(pak.auth_config) <> ''
|
||||
)
|
||||
)
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM json_array_elements_text(pak.api_formats) AS fmt(value)
|
||||
|
||||
@@ -249,19 +249,22 @@ SELECT
|
||||
provider_id,
|
||||
COALESCE(NULLIF(name, ''), id) AS name,
|
||||
COALESCE(NULLIF(auth_type, ''), 'summary') AS auth_type,
|
||||
capabilities,
|
||||
NULL::jsonb AS capabilities,
|
||||
is_active,
|
||||
api_formats,
|
||||
'summary' AS api_key,
|
||||
NULL::text AS auth_config,
|
||||
CASE
|
||||
WHEN auth_config IS NULL THEN NULL
|
||||
ELSE '{}'::text
|
||||
END AS auth_config,
|
||||
NULL::text AS note,
|
||||
internal_priority,
|
||||
rate_multipliers,
|
||||
global_priority_by_format,
|
||||
NULL::integer AS internal_priority,
|
||||
NULL::jsonb AS rate_multipliers,
|
||||
NULL::jsonb AS global_priority_by_format,
|
||||
NULL::jsonb AS allowed_models,
|
||||
NULL::bigint AS expires_at_unix_secs,
|
||||
cache_ttl_minutes,
|
||||
max_probe_interval_minutes,
|
||||
NULL::integer AS cache_ttl_minutes,
|
||||
NULL::integer AS max_probe_interval_minutes,
|
||||
NULL::jsonb AS proxy,
|
||||
NULL::jsonb AS fingerprint,
|
||||
NULL::integer AS rpm_limit,
|
||||
@@ -274,27 +277,27 @@ SELECT
|
||||
NULL::jsonb AS utilization_samples,
|
||||
NULL::bigint AS last_probe_increase_at_unix_secs,
|
||||
NULL::integer AS last_rpm_peak,
|
||||
request_count,
|
||||
NULL::integer AS request_count,
|
||||
0::bigint AS total_tokens,
|
||||
0::double precision AS total_cost_usd,
|
||||
success_count,
|
||||
NULL::integer AS success_count,
|
||||
NULL::integer AS error_count,
|
||||
total_response_time_ms,
|
||||
EXTRACT(EPOCH FROM last_used_at)::bigint AS last_used_at_unix_secs,
|
||||
auto_fetch_models,
|
||||
NULL::integer AS total_response_time_ms,
|
||||
NULL::bigint AS last_used_at_unix_secs,
|
||||
FALSE AS auto_fetch_models,
|
||||
NULL::bigint AS last_models_fetch_at_unix_secs,
|
||||
NULL::text AS last_models_fetch_error,
|
||||
NULL::jsonb AS locked_models,
|
||||
NULL::jsonb AS model_include_patterns,
|
||||
NULL::jsonb AS model_exclude_patterns,
|
||||
NULL::jsonb AS upstream_metadata,
|
||||
EXTRACT(EPOCH FROM oauth_invalid_at)::bigint AS oauth_invalid_at_unix_secs,
|
||||
oauth_invalid_reason,
|
||||
NULL::bigint AS oauth_invalid_at_unix_secs,
|
||||
NULL::text AS oauth_invalid_reason,
|
||||
NULL::jsonb AS status_snapshot,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint AS created_at_unix_ms,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint AS updated_at_unix_secs,
|
||||
NULL::bigint AS created_at_unix_ms,
|
||||
NULL::bigint AS updated_at_unix_secs,
|
||||
health_by_format,
|
||||
circuit_breaker_by_format
|
||||
NULL::jsonb AS circuit_breaker_by_format
|
||||
FROM provider_api_keys
|
||||
WHERE provider_id IN (
|
||||
"#;
|
||||
|
||||
@@ -42,6 +42,17 @@ impl ProviderQuotaReadRepository for InMemoryProviderQuotaRepository {
|
||||
.get(provider_id)
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn find_by_provider_ids(
|
||||
&self,
|
||||
provider_ids: &[String],
|
||||
) -> Result<Vec<StoredProviderQuotaSnapshot>, DataLayerError> {
|
||||
let quotas = self.by_provider_id.read().expect("quota repository lock");
|
||||
Ok(provider_ids
|
||||
.iter()
|
||||
.filter_map(|provider_id| quotas.get(provider_id).cloned())
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -106,4 +117,35 @@ mod tests {
|
||||
.expect("quota should exist");
|
||||
assert_eq!(stored.monthly_used_usd, 0.0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn finds_quotas_by_provider_ids() {
|
||||
let repository = InMemoryProviderQuotaRepository::seed(vec![
|
||||
sample_quota(),
|
||||
StoredProviderQuotaSnapshot::new(
|
||||
"provider-2".to_string(),
|
||||
"payg".to_string(),
|
||||
None,
|
||||
1.5,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.expect("quota should build"),
|
||||
]);
|
||||
|
||||
let stored = repository
|
||||
.find_by_provider_ids(&[
|
||||
"provider-2".to_string(),
|
||||
"missing".to_string(),
|
||||
"provider-1".to_string(),
|
||||
])
|
||||
.await
|
||||
.expect("lookup should succeed");
|
||||
|
||||
assert_eq!(stored.len(), 2);
|
||||
assert_eq!(stored[0].provider_id, "provider-2");
|
||||
assert_eq!(stored[1].provider_id, "provider-1");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,21 @@ WHERE id = $1
|
||||
LIMIT 1
|
||||
"#;
|
||||
|
||||
const FIND_BY_PROVIDER_IDS_SQL: &str = r#"
|
||||
SELECT
|
||||
id AS provider_id,
|
||||
CAST(billing_type AS TEXT) AS billing_type,
|
||||
CAST(monthly_quota_usd AS DOUBLE PRECISION) AS monthly_quota_usd,
|
||||
CAST(COALESCE(monthly_used_usd, 0) AS DOUBLE PRECISION) AS monthly_used_usd,
|
||||
quota_reset_day,
|
||||
CAST(EXTRACT(EPOCH FROM quota_last_reset_at) AS BIGINT) AS quota_last_reset_at_unix_secs,
|
||||
CAST(EXTRACT(EPOCH FROM quota_expires_at) AS BIGINT) AS quota_expires_at_unix_secs,
|
||||
is_active
|
||||
FROM providers
|
||||
WHERE id = ANY($1::TEXT[])
|
||||
ORDER BY id ASC
|
||||
"#;
|
||||
|
||||
const RESET_DUE_SQL: &str = r#"
|
||||
UPDATE providers
|
||||
SET
|
||||
@@ -60,6 +75,24 @@ impl ProviderQuotaReadRepository for SqlxProviderQuotaRepository {
|
||||
.map_postgres_err()?;
|
||||
row.as_ref().map(map_row).transpose()
|
||||
}
|
||||
|
||||
async fn find_by_provider_ids(
|
||||
&self,
|
||||
provider_ids: &[String],
|
||||
) -> Result<Vec<StoredProviderQuotaSnapshot>, DataLayerError> {
|
||||
if provider_ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
sqlx::query(FIND_BY_PROVIDER_IDS_SQL)
|
||||
.bind(provider_ids)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_postgres_err()?
|
||||
.iter()
|
||||
.map(map_row)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
||||
Reference in New Issue
Block a user