Allow emergency model provider disable
This commit is contained in:
@@ -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/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user