Allow emergency model provider disable
Staging Backend Quality Gate / validate (push) Successful in 14m32s
Staging Backend Quality Gate / publish (push) Successful in 25m17s

This commit is contained in:
Jesse_Chen
2026-08-08 12:20:26 +08:00
parent 5fa1b8304e
commit a32d56677b
3 changed files with 130 additions and 9 deletions
@@ -8,11 +8,11 @@ type Dependencies={queryRows(sql:string,values?:readonly unknown[]):Promise<read
const json=(body:unknown,status=200)=>Response.json(body,{status});
const immutable=(e:unknown)=>e instanceof Error&&e.message.includes("model_provider_runtime_immutable");
async function versionProvider(d:Dependencies,where:string,values:readonly unknown[]){const rows=await d.queryRows(`select p.id,p.code,p.provider_type,p.base_url,p.encrypted_api_key,p.enabled,v.id version_id,v.enabled version_enabled from public.model_config_versions v join public.model_providers p on p.id=v.provider_id where ${where}`,values);return rows[0] as ProviderRow|undefined;}
async function probeAndRecord(p:ProviderRow,actor:string,rid:string,d:Dependencies){if(!p.enabled||!p.version_enabled||!p.encrypted_api_key)return{recorded:false,status:0,success:false};let key="";try{key=decryptModelProviderApiKey({encryptedApiKey:p.encrypted_api_key},d.environment);}catch{return{recorded:false,status:0,success:false};}if(p.provider_type==="openai-compatible")await d.assertAllowedUrl(p.base_url??"");let status=0;try{status=await d.probeAllowed(modelProviderModelsUrl({providerType:p.provider_type,baseUrl:p.base_url}),modelProviderRequestHeaders(p.provider_type,key));}catch{}await d.queryRows("select public.admin_record_model_connection_test($1,$2,$3,$4) id",[actor,p.version_id,status,rid]);return{recorded:true,status,success:modelConnectionTestSucceeded(status)};}
async function probeAndRecord(p:ProviderRow,actor:string,rid:string,d:Dependencies,allowDisabled=false){if((!allowDisabled&&(!p.enabled||!p.version_enabled))||!p.encrypted_api_key)return{recorded:false,status:0,success:false};let key="";try{key=decryptModelProviderApiKey({encryptedApiKey:p.encrypted_api_key},d.environment);}catch{return{recorded:false,status:0,success:false};}if(p.provider_type==="openai-compatible")await d.assertAllowedUrl(p.base_url??"");let status=0;try{status=await d.probeAllowed(modelProviderModelsUrl({providerType:p.provider_type,baseUrl:p.base_url}),modelProviderRequestHeaders(p.provider_type,key));}catch{}await d.queryRows("select public.admin_record_model_connection_test($1,$2,$3,$4) id",[actor,p.version_id,status,rid]);return{recorded:true,status,success:modelConnectionTestSucceeded(status)};}
export async function handleAdminModelMutation(a:AdminModelMutation,c:Readonly<{actorUserId:string;requestId:string}>,d:Dependencies){
if(a.action==="saveProvider"){if(a.providerType==="openai-compatible")await d.assertAllowedUrl(a.baseUrl??"");const key=a.apiKey?.trim();let encrypted:string|null=null;if(key)encrypted=encryptModelProviderApiKey(key,d.environment);else if(!a.id&&a.enabled)return json({error:"模型供应商密钥未配置"},409);try{const rows=await d.queryRows("select public.admin_save_model_provider($1,$2,$3,$4,$5,$6,$7,$8,$9) id",[c.actorUserId,a.id??null,a.name,a.providerType,a.providerType==="openai-compatible"?a.baseUrl??null:null,encrypted,a.enabled,a.reason,c.requestId]);return json({data:{id:rows[0]!.id,requestId:c.requestId}});}catch(e){if(immutable(e))return json({error:"已发布或已退役版本使用的供应商连接配置不可修改,请新建供应商和模型版本后重新测试并发布",code:"model_provider_runtime_immutable"},409);if(e instanceof Error&&e.message.includes("model_provider_code_immutable"))return json({error:"供应商代码不可修改",code:"model_provider_code_immutable"},409);throw e;}}
if(a.action==="saveProvider"){if(a.providerType==="openai-compatible")await d.assertAllowedUrl(a.baseUrl??"");const key=a.apiKey?.trim();let encrypted:string|null=null;if(key)encrypted=encryptModelProviderApiKey(key,d.environment);else if(!a.id&&a.enabled)return json({error:"模型供应商密钥未配置"},409);try{const rows=await d.queryRows("select public.admin_save_model_provider($1,$2,$3,$4,$5,$6,$7,$8,$9) id",[c.actorUserId,a.id??null,a.name,a.providerType,a.providerType==="openai-compatible"?a.baseUrl??null:null,encrypted,a.enabled,a.reason,c.requestId]);d.invalidateCatalog();return json({data:{id:rows[0]!.id,requestId:c.requestId}});}catch(e){if(immutable(e))return json({error:"已发布或已退役版本使用的供应商连接配置不可修改,请新建供应商和模型版本后重新测试并发布",code:"model_provider_runtime_immutable"},409);if(e instanceof Error&&e.message.includes("model_provider_code_immutable"))return json({error:"供应商代码不可修改",code:"model_provider_code_immutable"},409);if(e instanceof Error&&e.message.includes("model_provider_reenable_test_required"))return json({error:"重新启用前,请先在模型版本列表中测试此供应商的已发布或已退役版本",code:"model_provider_reenable_test_required"},409);throw e;}}
if(a.action==="saveDraft"){const rows=await d.queryRows("select public.admin_save_model_draft($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16::jsonb,$17,$18) id",[c.actorUserId,a.modelId,a.versionId??null,a.providerId,a.label,a.description,a.providerModel,a.modelTier,a.creditCost,a.contextWindow??null,a.inputCostMicrousdPerMillion,a.outputCostMicrousdPerMillion,a.enabled,a.isDefault,a.fallbackModelId??null,JSON.stringify(a.settings),a.reason,c.requestId]);return json({data:{id:rows[0]!.id,requestId:c.requestId}});}
if(a.action==="test"){const p=await versionProvider(d,"v.id=$1 and v.status in ('draft','published','retired')",[a.versionId]);if(!p)return json({error:"模型版本不存在"},404);const r=await probeAndRecord(p,c.actorUserId,c.requestId,d);if(!r.recorded)return json({error:"模型版本或供应商未启用,或数据库密钥不可用"},409);return json({data:{id:p.version_id,reachable:r.success,status:r.status,secretConfigured:true,requestId:c.requestId}},r.success?200:409);}
if(a.action==="test"){const p=await versionProvider(d,"v.id=$1 and v.status in ('draft','published','retired')",[a.versionId]);if(!p)return json({error:"模型版本不存在"},404);const r=await probeAndRecord(p,c.actorUserId,c.requestId,d,true);if(!r.recorded)return json({error:"数据库密钥不可用"},409);return json({data:{id:p.version_id,reachable:r.success,status:r.status,secretConfigured:true,requestId:c.requestId}},r.success?200:409);}
if(a.action==="publish"){const rows=await d.queryRows("select public.admin_publish_model($1,$2,$3,$4) id",[c.actorUserId,a.versionId,a.reason,c.requestId]);d.invalidateCatalog();return json({data:{id:rows[0]!.id,requestId:c.requestId}});}
const p=await versionProvider(d,"v.config_id=$1 and v.version=$2 and v.status='retired'",[a.configId,a.targetVersion]);if(!p)return json({error:"回滚版本不存在"},404);const r=await probeAndRecord(p,c.actorUserId,c.requestId,d);if(!r.recorded)return json({error:"回滚版本或供应商未启用,或数据库密钥不可用"},409);if(!r.success)return json({error:"回滚版本运行时连接测试失败",data:{status:r.status,requestId:c.requestId}},409);const rows=await d.queryRows("select public.admin_rollback_model($1,$2,$3,$4,$5) id",[c.actorUserId,a.configId,a.targetVersion,a.reason,c.requestId]);d.invalidateCatalog();return json({data:{id:rows[0]!.id,requestId:c.requestId}});
}
@@ -0,0 +1,83 @@
begin;
create or replace function public.model_version_config_hash(p_version_id uuid)
returns text language sql stable security definer set search_path=''
as $$
select encode(public.digest(convert_to(jsonb_build_object(
'versionId',v.id,
'configId',v.config_id,
'version',v.version,
'providerId',v.provider_id,
'label',v.label,
'description',v.description,
'providerModel',v.provider_model,
'modelTier',v.model_tier,
'creditCost',v.credit_cost,
'contextWindow',v.context_window,
'inputCost',v.input_cost_microusd_per_million,
'outputCost',v.output_cost_microusd_per_million,
'enabled',v.enabled,
'isDefault',v.is_default,
'fallbackModelId',v.fallback_model_id,
'settings',v.settings,
'providerCode',p.code,
'providerType',p.provider_type,
'providerBaseUrl',p.base_url,
'providerCredentialHash',case when p.encrypted_api_key is null then null else encode(public.digest(convert_to(p.encrypted_api_key,'utf8'),'sha256'),'hex') end
)::text,'utf8'),'sha256'),'hex')
from public.model_config_versions v
join public.model_providers p on p.id=v.provider_id
where v.id=p_version_id
$$;
create or replace function public.prevent_published_model_provider_runtime_mutation()
returns trigger language plpgsql set search_path=''
as $$
declare v_has_historical_version boolean;
begin
select exists(
select 1 from public.model_config_versions
where provider_id=old.id and status in ('published','retired')
) into v_has_historical_version;
if v_has_historical_version
and (new.code,new.provider_type,new.base_url)
is distinct from
(old.code,old.provider_type,old.base_url)
then
raise exception 'model_provider_runtime_immutable' using
errcode='23514',
hint='Create a new provider and model config version, then test and publish it.';
end if;
if v_has_historical_version and old.enabled and not new.enabled then
new.updated_at:=clock_timestamp();
end if;
if v_has_historical_version and not old.enabled and new.enabled
and (
new.encrypted_api_key is distinct from old.encrypted_api_key
or not exists(
select 1
from public.model_config_versions v
join public.model_connection_test_evidence e
on e.version_id=v.id
and e.provider_id=old.id
and e.config_hash=public.model_version_config_hash(v.id)
and e.http_status between 200 and 299
and e.expires_at>clock_timestamp()
and e.tested_at>old.updated_at
where v.provider_id=old.id
and v.status in ('published','retired')
)
)
then
raise exception 'model_provider_reenable_test_required' using
errcode='23514',
hint='Test a published or retired model version with the saved provider credentials after disabling the provider and before re-enabling it.';
end if;
return new;
end $$;
commit;
@@ -12,6 +12,7 @@ import { runMigrations } from "../scripts/db-migrate.mjs";
import { startPostgresFixture } from "./helpers/postgres-fixture.ts";
const migrationPath = fileURLToPath(new URL("../supabase/migrations/20260807160000_model_provider_encrypted_credentials.sql", import.meta.url));
const emergencyDisableMigrationPath = fileURLToPath(new URL("../supabase/migrations/20260808010000_model_provider_emergency_disable.sql", import.meta.url));
const handlerPath = fileURLToPath(new URL("../src/lib/admin/model-mutation-handler.ts", import.meta.url));
const routePath = fileURLToPath(new URL("../src/app/api/admin/models/route.ts", import.meta.url));
const catalogPath = fileURLToPath(new URL("../src/lib/model-catalog.ts", import.meta.url));
@@ -30,7 +31,7 @@ async function migrateModelConfigurationFixture(connectionString: string) {
const target = join(temporaryRoot, relative.replace("/", "-"));
mkdirSync(target, { recursive: true });
for (const filename of readdirSync(source)) {
if (!filename.endsWith(".sql") || filename > "20260807160000_model_provider_encrypted_credentials.sql") continue;
if (!filename.endsWith(".sql") || filename > "20260808010000_model_provider_emergency_disable.sql") continue;
if ([
"20260806030000_settle_order_usage_authorization.sql",
"20260806050000_operations_feature_flags.sql",
@@ -133,6 +134,7 @@ test("mutation handler preserves an omitted key on edit and requires a key for e
assert.equal(createQueries, 0);
const calls: Array<{ sql: string; values?: readonly unknown[] }> = [];
let invalidations = 0;
const edited = await handleAdminModelMutation({
action: "saveProvider",
id: "40000000-0000-4000-8000-000000000001",
@@ -148,10 +150,11 @@ test("mutation handler preserves an omitted key on edit and requires a key for e
},
assertAllowedUrl: async () => undefined,
probeAllowed: async () => 200,
invalidateCatalog: () => undefined,
invalidateCatalog: () => { invalidations += 1; },
environment,
});
assert.equal(edited.status, 200);
assert.equal(invalidations, 1);
assert.match(calls[0]!.sql, /admin_save_model_provider\(\$1,\$2,\$3,\$4,\$5,\$6,\$7,\$8,\$9\)/);
assert.equal(calls[0]!.values?.[5], null);
});
@@ -173,9 +176,9 @@ test("mutation handler decrypts only the database ciphertext and records failed
provider_type: "anthropic",
base_url: null,
encrypted_api_key: encrypted,
enabled: true,
enabled: false,
version_id: "30000000-0000-4000-8000-000000000001",
version_enabled: true,
version_enabled: false,
}];
}
return [{ id: "50000000-0000-4000-8000-000000000001" }];
@@ -257,10 +260,30 @@ test("database drops secret refs, generates immutable codes, and invalidates evi
assert.equal(sql(`select public.model_connection_test_is_fresh('${versionOne}',null)`), "f");
sql(`select public.admin_record_model_connection_test('${actorId}','${versionOne}',200,'publish-v1')`);
sql(`select public.admin_publish_model('${actorId}','${versionOne}','publish v1','publish-v1')`);
expectSqlError(
for (const mutation of [
`update public.model_providers set code='mutated' where id='${provider}'`,
/model_provider_runtime_immutable/,
`update public.model_providers set provider_type='anthropic' where id='${provider}'`,
`update public.model_providers set base_url='https://changed.example/v1' where id='${provider}'`,
]) {
expectSqlError(mutation, /model_provider_runtime_immutable/);
}
sql(`select public.admin_save_model_provider('${actorId}','${provider}','OpenAI Renamed','openai',null,null,false,'emergency disable','provider-disable')`);
assert.equal(sql(`select enabled::text from public.model_providers where id='${provider}'`), "f");
assert.equal(sql(`select public.model_connection_test_is_fresh('${versionOne}',null)`), "t");
assert.equal(sql(`select exists(select 1 from public.model_connection_test_evidence e join public.model_providers p on p.id=e.provider_id where e.provider_id='${provider}' and e.version_id='${versionOne}' and e.request_id='publish-v1' and e.tested_at<=p.updated_at)::text`), "true");
expectSqlError(
`select public.admin_save_model_provider('${actorId}','${provider}','OpenAI Renamed','openai',null,'untested-ciphertext',true,'unsafe key and enable','provider-reenable-new-key')`,
/model_provider_reenable_test_required/,
);
expectSqlError(
`select public.admin_save_model_provider('${actorId}','${provider}','OpenAI Renamed','openai',null,null,true,'unsafe re-enable with pre-disable evidence','provider-reenable-pre-disable-test')`,
/model_provider_reenable_test_required/,
);
sql(`select public.admin_record_model_connection_test('${actorId}','${versionOne}',200,'provider-reenable-test')`);
assert.equal(sql(`select exists(select 1 from public.model_connection_test_evidence e join public.model_providers p on p.id=e.provider_id where e.provider_id='${provider}' and e.version_id='${versionOne}' and e.request_id='provider-reenable-test' and e.tested_at>p.updated_at)::text`), "true");
sql(`select public.admin_save_model_provider('${actorId}','${provider}','OpenAI Renamed','openai',null,null,true,'tested re-enable','provider-reenable-tested')`);
assert.equal(sql(`select enabled::text from public.model_providers where id='${provider}'`), "true");
const providerTwo = conflictingProvider;
const versionTwo = sql(`select public.admin_save_model_draft('${actorId}','default-model',null,'${providerTwo}','Default v2','', 'model-v2','standard',1,64000,0,0,true,true,null,'{}','create v2','draft-v2')`);
@@ -306,3 +329,18 @@ test("forward migration removes secret-ref dependencies and hashes credential st
assert.match(migration, /models\.write/);
assert.match(migration, /audit\.admin_audit_logs/);
});
test("emergency-disable migration keeps runtime identity immutable and gates re-enable on fresh evidence", () => {
const migration = readFileSync(emergencyDisableMigrationPath, "utf8");
assert.match(migration, /\(new\.code,new\.provider_type,new\.base_url\)/);
assert.doesNotMatch(migration, /\(new\.code,new\.provider_type,new\.base_url,new\.enabled\)/);
assert.match(migration, /old\.enabled and not new\.enabled/);
assert.match(migration, /new\.updated_at:=clock_timestamp\(\)/);
assert.match(migration, /not old\.enabled and new\.enabled/);
assert.match(migration, /e\.tested_at>old\.updated_at/);
assert.match(migration, /e\.http_status between 200 and 299/);
assert.match(migration, /e\.config_hash=public\.model_version_config_hash\(v\.id\)/);
assert.match(migration, /new\.encrypted_api_key is distinct from old\.encrypted_api_key/);
assert.match(migration, /model_provider_reenable_test_required/);
});