Support per-format provider key auth

This commit is contained in:
fawney19
2026-04-29 15:46:50 +08:00
parent 07a319259b
commit e751289dfb
67 changed files with 1244 additions and 420 deletions

View File

@@ -465,7 +465,7 @@ CREATE TABLE IF NOT EXISTS public.payment_orders (
CREATE TABLE IF NOT EXISTS public.provider_api_keys (
id character varying(36) NOT NULL,
api_key text NOT NULL,
api_key text,
name character varying(100) NOT NULL,
note character varying(500),
internal_priority integer DEFAULT 50,
@@ -496,6 +496,7 @@ CREATE TABLE IF NOT EXISTS public.provider_api_keys (
updated_at timestamp with time zone DEFAULT now() NOT NULL,
provider_id character varying(36) NOT NULL,
api_formats json,
auth_type_by_format json,
rate_multipliers json,
health_by_format jsonb,
circuit_breaker_by_format jsonb,
@@ -1992,21 +1993,6 @@ END $mig$;
--
-- Name: provider_endpoints uq_provider_api_format; Type: CONSTRAINT; Schema: public; Owner: -
--
DO $mig$ BEGIN
ALTER TABLE ONLY public.provider_endpoints
ADD CONSTRAINT uq_provider_api_format UNIQUE (provider_id, api_format);
EXCEPTION
WHEN duplicate_object THEN NULL;
WHEN duplicate_table THEN NULL;
WHEN invalid_table_definition THEN NULL;
END $mig$;
--
-- Name: models uq_provider_model; Type: CONSTRAINT; Schema: public; Owner: -
--
@@ -2503,6 +2489,14 @@ CREATE INDEX IF NOT EXISTS idx_endpoint_format_active ON public.provider_endpoin
--
-- Name: idx_provider_endpoints_provider_api_format; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX IF NOT EXISTS idx_provider_endpoints_provider_api_format ON public.provider_endpoints USING btree (provider_id, api_format);
--
-- Name: idx_gemini_file_mappings_expires; Type: INDEX; Schema: public; Owner: -
--

View File

@@ -464,7 +464,7 @@ CREATE TABLE IF NOT EXISTS public.payment_orders (
CREATE TABLE IF NOT EXISTS public.provider_api_keys (
id character varying(36) NOT NULL,
api_key text NOT NULL,
api_key text,
name character varying(100) NOT NULL,
note character varying(500),
internal_priority integer DEFAULT 50,
@@ -495,6 +495,7 @@ CREATE TABLE IF NOT EXISTS public.provider_api_keys (
updated_at timestamp with time zone DEFAULT now() NOT NULL,
provider_id character varying(36) NOT NULL,
api_formats json,
auth_type_by_format json,
rate_multipliers json,
health_by_format jsonb,
circuit_breaker_by_format jsonb,

View File

@@ -14,89 +14,38 @@ AS $$
END
$$;
DO $$
DECLARE
conflict_summary text;
BEGIN
SELECT string_agg(
DISTINCT provider_id::text || ':' || canonical_api_format,
', ' ORDER BY provider_id::text || ':' || canonical_api_format
)
INTO conflict_summary
FROM (
SELECT
left_endpoint.provider_id,
public.aether_canonical_api_format_alias(left_endpoint.api_format) AS canonical_api_format
FROM public.provider_endpoints AS left_endpoint
INNER JOIN public.provider_endpoints AS right_endpoint
ON right_endpoint.provider_id = left_endpoint.provider_id
AND right_endpoint.id > left_endpoint.id
AND public.aether_canonical_api_format_alias(right_endpoint.api_format)
= public.aether_canonical_api_format_alias(left_endpoint.api_format)
WHERE left_endpoint.api_format IN ('openai:responses', 'openai:cli', 'openai:responses:compact', 'openai:compact', 'claude:messages', 'claude:chat', 'claude:cli', 'gemini:generate_content', 'gemini:chat', 'gemini:cli')
AND right_endpoint.api_format IN ('openai:responses', 'openai:cli', 'openai:responses:compact', 'openai:compact', 'claude:messages', 'claude:chat', 'claude:cli', 'gemini:generate_content', 'gemini:chat', 'gemini:cli')
AND (
left_endpoint.base_url IS DISTINCT FROM right_endpoint.base_url
OR left_endpoint.custom_path IS DISTINCT FROM right_endpoint.custom_path
OR left_endpoint.max_retries IS DISTINCT FROM right_endpoint.max_retries
OR left_endpoint.header_rules::jsonb IS DISTINCT FROM right_endpoint.header_rules::jsonb
OR left_endpoint.body_rules::jsonb IS DISTINCT FROM right_endpoint.body_rules::jsonb
OR left_endpoint.config::jsonb IS DISTINCT FROM right_endpoint.config::jsonb
OR left_endpoint.proxy IS DISTINCT FROM right_endpoint.proxy
OR left_endpoint.format_acceptance_config::jsonb IS DISTINCT FROM right_endpoint.format_acceptance_config::jsonb
)
) AS conflicts;
ALTER TABLE IF EXISTS public.provider_endpoints
DROP CONSTRAINT IF EXISTS uq_provider_api_format;
IF conflict_summary IS NOT NULL THEN
RAISE EXCEPTION
'Cannot normalize OpenAI/Claude/Gemini provider_endpoints because transport fields differ for: %',
conflict_summary;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_provider_endpoints_provider_api_format
ON public.provider_endpoints USING btree (provider_id, api_format);
WITH grouped AS (
ALTER TABLE IF EXISTS public.provider_api_keys
ADD COLUMN IF NOT EXISTS auth_type_by_format json;
ALTER TABLE IF EXISTS public.provider_api_keys
ALTER COLUMN api_key DROP NOT NULL;
WITH normalized AS (
SELECT
id,
provider_id,
api_format,
public.aether_canonical_api_format_alias(api_format) AS canonical_api_format,
ROW_NUMBER() OVER (
PARTITION BY provider_id, public.aether_canonical_api_format_alias(api_format)
ORDER BY
CASE
WHEN api_format = public.aether_canonical_api_format_alias(api_format) THEN 0
ELSE 1
END,
created_at ASC,
id ASC
) AS rank
public.aether_canonical_api_format_alias(api_format) AS canonical_api_format
FROM public.provider_endpoints
WHERE api_format IN ('openai:responses', 'openai:cli', 'openai:responses:compact', 'openai:compact', 'claude:messages', 'claude:chat', 'claude:cli', 'gemini:generate_content', 'gemini:chat', 'gemini:cli')
),
survivors AS (
SELECT *
FROM grouped
WHERE rank = 1
),
retired AS (
UPDATE public.provider_endpoints AS endpoint
SET
is_active = FALSE,
updated_at = NOW()
FROM grouped
WHERE endpoint.id = grouped.id
AND grouped.rank > 1
RETURNING endpoint.id
)
UPDATE public.provider_endpoints AS endpoint
SET
api_format = survivors.canonical_api_format,
api_family = SPLIT_PART(survivors.canonical_api_format, ':', 1),
endpoint_kind = SUBSTRING(survivors.canonical_api_format FROM POSITION(':' IN survivors.canonical_api_format) + 1),
api_format = normalized.canonical_api_format,
api_family = SPLIT_PART(normalized.canonical_api_format, ':', 1),
endpoint_kind = SUBSTRING(normalized.canonical_api_format FROM POSITION(':' IN normalized.canonical_api_format) + 1),
updated_at = NOW()
FROM survivors
WHERE endpoint.id = survivors.id
AND endpoint.api_format IS DISTINCT FROM survivors.canonical_api_format;
FROM normalized
WHERE endpoint.id = normalized.id
AND (
endpoint.api_format IS DISTINCT FROM normalized.canonical_api_format
OR endpoint.api_family IS DISTINCT FROM SPLIT_PART(normalized.canonical_api_format, ':', 1)
OR endpoint.endpoint_kind IS DISTINCT FROM SUBSTRING(normalized.canonical_api_format FROM POSITION(':' IN normalized.canonical_api_format) + 1)
);
WITH expanded AS (
SELECT

View File

@@ -735,6 +735,266 @@ SELECT EXISTS (
assert!(!BASELINE_V2_SQL.contains("api_formats json DEFAULT '[]'::json NOT NULL"));
}
#[test]
fn provider_api_keys_api_key_is_nullable() {
let baseline_migration = MIGRATOR
.iter()
.find(|migration| migration.version == 20260403000000)
.expect("baseline migration should be embedded");
let normalization_migration = MIGRATOR
.iter()
.find(|migration| migration.version == 20260428000000)
.expect("api format normalization migration should be embedded");
assert!(baseline_migration.sql.contains("api_key text,"));
assert!(!baseline_migration.sql.contains("api_key text NOT NULL"));
assert!(BASELINE_V2_SQL.contains("api_key text,"));
assert!(!BASELINE_V2_SQL.contains("api_key text NOT NULL"));
assert!(normalization_migration
.sql
.contains("ALTER COLUMN api_key DROP NOT NULL"));
}
#[test]
fn normalized_endpoint_formats_do_not_require_unique_provider_format_pairs() {
let normalization_migration = MIGRATOR
.iter()
.find(|migration| migration.version == 20260428000000)
.expect("api format normalization migration should be embedded");
assert!(normalization_migration
.sql
.contains("DROP CONSTRAINT IF EXISTS uq_provider_api_format"));
assert!(normalization_migration
.sql
.contains("idx_provider_endpoints_provider_api_format"));
assert!(!BASELINE_V2_SQL.contains("uq_provider_api_format"));
assert!(BASELINE_V2_SQL.contains("idx_provider_endpoints_provider_api_format"));
}
#[tokio::test]
async fn api_format_normalization_migration_preserves_duplicate_endpoint_transports() {
let Some(server) = ManagedPostgresServer::try_start()
.await
.expect("postgres migration test should start or skip")
else {
return;
};
let pool = PgPool::connect(server.database_url())
.await
.expect("pool should connect");
let normalization_migration = MIGRATOR
.iter()
.find(|migration| migration.version == 20260428000000)
.expect("api format normalization migration should be embedded");
sqlx::raw_sql(
r#"
CREATE TABLE public.provider_endpoints (
id text PRIMARY KEY,
provider_id text NOT NULL,
api_format text NOT NULL,
api_family text,
endpoint_kind text,
base_url text NOT NULL,
max_retries integer,
is_active boolean DEFAULT true NOT NULL,
custom_path text,
config json,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
proxy jsonb,
header_rules json,
format_acceptance_config json,
body_rules json
);
ALTER TABLE ONLY public.provider_endpoints
ADD CONSTRAINT uq_provider_api_format UNIQUE (provider_id, api_format);
CREATE TABLE public.provider_api_keys (
id text PRIMARY KEY,
api_formats json,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
rate_multipliers json,
global_priority_by_format json,
health_by_format jsonb,
circuit_breaker_by_format jsonb
);
CREATE TABLE public.api_keys (
id text PRIMARY KEY,
allowed_api_formats json,
updated_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE public.users (
id text PRIMARY KEY,
allowed_api_formats json,
updated_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE public.models (
id text PRIMARY KEY,
provider_model_mappings jsonb,
updated_at timestamp with time zone DEFAULT now() NOT NULL
);
INSERT INTO public.provider_endpoints (
id,
provider_id,
api_format,
api_family,
endpoint_kind,
base_url,
custom_path,
max_retries,
header_rules,
body_rules,
config,
proxy,
format_acceptance_config
) VALUES
(
'endpoint-claude-chat',
'provider-conflict',
'claude:chat',
'claude',
'chat',
'https://claude-chat.example',
'/v1/messages',
2,
'{"x-channel":"chat"}'::json,
'{"mode":"chat"}'::json,
'{"transport":"chat"}'::json,
'{"url":"http://proxy-chat"}'::jsonb,
'{"accept":"chat"}'::json
),
(
'endpoint-claude-cli',
'provider-conflict',
'claude:cli',
'claude',
'cli',
'https://claude-cli.example',
'/v1/messages',
3,
'{"x-channel":"cli"}'::json,
'{"mode":"cli"}'::json,
'{"transport":"cli"}'::json,
'{"url":"http://proxy-cli"}'::jsonb,
'{"accept":"cli"}'::json
);
INSERT INTO public.provider_api_keys (
id,
api_formats,
rate_multipliers,
global_priority_by_format,
health_by_format,
circuit_breaker_by_format
) VALUES (
'provider-key',
'["claude:chat","claude:cli","openai:cli","openai:responses"]'::json,
'{"claude:chat":1,"openai:compact":2}'::json,
'{"gemini:cli":3}'::json,
'{"openai:cli":{"health_score":0.9}}'::jsonb,
'{"openai:compact":{"open":false}}'::jsonb
);
INSERT INTO public.api_keys (id, allowed_api_formats)
VALUES ('api-key', '["gemini:chat","gemini:cli"]'::json);
INSERT INTO public.users (id, allowed_api_formats)
VALUES ('user', '["openai:compact","openai:responses:compact"]'::json);
INSERT INTO public.models (id, provider_model_mappings)
VALUES (
'model',
'[{"api_formats":["claude:chat","claude:cli","gemini:chat"]}]'::jsonb
);
"#,
)
.execute(&pool)
.await
.expect("fixture schema should be created");
sqlx::raw_sql(&normalization_migration.sql)
.execute(&pool)
.await
.expect("api format normalization migration should preserve duplicate endpoints");
let endpoint_rows = sqlx::query_as::<_, (String, String, Option<String>, Option<String>)>(
r#"
SELECT id, api_format, api_family, endpoint_kind
FROM public.provider_endpoints
WHERE provider_id = 'provider-conflict'
ORDER BY id
"#,
)
.fetch_all(&pool)
.await
.expect("endpoint rows should be readable");
assert_eq!(
endpoint_rows,
vec![
(
"endpoint-claude-chat".to_string(),
"claude:messages".to_string(),
Some("claude".to_string()),
Some("messages".to_string())
),
(
"endpoint-claude-cli".to_string(),
"claude:messages".to_string(),
Some("claude".to_string()),
Some("messages".to_string())
),
]
);
let base_urls = sqlx::query_as::<_, (String,)>(
r#"
SELECT base_url
FROM public.provider_endpoints
WHERE provider_id = 'provider-conflict'
ORDER BY id
"#,
)
.fetch_all(&pool)
.await
.expect("endpoint transport rows should be readable")
.into_iter()
.map(|(base_url,)| base_url)
.collect::<Vec<_>>();
assert_eq!(
base_urls,
vec![
"https://claude-chat.example".to_string(),
"https://claude-cli.example".to_string()
]
);
let provider_key_formats: serde_json::Value = query_scalar(
"SELECT api_formats::jsonb FROM public.provider_api_keys WHERE id = 'provider-key'",
)
.fetch_one(&pool)
.await
.expect("provider key formats should be readable");
assert_eq!(
provider_key_formats,
serde_json::json!(["claude:messages", "openai:responses"])
);
let provider_format_constraint_count: i64 = query_scalar(
"SELECT COUNT(*)::BIGINT FROM pg_constraint WHERE conname = 'uq_provider_api_format'",
)
.fetch_one(&pool)
.await
.expect("constraint count should be readable");
assert_eq!(provider_format_constraint_count, 0);
}
#[test]
fn deprecation_migration_and_baseline_mark_legacy_usage_columns() {
let settlement_migration = MIGRATOR

View File

@@ -537,7 +537,7 @@ impl ProviderCatalogWriteRepository for InMemoryProviderCatalogReadRepository {
return Ok(false);
};
key.encrypted_api_key = encrypted_api_key.to_string();
key.encrypted_api_key = Some(encrypted_api_key.to_string());
key.encrypted_auth_config = encrypted_auth_config.map(ToOwned::to_owned);
key.expires_at_unix_secs = expires_at_unix_secs;
Ok(true)
@@ -705,7 +705,10 @@ mod tests {
.await
.expect("keys should read");
assert_eq!(stored.len(), 1);
assert_eq!(stored[0].encrypted_api_key, "ciphertext-updated-token");
assert_eq!(
stored[0].encrypted_api_key.as_deref(),
Some("ciphertext-updated-token")
);
assert_eq!(
stored[0].encrypted_auth_config.as_deref(),
Some("ciphertext-auth-2")

View File

@@ -140,6 +140,7 @@ SELECT
capabilities,
is_active,
api_formats,
auth_type_by_format,
api_key,
auth_config,
note,
@@ -196,6 +197,7 @@ SELECT
capabilities,
is_active,
api_formats,
auth_type_by_format,
api_key,
auth_config,
note,
@@ -252,6 +254,7 @@ SELECT
NULL::jsonb AS capabilities,
is_active,
api_formats,
NULL::jsonb AS auth_type_by_format,
'summary' AS api_key,
CASE
WHEN auth_config IS NULL THEN NULL
@@ -592,6 +595,7 @@ SELECT
capabilities,
is_active,
api_formats,
auth_type_by_format,
api_key,
auth_config,
note,
@@ -1183,6 +1187,7 @@ INSERT INTO provider_api_keys (
id,
provider_id,
api_formats,
auth_type_by_format,
auth_type,
api_key,
auth_config,
@@ -1255,55 +1260,56 @@ INSERT INTO provider_api_keys (
$22,
$23,
$24,
CASE
WHEN $25::double precision IS NULL THEN NULL
ELSE TO_TIMESTAMP($25::double precision)
END,
$25,
CASE
WHEN $26::double precision IS NULL THEN NULL
ELSE TO_TIMESTAMP($26::double precision)
END,
$27,
$28,
COALESCE($29, 0),
COALESCE($30, 0),
CASE
WHEN $31::double precision IS NULL THEN NULL
ELSE TO_TIMESTAMP($31::double precision)
WHEN $27::double precision IS NULL THEN NULL
ELSE TO_TIMESTAMP($27::double precision)
END,
$28,
$29,
COALESCE($30, 0),
COALESCE($31, 0),
CASE
WHEN $32::double precision IS NULL THEN NULL
ELSE TO_TIMESTAMP($32::double precision)
END,
$32,
$33,
$34,
$35,
CASE
WHEN $35::double precision IS NULL THEN NULL
ELSE TO_TIMESTAMP($35::double precision)
WHEN $36::double precision IS NULL THEN NULL
ELSE TO_TIMESTAMP($36::double precision)
END,
$36,
COALESCE($37, 0),
$37,
COALESCE($38, 0),
COALESCE($39, 0),
COALESCE($40, 0),
COALESCE($41, 0),
COALESCE($42, 0),
CASE
WHEN $43::double precision IS NULL THEN NULL
ELSE TO_TIMESTAMP($43::double precision)
END,
COALESCE($43, 0),
CASE
WHEN $44::double precision IS NULL THEN NULL
ELSE TO_TIMESTAMP($44::double precision)
END,
$45,
CASE
WHEN $45::double precision IS NULL THEN NULL
ELSE TO_TIMESTAMP($45::double precision)
END,
$46,
$47,
$48,
CASE
WHEN $49::double precision IS NULL THEN NOW()
ELSE TO_TIMESTAMP($49::double precision)
END,
$49,
CASE
WHEN $50::double precision IS NULL THEN NOW()
ELSE TO_TIMESTAMP($50::double precision)
END,
CASE
WHEN $51::double precision IS NULL THEN NOW()
ELSE TO_TIMESTAMP($51::double precision)
END
)
"#,
@@ -1311,6 +1317,7 @@ INSERT INTO provider_api_keys (
.bind(&key.id)
.bind(&key.provider_id)
.bind(&key.api_formats)
.bind(&key.auth_type_by_format)
.bind(&key.auth_type)
.bind(&key.encrypted_api_key)
.bind(&key.encrypted_auth_config)
@@ -1715,6 +1722,7 @@ UPDATE provider_api_keys
SET
provider_id = $2,
api_formats = $3,
auth_type_by_format = $39,
auth_type = $4,
api_key = $5,
auth_config = $6,
@@ -1809,6 +1817,7 @@ WHERE id = $1
.bind(key.is_active)
.bind(key.updated_at_unix_secs.map(|value| value as f64))
.bind(key.expires_at_unix_secs.map(|value| value as f64))
.bind(&key.auth_type_by_format)
.execute(&self.pool)
.await
.map_postgres_err()?
@@ -2437,7 +2446,7 @@ fn map_key_row(row: &PgRow) -> Result<StoredProviderCatalogKey, DataLayerError>
)?
.with_transport_fields(
row_get(row, "api_formats")?,
row_get(row, "api_key")?,
row_get::<Option<String>>(row, "api_key")?,
row_get(row, "auth_config")?,
row_get(row, "rate_multipliers")?,
row_get(row, "global_priority_by_format")?,
@@ -2469,6 +2478,7 @@ fn map_key_row(row: &PgRow) -> Result<StoredProviderCatalogKey, DataLayerError>
row.try_get("circuit_breaker_by_format").ok(),
);
key.note = row.try_get("note").ok();
key.auth_type_by_format = row.try_get("auth_type_by_format").ok();
key.internal_priority = row.try_get("internal_priority").unwrap_or(50);
key.cache_ttl_minutes = row.try_get("cache_ttl_minutes").unwrap_or(5);
key.max_probe_interval_minutes = row.try_get("max_probe_interval_minutes").unwrap_or(32);