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:
fawney19
2026-04-23 14:42:51 +08:00
parent f55f22d2e8
commit fa328e18a1
128 changed files with 5583 additions and 690 deletions

View File

@@ -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;

View File

@@ -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,

View File

@@ -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), '') <> ''
)
)
)
);

View File

@@ -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;

View File

@@ -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,
]
);
}

View File

@@ -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,

View File

@@ -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)

View File

@@ -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 (
"#;

View File

@@ -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");
}
}

View File

@@ -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]