feat: complete TASK-WP3-01 model configuration

This commit is contained in:
suyx
2026-08-03 02:40:48 +08:00
parent 1b0a05bbcf
commit d79ac74fdd
19 changed files with 7018 additions and 3 deletions
+142
View File
@@ -49,6 +49,17 @@ import {
LogoutResponseSchema,
ModelConfigSseEventSchema,
ModelRuntimeSseEventSchema,
ModelIdSchema,
ModelContractValidationStatusSchema,
ModelRuntimeReasonSchema,
ModelReferenceLimitsSchema,
ModelRuntimeAvailabilitySchema,
ModelConfigurationResponseSchema,
ModelConfigSchema,
ModelConfigCandidateSchema,
ModelConfigUpdateRequestSchema,
ModelParamsSchema,
ModelConfigUpdateHeadersSchema,
FailedEmptyTrashRequestSchema,
FailedEmptyTrashResponseSchema,
ExportFormatSchema,
@@ -151,6 +162,8 @@ import {
registrationFieldError,
} from "./registration-errors.js";
import type { RegistrationService } from "./registration.js";
import { ModelConfigurationError } from "./model-configuration.js";
import type { ModelConfigurationService } from "./model-configuration.js";
const defaultBootstrap: BootstrapResponse = {
app_version: "0.0.0",
@@ -173,6 +186,7 @@ export interface CreateAppOptions {
eventHub?: EventHub;
generations?: GenerationSubmissionService;
latestExports?: LatestExportService;
models?: ModelConfigurationService;
networkBoundary?: NetworkBoundaryOptions;
publicAssets?: PublicAssetResolver;
projects?: ProjectService;
@@ -316,6 +330,22 @@ function creditFailure(reply: FastifyReply, correlationId: string, error: unknow
return reply.code(status).send(null);
}
function modelConfigurationFailure(reply: FastifyReply, correlationId: string, error: unknown) {
if (!(error instanceof ModelConfigurationError)) {
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId }));
}
const status = error.code === "MODEL_CONFIG_VERSION_CONFLICT" ? 412
: error.code === "MODEL_RECOMMENDATION_PRIORITY_INVALID" ? 400
: error.code === "IDEMPOTENCY_KEY_CONFLICT" ? 409 : 409;
const details = {
...(error.code === "MODEL_CONFIG_VERSION_CONFLICT" && typeof error.details.latest_version === "number"
? { latest_version: error.details.latest_version } : {}),
...(Array.isArray(error.details.conflict_model_ids) ? { conflict_model_ids: error.details.conflict_model_ids as string[] } : {}),
...(Array.isArray(error.details.field_errors) ? { field_errors: error.details.field_errors as Array<{ field: string; message_key: string }> } : {}),
};
return reply.code(status).send(createErrorEnvelope({ code: error.code, correlationId, details }));
}
function generationTaskResponse(task: GenerationTaskView) {
return {
confirmed_credit_cost: task.confirmedCreditCost,
@@ -698,6 +728,17 @@ export async function createApp(options: CreateAppOptions = {}) {
ProjectStateConflictResponseSchema,
FailedEmptyTrashRequestSchema,
FailedEmptyTrashResponseSchema,
ModelIdSchema,
ModelContractValidationStatusSchema,
ModelRuntimeReasonSchema,
ModelReferenceLimitsSchema,
ModelRuntimeAvailabilitySchema,
ModelConfigSchema,
ModelConfigCandidateSchema,
ModelConfigurationResponseSchema,
ModelParamsSchema,
ModelConfigUpdateRequestSchema,
ModelConfigUpdateHeadersSchema,
]) {
app.addSchema(schema);
}
@@ -2163,6 +2204,107 @@ export async function createApp(options: CreateAppOptions = {}) {
},
);
app.get(
"/api/v1/models",
{
schema: {
operationId: "getModels",
response: { 200: ModelConfigurationResponseSchema, 503: ErrorEnvelopeSchema, 426: ErrorEnvelopeSchema },
tags: ["Models"],
},
},
async (request, reply) => {
if (!options.models) return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
try {
return options.models.read();
} catch {
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
}
},
);
app.get(
"/api/v1/models/:model_id",
{
schema: {
operationId: "getModel",
params: ModelParamsSchema,
response: { 200: ModelConfigSchema, 404: Type.Null(), 503: ErrorEnvelopeSchema, 426: ErrorEnvelopeSchema },
tags: ["Models"],
},
},
async (request, reply) => {
if (!options.models) return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
const model = options.models.readModel((request.params as { model_id: string }).model_id);
return model ?? reply.code(404).send(null);
},
);
app.put(
"/api/v1/admin/models/configuration",
{
attachValidation: true,
schema: {
operationId: "replaceModelConfiguration",
body: ModelConfigUpdateRequestSchema,
headers: ModelConfigUpdateHeadersSchema,
response: {
200: ModelConfigurationResponseSchema,
400: ErrorEnvelopeSchema,
401: ErrorEnvelopeSchema,
403: ErrorEnvelopeSchema,
409: ErrorEnvelopeSchema,
412: ErrorEnvelopeSchema,
429: ErrorEnvelopeSchema,
503: ErrorEnvelopeSchema,
426: ErrorEnvelopeSchema,
},
tags: ["Admin Models"],
},
},
async (request, reply) => {
if (request.validationError) {
const validation = JSON.stringify(request.validationError.validation ?? []);
const priority = validation.includes("recommendation_priority");
return reply.code(priority ? 400 : 409).send(createErrorEnvelope({
code: priority ? "MODEL_RECOMMENDATION_PRIORITY_INVALID" : "MODEL_DEFAULT_REPLACEMENT_INVALID",
correlationId: request.id,
...(priority ? { details: { field_errors: [{ field: "models.recommendation_priority", message_key: "model.priority.invalid" }] } } : {}),
}));
}
if (!options.models || !options.registration) {
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
}
const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName);
const session = token ? options.registration.readAdminSession(token) : undefined;
if (!token || !session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
try {
const headers = request.headers as { "idempotency-key": string; "x-csrf-token": string };
const admin = options.registration.authorizeAdminMutation({ csrfToken: headers["x-csrf-token"], sessionToken: token });
const body = request.body as {
expected_config_set_version: number;
models: Array<Record<string, unknown>>;
};
const result = options.models.replace({
actorId: admin.userId,
expectedConfigSetVersion: body.expected_config_set_version,
idempotencyKey: headers["idempotency-key"],
models: body.models as never,
});
return result;
} catch (error) {
if (error instanceof RegistrationError) {
return reply.code(error.httpStatus).send(createErrorEnvelope({
code: error.code,
correlationId: request.id,
details: { field_errors: [registrationFieldError(error.reason)] },
}));
}
return modelConfigurationFailure(reply, request.id, error);
}
},
);
app.get(
"/api/v1/bootstrap",
{
+4
View File
@@ -15,6 +15,7 @@ import { MockResendAdapter } from "./resend-adapter.js";
import { readSecureConfigCandidate } from "./secure-config.js";
import { StructuredJsonlLogger } from "./structured-log.js";
import { attachApiSupervisorControl, initializeApiCredentialClients, receiveApiCredentials } from "./supervisor-channel.js";
import { ModelConfigurationService } from "./model-configuration.js";
const credentialChannelEnabled = process.argv.includes("--dada-credential-stdin");
let registration: RegistrationService | undefined;
@@ -22,6 +23,7 @@ let projects: ProjectService | undefined;
let credits: CreditService | undefined;
let storage: ManagedStorage | undefined;
let latestExports: LatestExportService | undefined;
let models: ModelConfigurationService | undefined;
const instanceConfigPath = process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultInstanceConfigPath();
if (credentialChannelEnabled) {
const clients = initializeApiCredentialClients(await receiveApiCredentials());
@@ -44,6 +46,7 @@ if (credentialChannelEnabled) {
credits = new CreditService({ databasePath });
storage = new ManagedStorage({ dataRoot, databasePath });
latestExports = new LatestExportService({ databasePath, storage });
models = new ModelConfigurationService({ database: registration.database });
registration.applySecureConfig(readSecureConfigCandidate(instanceConfigPath));
} catch (error) {
latestExports?.close();
@@ -67,6 +70,7 @@ const app = await createApp({
...(browserSupportRelease ? { browserSupportRelease } : {}),
...(credits ? { credits } : {}),
...(latestExports ? { latestExports } : {}),
...(models ? { models } : {}),
...(projects ? { projects } : {}),
...(registration ? { registration } : {}),
});
+541
View File
@@ -0,0 +1,541 @@
import { randomUUID, createHash } from "node:crypto";
import type BetterSqlite3 from "better-sqlite3";
import { serializeAuditSummary, auditRetentionMilliseconds } from "./audit-policy.js";
export const modelIds = [
"gemini-3.1-flash-image-preview",
"gemini-3-pro-image-preview",
"gpt-image-2",
] as const;
export type ModelId = typeof modelIds[number];
export type ContractValidationStatus = "blocked" | "unverified" | "verified";
export type ModelRuntimeReason =
| "available"
| "configured_disabled"
| "contract_unverified"
| "contract_blocked"
| "gateway_balance_insufficient"
| "gateway_paused"
| "worker_degraded";
export interface ModelConfigCandidate {
model_id: string;
display_name: string;
enabled: boolean;
is_default: boolean;
recommendation_priority: number;
route_profile: Record<string, unknown>;
gateway_account_ref: string;
error_mapping_profile: Record<string, string>;
credit_cost: number;
supported_ratios: string[];
reference_limits: { max_file_bytes: number; max_files: number; max_total_bytes: number };
prompt_max_length: number;
safety_source: string;
contract_validation_status?: ContractValidationStatus;
contract_evidence_ref?: string | null;
config_version?: number;
}
export interface ModelRuntimeAvailability {
available_for_new_jobs: boolean;
reason: ModelRuntimeReason;
checked_at: string;
}
export interface ModelConfigView extends ModelConfigCandidate {
config_version: number;
contract_validation_status: ContractValidationStatus;
contract_evidence_ref: string | null;
runtime_availability: ModelRuntimeAvailability;
}
export interface ModelConfigurationView {
config_set_version: number;
configured_default_model_id: ModelId;
recommended_model_id: null;
models: ModelConfigView[];
}
export class ModelConfigurationError extends Error {
constructor(
readonly code:
| "MODEL_CONFIG_VERSION_CONFLICT"
| "MODEL_DEFAULT_REPLACEMENT_REQUIRED"
| "MODEL_DEFAULT_REPLACEMENT_INVALID"
| "MODEL_RECOMMENDATION_PRIORITY_INVALID"
| "MODEL_RECOMMENDATION_PRIORITY_CONFLICT"
| "IDEMPOTENCY_KEY_CONFLICT",
readonly details: Record<string, unknown> = {},
) {
super(code);
this.name = "ModelConfigurationError";
}
}
interface StoredVersion {
config_version: number;
contract_evidence_ref: string | null;
contract_validation_status: ContractValidationStatus;
credit_cost: number;
display_name: string;
error_mapping_profile_json: string;
gateway_account_ref: string;
model_id: ModelId;
prompt_max_length: number;
reference_limits_json: string;
route_profile_json: string;
safety_source: string;
supported_ratios_json: string;
}
interface StoredMember {
config_set_id: string;
config_version: number;
enabled: 0 | 1;
is_default: 0 | 1;
model_id: ModelId;
recommendation_priority: number;
}
const defaultErrorMapping: Record<string, string> = {
gateway_contract_invalid: "gateway_contract_invalid",
gateway_balance_insufficient: "gateway_balance_insufficient",
reference_invalid: "reference_invalid",
safety_rejected: "safety_rejected",
unknown_non_retryable: "unknown_non_retryable",
unknown_retryable: "unknown_retryable",
upstream_failed: "upstream_failed",
upstream_timeout: "upstream_timeout",
};
const seedCandidates: ModelConfigCandidate[] = [
{
model_id: modelIds[0], display_name: "Gemini 3.1 Flash Image Preview", enabled: true, is_default: true,
recommendation_priority: 1, route_profile: { endpoint: "https://mock.invalid/v1/images", mode: "sync" },
gateway_account_ref: "mock-gateway", error_mapping_profile: defaultErrorMapping, credit_cost: 1,
supported_ratios: ["3:4", "1:1", "4:3", "9:16"],
reference_limits: { max_file_bytes: 10_485_760, max_files: 2, max_total_bytes: 20_971_520 },
prompt_max_length: 1_000, safety_source: "provider", contract_validation_status: "unverified", contract_evidence_ref: null,
},
{
model_id: modelIds[1], display_name: "Gemini 3 Pro Image Preview", enabled: true, is_default: false,
recommendation_priority: 2, route_profile: { endpoint: "https://mock.invalid/v1/images", mode: "sync" },
gateway_account_ref: "mock-gateway", error_mapping_profile: defaultErrorMapping, credit_cost: 1,
supported_ratios: ["3:4", "1:1", "4:3", "9:16"],
reference_limits: { max_file_bytes: 10_485_760, max_files: 2, max_total_bytes: 20_971_520 },
prompt_max_length: 1_000, safety_source: "provider", contract_validation_status: "unverified", contract_evidence_ref: null,
},
{
model_id: modelIds[2], display_name: "GPT Image 2", enabled: true, is_default: false,
recommendation_priority: 3, route_profile: { endpoint: "https://mock.invalid/v1/images", mode: "sync" },
gateway_account_ref: "mock-gateway", error_mapping_profile: defaultErrorMapping, credit_cost: 1,
supported_ratios: ["3:4", "1:1", "4:3", "9:16"],
reference_limits: { max_file_bytes: 10_485_760, max_files: 2, max_total_bytes: 20_971_520 },
prompt_max_length: 1_000, safety_source: "provider", contract_validation_status: "unverified", contract_evidence_ref: null,
},
];
function stableJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
if (value && typeof value === "object") {
return `{${Object.entries(value).sort(([left], [right]) => left.localeCompare(right))
.map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`).join(",")}}`;
}
return JSON.stringify(value);
}
function fingerprint(candidate: ModelConfigCandidate) {
return createHash("sha256").update(stableJson({
error_mapping_profile: candidate.error_mapping_profile,
gateway_account_ref: candidate.gateway_account_ref,
prompt_max_length: candidate.prompt_max_length,
reference_limits: candidate.reference_limits,
route_profile: candidate.route_profile,
safety_source: candidate.safety_source,
supported_ratios: candidate.supported_ratios,
})).digest("hex");
}
function profileRef(prefix: "error" | "route", value: Record<string, unknown>) {
return `${prefix}:${createHash("sha256").update(stableJson(value)).digest("hex")}`;
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
export function validateModelConfigurationCandidateSet(input: ModelConfigCandidate[]) {
if (input.length !== modelIds.length || new Set(input.map((item) => item.model_id)).size !== modelIds.length
|| input.some((item) => !modelIds.includes(item.model_id as ModelId))) {
throw new ModelConfigurationError("MODEL_DEFAULT_REPLACEMENT_INVALID", { reason: "fixed_model_set" });
}
for (const item of input) {
if (!Number.isSafeInteger(item.recommendation_priority) || item.recommendation_priority <= 0) {
throw new ModelConfigurationError("MODEL_RECOMMENDATION_PRIORITY_INVALID", {
field_errors: [{ field: `models.${item.model_id}.recommendation_priority`, message_key: "model.priority.invalid" }],
});
}
}
const byPriority = new Map<number, ModelConfigCandidate[]>();
for (const item of input) byPriority.set(item.recommendation_priority, [...(byPriority.get(item.recommendation_priority) ?? []), item]);
const duplicate = [...byPriority.values()].find((items) => items.length > 1);
if (duplicate) {
throw new ModelConfigurationError("MODEL_RECOMMENDATION_PRIORITY_CONFLICT", {
conflict_model_ids: duplicate.map((item) => item.model_id),
});
}
if (input.some((item) => !isPlainRecord(item.route_profile) || !isPlainRecord(item.error_mapping_profile))) {
throw new ModelConfigurationError("MODEL_DEFAULT_REPLACEMENT_INVALID", { reason: "contract_fields_invalid" });
}
}
function runtimeFor(candidate: ModelConfigCandidate, previous: ModelRuntimeAvailability | undefined, contractChanged: boolean, now: number): ModelRuntimeAvailability {
if (!candidate.enabled) return { available_for_new_jobs: false, checked_at: new Date(now).toISOString(), reason: "configured_disabled" };
if (candidate.contract_validation_status === "blocked" && !contractChanged) {
return { available_for_new_jobs: false, checked_at: new Date(now).toISOString(), reason: "contract_blocked" };
}
if (candidate.contract_validation_status !== "verified" || contractChanged) {
return { available_for_new_jobs: false, checked_at: new Date(now).toISOString(), reason: "contract_unverified" };
}
return previous ?? { available_for_new_jobs: false, checked_at: new Date(now).toISOString(), reason: "contract_unverified" };
}
export interface ModelConfigurationServiceOptions {
clock?: () => number;
database: BetterSqlite3.Database;
onChanged?: (configSetVersion: number) => void;
}
export class ModelConfigurationService {
readonly database: BetterSqlite3.Database;
readonly #clock: () => number;
readonly #onChanged: ((configSetVersion: number) => void) | undefined;
constructor(options: ModelConfigurationServiceOptions) {
this.database = options.database;
this.#clock = options.clock ?? Date.now;
this.#onChanged = options.onChanged;
this.ensureSchema();
}
read(): ModelConfigurationView {
const current = this.database.prepare(`
SELECT c.config_set_id, c.config_set_version FROM model_config_current mc
JOIN model_config_sets c ON c.config_set_id = mc.config_set_id WHERE mc.singleton = 1
`).get() as { config_set_id: string; config_set_version: number } | undefined;
if (!current) throw new Error("model_config_current_missing");
const rows = this.database.prepare(`
SELECT m.model_id, m.enabled, m.is_default, m.recommendation_priority, v.*,
r.available_for_new_jobs, r.reason, r.checked_at
FROM model_config_set_members m
JOIN model_config_versions v ON v.model_id = m.model_id AND v.config_version = m.config_version
JOIN model_runtime_availability r ON r.model_id = m.model_id
WHERE m.config_set_id = ? ORDER BY m.recommendation_priority
`).all(current.config_set_id) as Array<StoredMember & StoredVersion & { available_for_new_jobs: 0 | 1; reason: ModelRuntimeReason; checked_at: number }>;
const models = rows.map((row) => ({
model_id: row.model_id,
display_name: row.display_name,
config_version: row.config_version,
enabled: row.enabled === 1,
is_default: row.is_default === 1,
recommendation_priority: row.recommendation_priority,
route_profile: JSON.parse(row.route_profile_json) as Record<string, unknown>,
gateway_account_ref: row.gateway_account_ref,
error_mapping_profile: JSON.parse(row.error_mapping_profile_json) as Record<string, string>,
credit_cost: row.credit_cost,
supported_ratios: JSON.parse(row.supported_ratios_json) as string[],
reference_limits: JSON.parse(row.reference_limits_json) as ModelConfigCandidate["reference_limits"],
prompt_max_length: row.prompt_max_length,
safety_source: row.safety_source,
contract_validation_status: row.contract_validation_status,
contract_evidence_ref: row.contract_evidence_ref,
runtime_availability: {
available_for_new_jobs: row.available_for_new_jobs === 1,
checked_at: new Date(row.checked_at).toISOString(),
reason: row.reason,
},
} satisfies ModelConfigView));
return {
config_set_version: current.config_set_version,
configured_default_model_id: models.find((model) => model.enabled && model.is_default)!.model_id as ModelId,
recommended_model_id: null,
models,
};
}
readModel(modelId: string) {
return this.read().models.find((model) => model.model_id === modelId);
}
replace(input: { actorId: string; expectedConfigSetVersion: number; idempotencyKey: string; models: ModelConfigCandidate[] }) {
const requestHash = createHash("sha256").update(JSON.stringify({ expected: input.expectedConfigSetVersion, models: input.models })).digest("hex");
const now = this.#clock();
const result = this.database.transaction(() => {
const existing = this.database.prepare("SELECT request_hash, response_json FROM model_config_idempotency WHERE idempotency_key = ?")
.get(input.idempotencyKey) as { request_hash: string; response_json: string } | undefined;
if (existing) {
if (existing.request_hash !== requestHash) throw new ModelConfigurationError("IDEMPOTENCY_KEY_CONFLICT");
return { replayed: true, value: JSON.parse(existing.response_json) as ModelConfigurationView };
}
validateModelConfigurationCandidateSet(input.models);
const current = this.database.prepare(`
SELECT c.config_set_id, c.config_set_version FROM model_config_current mc
JOIN model_config_sets c ON c.config_set_id = mc.config_set_id WHERE mc.singleton = 1
`).get() as { config_set_id: string; config_set_version: number };
if (input.expectedConfigSetVersion !== current.config_set_version) {
throw new ModelConfigurationError("MODEL_CONFIG_VERSION_CONFLICT", { latest_version: current.config_set_version });
}
const previous = this.read();
const oldDefault = previous.models.find((model) => model.is_default && model.enabled);
const declaredDefaults = input.models.filter((model) => model.is_default);
const nextDefaults = declaredDefaults.filter((model) => model.enabled);
const nextDefault = nextDefaults[0];
if (oldDefault && !input.models.find((model) => model.model_id === oldDefault.model_id)?.enabled) {
if (declaredDefaults.length === 0) throw new ModelConfigurationError("MODEL_DEFAULT_REPLACEMENT_REQUIRED");
if (!nextDefault) throw new ModelConfigurationError("MODEL_DEFAULT_REPLACEMENT_INVALID");
if (nextDefault.model_id === oldDefault.model_id) throw new ModelConfigurationError("MODEL_DEFAULT_REPLACEMENT_INVALID");
}
if (nextDefaults.length !== 1) throw new ModelConfigurationError("MODEL_DEFAULT_REPLACEMENT_INVALID", { reason: "enabled_default_count" });
const setId = randomUUID();
const nextSetVersion = current.config_set_version + 1;
this.database.prepare("INSERT INTO model_config_sets (config_set_id, config_set_version, created_at, created_by) VALUES (?, ?, ?, ?)")
.run(setId, nextSetVersion, now, input.actorId);
const insertVersion = this.database.prepare(`
INSERT INTO model_config_versions (
model_id, config_version, display_name, enabled, is_default, recommendation_priority,
route_profile_id, route_profile_json, gateway_account_ref, error_mapping_profile_id, error_mapping_profile_json,
credit_cost, supported_ratios_json, reference_limits_json, prompt_max_length, safety_source,
contract_validation_status, contract_evidence_ref, contract_fingerprint, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const insertMember = this.database.prepare(`
INSERT INTO model_config_set_members (config_set_id, model_id, config_version, enabled, is_default, recommendation_priority)
VALUES (?, ?, ?, ?, ?, ?)
`);
for (const candidate of input.models) {
const old = previous.models.find((model) => model.model_id === candidate.model_id);
const changed = !old || fingerprint(candidate) !== fingerprint(old) || candidate.display_name !== old.display_name
|| candidate.credit_cost !== old.credit_cost || candidate.enabled !== old.enabled
|| candidate.is_default !== old.is_default || candidate.recommendation_priority !== old.recommendation_priority
|| (candidate.contract_validation_status !== undefined && candidate.contract_validation_status !== old.contract_validation_status);
const contractChanged = Boolean(old && fingerprint(candidate) !== fingerprint(old));
const configVersion = changed ? (old?.config_version ?? 0) + 1 : old!.config_version;
const status: ContractValidationStatus = contractChanged
? "unverified"
: candidate.contract_validation_status ?? old?.contract_validation_status ?? "unverified";
const evidence = status === "unverified" ? null : candidate.contract_evidence_ref ?? old?.contract_evidence_ref ?? null;
if (changed) {
const routeProfileId = profileRef("route", candidate.route_profile);
const errorMappingProfileId = profileRef("error", candidate.error_mapping_profile);
this.database.prepare("INSERT OR IGNORE INTO gateway_route_profiles (route_profile_id, profile_json, created_at) VALUES (?, ?, ?)")
.run(routeProfileId, stableJson(candidate.route_profile), now);
this.database.prepare("INSERT OR IGNORE INTO error_mapping_profiles (error_mapping_profile_id, profile_json, created_at) VALUES (?, ?, ?)")
.run(errorMappingProfileId, stableJson(candidate.error_mapping_profile), now);
insertVersion.run(
candidate.model_id, configVersion, candidate.display_name, candidate.enabled ? 1 : 0, candidate.is_default ? 1 : 0,
candidate.recommendation_priority, routeProfileId, stableJson(candidate.route_profile), candidate.gateway_account_ref,
errorMappingProfileId, stableJson(candidate.error_mapping_profile), candidate.credit_cost, stableJson(candidate.supported_ratios),
stableJson(candidate.reference_limits), candidate.prompt_max_length, candidate.safety_source, status, evidence, fingerprint(candidate), now,
);
}
insertMember.run(setId, candidate.model_id, configVersion, candidate.enabled ? 1 : 0, candidate.is_default ? 1 : 0, candidate.recommendation_priority);
const runtime = runtimeFor({ ...candidate, contract_validation_status: status }, old?.runtime_availability, contractChanged, now);
this.database.prepare(`
INSERT INTO model_runtime_availability (model_id, available_for_new_jobs, reason, checked_at, runtime_availability_version)
VALUES (?, ?, ?, ?, (SELECT COALESCE(MAX(runtime_availability_version), 0) + 1 FROM model_runtime_availability))
ON CONFLICT(model_id) DO UPDATE SET available_for_new_jobs=excluded.available_for_new_jobs, reason=excluded.reason,
checked_at=excluded.checked_at, runtime_availability_version=excluded.runtime_availability_version
`).run(candidate.model_id, runtime.available_for_new_jobs ? 1 : 0, runtime.reason, now);
}
this.database.prepare("UPDATE model_config_current SET config_set_id = ? WHERE singleton = 1").run(setId);
const after = this.read();
const occurredAt = now;
this.database.prepare(`
INSERT INTO admin_operation_logs (
log_id, actor_type, actor_ref, operation_type, target_type, target_ref, result,
before_summary, after_summary, occurred_at, expires_at
) VALUES (?, 'super_admin', ?, 'model_configuration_replace', 'model_config_set', ?, 'succeeded', ?, ?, ?, ?)
`).run(
randomUUID(), input.actorId, setId,
serializeAuditSummary({ config_set_version: current.config_set_version }),
serializeAuditSummary({ config_set_version: nextSetVersion, model_count: after.models.length }), occurredAt, occurredAt + auditRetentionMilliseconds,
);
this.database.prepare(`
INSERT INTO outbox_events (event_id, operation_key, topic, aggregate_type, aggregate_id, payload_json, status, created_at, published_at)
VALUES (?, ?, 'model_config_changed', 'model_config_set', ?, ?, 'pending', ?, NULL)
`).run(randomUUID(), `model-config:${input.idempotencyKey}`, setId, JSON.stringify({ config_set_version: nextSetVersion }), now);
this.database.prepare("INSERT INTO model_config_idempotency (idempotency_key, request_hash, response_json, created_at) VALUES (?, ?, ?, ?)")
.run(input.idempotencyKey, requestHash, JSON.stringify(after), now);
return { replayed: false, value: after };
}).immediate();
if (!result.replayed) this.#onChanged?.(result.value.config_set_version);
return result.value;
}
private ensureSchema() {
this.database.exec(`
CREATE TABLE IF NOT EXISTS gateway_route_profiles (
route_profile_id TEXT PRIMARY KEY,
profile_json TEXT NOT NULL CHECK (json_valid(profile_json)),
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS error_mapping_profiles (
error_mapping_profile_id TEXT PRIMARY KEY,
profile_json TEXT NOT NULL CHECK (json_valid(profile_json)),
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS model_config_sets (
config_set_id TEXT PRIMARY KEY,
config_set_version INTEGER NOT NULL UNIQUE CHECK (config_set_version > 0),
created_at INTEGER NOT NULL,
created_by TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS model_config_versions (
model_id TEXT NOT NULL CHECK (model_id IN ('gemini-3.1-flash-image-preview', 'gemini-3-pro-image-preview', 'gpt-image-2')),
config_version INTEGER NOT NULL CHECK (config_version > 0),
display_name TEXT NOT NULL,
enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)),
is_default INTEGER NOT NULL CHECK (is_default IN (0, 1)),
recommendation_priority INTEGER NOT NULL CHECK (recommendation_priority > 0),
route_profile_id TEXT NOT NULL REFERENCES gateway_route_profiles(route_profile_id),
route_profile_json TEXT NOT NULL CHECK (json_valid(route_profile_json)),
gateway_account_ref TEXT NOT NULL,
error_mapping_profile_id TEXT NOT NULL REFERENCES error_mapping_profiles(error_mapping_profile_id),
error_mapping_profile_json TEXT NOT NULL CHECK (json_valid(error_mapping_profile_json)),
credit_cost INTEGER NOT NULL CHECK (credit_cost > 0),
supported_ratios_json TEXT NOT NULL CHECK (json_valid(supported_ratios_json)),
reference_limits_json TEXT NOT NULL CHECK (json_valid(reference_limits_json)),
prompt_max_length INTEGER NOT NULL CHECK (prompt_max_length > 0),
safety_source TEXT NOT NULL,
contract_validation_status TEXT NOT NULL CHECK (contract_validation_status IN ('blocked', 'unverified', 'verified')),
contract_evidence_ref TEXT,
contract_fingerprint TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (model_id, config_version)
);
CREATE TABLE IF NOT EXISTS model_config_set_members (
config_set_id TEXT NOT NULL REFERENCES model_config_sets(config_set_id),
model_id TEXT NOT NULL CHECK (model_id IN ('gemini-3.1-flash-image-preview', 'gemini-3-pro-image-preview', 'gpt-image-2')),
config_version INTEGER NOT NULL,
enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)),
is_default INTEGER NOT NULL CHECK (is_default IN (0, 1)),
recommendation_priority INTEGER NOT NULL CHECK (recommendation_priority > 0),
PRIMARY KEY (config_set_id, model_id),
FOREIGN KEY (model_id, config_version) REFERENCES model_config_versions(model_id, config_version)
);
CREATE UNIQUE INDEX IF NOT EXISTS model_config_set_priority_unique
ON model_config_set_members(config_set_id, recommendation_priority);
CREATE UNIQUE INDEX IF NOT EXISTS model_config_set_enabled_default_unique
ON model_config_set_members(config_set_id) WHERE enabled = 1 AND is_default = 1;
CREATE TABLE IF NOT EXISTS model_config_current (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
config_set_id TEXT NOT NULL REFERENCES model_config_sets(config_set_id)
);
CREATE TABLE IF NOT EXISTS model_runtime_availability (
model_id TEXT PRIMARY KEY,
available_for_new_jobs INTEGER NOT NULL CHECK (available_for_new_jobs IN (0, 1)),
reason TEXT NOT NULL CHECK (reason IN ('available', 'configured_disabled', 'contract_unverified', 'contract_blocked', 'gateway_balance_insufficient', 'gateway_paused', 'worker_degraded')),
checked_at INTEGER NOT NULL,
runtime_availability_version INTEGER NOT NULL CHECK (runtime_availability_version >= 0)
);
CREATE TABLE IF NOT EXISTS model_config_idempotency (
idempotency_key TEXT PRIMARY KEY,
request_hash TEXT NOT NULL,
response_json TEXT NOT NULL CHECK (json_valid(response_json)),
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS outbox_events (
event_id TEXT PRIMARY KEY,
operation_key TEXT NOT NULL UNIQUE,
topic TEXT NOT NULL,
aggregate_type TEXT NOT NULL,
aggregate_id TEXT NOT NULL,
payload_json TEXT NOT NULL CHECK (json_valid(payload_json)),
status TEXT NOT NULL,
created_at INTEGER NOT NULL,
published_at INTEGER
);
`);
this.database.exec(`
DROP TRIGGER IF EXISTS model_config_current_default_guard_insert;
DROP TRIGGER IF EXISTS model_config_current_default_guard_update;
CREATE TRIGGER model_config_current_default_guard_insert
BEFORE INSERT ON model_config_current
WHEN (SELECT COUNT(*) FROM model_config_set_members WHERE config_set_id = NEW.config_set_id AND enabled = 1 AND is_default = 1) <> 1
OR (SELECT COUNT(*) FROM model_config_set_members WHERE config_set_id = NEW.config_set_id) <> 3
OR (SELECT COUNT(*) FROM model_config_set_members WHERE config_set_id = NEW.config_set_id
AND model_id IN ('gemini-3.1-flash-image-preview', 'gemini-3-pro-image-preview', 'gpt-image-2')) <> 3
BEGIN SELECT RAISE(ABORT, 'model_config_default_invariant'); END;
CREATE TRIGGER model_config_current_default_guard_update
BEFORE UPDATE OF config_set_id ON model_config_current
WHEN (SELECT COUNT(*) FROM model_config_set_members WHERE config_set_id = NEW.config_set_id AND enabled = 1 AND is_default = 1) <> 1
OR (SELECT COUNT(*) FROM model_config_set_members WHERE config_set_id = NEW.config_set_id) <> 3
OR (SELECT COUNT(*) FROM model_config_set_members WHERE config_set_id = NEW.config_set_id
AND model_id IN ('gemini-3.1-flash-image-preview', 'gemini-3-pro-image-preview', 'gpt-image-2')) <> 3
BEGIN SELECT RAISE(ABORT, 'model_config_default_invariant'); END;
CREATE TRIGGER IF NOT EXISTS model_config_sets_no_update BEFORE UPDATE ON model_config_sets
BEGIN SELECT RAISE(ABORT, 'model_config_sets_immutable'); END;
CREATE TRIGGER IF NOT EXISTS model_config_sets_no_delete BEFORE DELETE ON model_config_sets
BEGIN SELECT RAISE(ABORT, 'model_config_sets_immutable'); END;
CREATE TRIGGER IF NOT EXISTS model_config_versions_no_update BEFORE UPDATE ON model_config_versions
BEGIN SELECT RAISE(ABORT, 'model_config_versions_immutable'); END;
CREATE TRIGGER IF NOT EXISTS model_config_versions_no_delete BEFORE DELETE ON model_config_versions
BEGIN SELECT RAISE(ABORT, 'model_config_versions_immutable'); END;
CREATE TRIGGER IF NOT EXISTS model_config_members_no_update BEFORE UPDATE ON model_config_set_members
BEGIN SELECT RAISE(ABORT, 'model_config_members_immutable'); END;
CREATE TRIGGER IF NOT EXISTS model_config_members_no_delete BEFORE DELETE ON model_config_set_members
BEGIN SELECT RAISE(ABORT, 'model_config_members_immutable'); END;
CREATE TRIGGER IF NOT EXISTS gateway_route_profiles_no_update BEFORE UPDATE ON gateway_route_profiles
BEGIN SELECT RAISE(ABORT, 'gateway_route_profiles_immutable'); END;
CREATE TRIGGER IF NOT EXISTS gateway_route_profiles_no_delete BEFORE DELETE ON gateway_route_profiles
BEGIN SELECT RAISE(ABORT, 'gateway_route_profiles_immutable'); END;
CREATE TRIGGER IF NOT EXISTS error_mapping_profiles_no_update BEFORE UPDATE ON error_mapping_profiles
BEGIN SELECT RAISE(ABORT, 'error_mapping_profiles_immutable'); END;
CREATE TRIGGER IF NOT EXISTS error_mapping_profiles_no_delete BEFORE DELETE ON error_mapping_profiles
BEGIN SELECT RAISE(ABORT, 'error_mapping_profiles_immutable'); END;
`);
const current = this.database.prepare("SELECT config_set_id FROM model_config_current WHERE singleton = 1").get() as { config_set_id: string } | undefined;
if (current) return;
const seed = this.database.transaction(() => {
validateModelConfigurationCandidateSet(seedCandidates);
const now = this.#clock();
const setId = randomUUID();
this.database.prepare("INSERT INTO model_config_sets (config_set_id, config_set_version, created_at, created_by) VALUES (?, 1, ?, 'system_seed')")
.run(setId, now);
const insertVersion = this.database.prepare(`
INSERT INTO model_config_versions (
model_id, config_version, display_name, enabled, is_default, recommendation_priority,
route_profile_id, route_profile_json, gateway_account_ref, error_mapping_profile_id, error_mapping_profile_json,
credit_cost, supported_ratios_json, reference_limits_json, prompt_max_length, safety_source,
contract_validation_status, contract_evidence_ref, contract_fingerprint, created_at
) VALUES (?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const insertMember = this.database.prepare(`
INSERT INTO model_config_set_members (config_set_id, model_id, config_version, enabled, is_default, recommendation_priority)
VALUES (?, ?, 1, ?, ?, ?)
`);
for (const candidate of seedCandidates) {
const routeProfileId = profileRef("route", candidate.route_profile);
const errorMappingProfileId = profileRef("error", candidate.error_mapping_profile);
this.database.prepare("INSERT OR IGNORE INTO gateway_route_profiles (route_profile_id, profile_json, created_at) VALUES (?, ?, ?)")
.run(routeProfileId, stableJson(candidate.route_profile), now);
this.database.prepare("INSERT OR IGNORE INTO error_mapping_profiles (error_mapping_profile_id, profile_json, created_at) VALUES (?, ?, ?)")
.run(errorMappingProfileId, stableJson(candidate.error_mapping_profile), now);
insertVersion.run(candidate.model_id, candidate.display_name, candidate.enabled ? 1 : 0, candidate.is_default ? 1 : 0,
candidate.recommendation_priority, routeProfileId, stableJson(candidate.route_profile), candidate.gateway_account_ref,
errorMappingProfileId, stableJson(candidate.error_mapping_profile), candidate.credit_cost, stableJson(candidate.supported_ratios), stableJson(candidate.reference_limits),
candidate.prompt_max_length, candidate.safety_source, "unverified", null, fingerprint(candidate), now);
insertMember.run(setId, candidate.model_id, candidate.enabled ? 1 : 0, candidate.is_default ? 1 : 0, candidate.recommendation_priority);
this.database.prepare(`
INSERT INTO model_runtime_availability (model_id, available_for_new_jobs, reason, checked_at, runtime_availability_version)
VALUES (?, 0, 'contract_unverified', ?, 0)
`).run(candidate.model_id, now);
}
this.database.prepare("INSERT INTO model_config_current (singleton, config_set_id) VALUES (1, ?)").run(setId);
});
seed.immediate();
}
}
+182
View File
@@ -0,0 +1,182 @@
.admin-models-page {
min-height: 100vh;
color: #111111;
background: #f6f6f4;
}
.admin-models-page > main {
width: min(1440px, calc(100% - 64px));
margin: 0 auto;
padding: 36px 0 80px;
}
.admin-models-heading {
display: flex;
align-items: end;
justify-content: space-between;
gap: 24px;
padding-bottom: 18px;
border-bottom: 1px solid #999993;
}
.admin-models-heading p {
margin: 0 0 4px;
font-family: Consolas, monospace;
font-size: 11px;
font-weight: 700;
}
.admin-models-heading h1 {
margin: 0;
font-size: 34px;
}
.admin-models-heading > strong {
font-family: Consolas, monospace;
font-size: 13px;
}
.admin-models-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
margin: 22px 0;
border-block: 1px solid #8c8c86;
background: #ffffff;
}
.admin-models-summary > span {
display: grid;
min-width: 0;
gap: 6px;
padding: 18px;
border-right: 1px solid #c1c1ba;
color: #65655f;
font-size: 12px;
}
.admin-models-summary > span:last-child { border-right: 0; }
.admin-models-summary strong { overflow-wrap: anywhere; color: #111111; font-size: 15px; }
.admin-models-table-wrap {
overflow-x: auto;
border: 1px solid #8c8c86;
background: #ffffff;
}
.admin-models-table-wrap table {
width: 100%;
min-width: 1220px;
border-collapse: collapse;
table-layout: fixed;
}
.admin-models-table-wrap th,
.admin-models-table-wrap td {
padding: 14px 12px;
border-right: 1px solid #d0d0ca;
border-bottom: 1px solid #d0d0ca;
text-align: left;
vertical-align: top;
font-size: 12px;
}
.admin-models-table-wrap thead th {
background: #e7e7e2;
font-weight: 900;
}
.admin-models-table-wrap th:first-child { width: 210px; }
.admin-models-table-wrap th:nth-child(2),
.admin-models-table-wrap th:nth-child(3) { width: 62px; text-align: center; }
.admin-models-table-wrap th:nth-child(4) { width: 112px; }
.admin-models-table-wrap th:nth-child(5) { width: 96px; }
.admin-models-table-wrap th:nth-child(6) { width: 170px; }
.admin-models-table-wrap th:nth-child(7) { width: 90px; }
.admin-models-table-wrap th:nth-child(8) { width: 180px; }
.admin-models-table-wrap th:nth-child(9) { width: 84px; }
.admin-models-table-wrap tbody th strong,
.admin-models-table-wrap tbody th code,
.admin-models-table-wrap td small {
display: block;
}
.admin-models-table-wrap tbody th code,
.admin-models-table-wrap td small {
margin-top: 5px;
color: #65655f;
font-size: 10px;
overflow-wrap: anywhere;
}
.admin-models-table-wrap td:nth-child(2),
.admin-models-table-wrap td:nth-child(3) { text-align: center; }
.admin-models-table-wrap input[type="number"] {
width: 76px;
min-height: 38px;
padding: 6px 8px;
border: 1px solid #777770;
border-radius: 0;
}
.admin-models-table-wrap input[type="checkbox"],
.admin-models-table-wrap input[type="radio"] {
width: 18px;
height: 18px;
}
.model-state { display: inline-block; padding: 4px 6px; border: 1px solid #7d7d77; font-family: Consolas, monospace; }
.model-state.is-available,
.model-state.is-verified { border-color: #287b45; color: #1f6639; background: #edf8f0; }
.model-state.is-unavailable,
.model-state.is-unverified,
.model-state.is-blocked { border-color: #a66c18; color: #7c4a06; background: #fff7df; }
.admin-models-actions {
display: flex;
min-height: 74px;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 14px 0;
border-bottom: 1px solid #999993;
}
.admin-models-actions > div:last-child { display: flex; gap: 8px; }
.admin-models-actions button,
.admin-models-alert button {
min-height: 42px;
padding: 9px 14px;
border: 1px solid #111111;
border-radius: 0;
background: #f2f500;
font-weight: 800;
}
.admin-models-actions button:disabled { color: #777770; background: #dfdfda; cursor: not-allowed; }
.admin-models-validation,
.admin-models-notice { margin: 0; font-weight: 700; }
.admin-models-validation { color: #a52e24; }
.admin-models-notice { color: #1f6639; }
.admin-models-alert {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 24px;
padding: 16px;
border-left: 5px solid #d14a3b;
background: #fff1ef;
}
.admin-models-loading { display: grid; gap: 10px; margin-top: 24px; }
.admin-models-loading span { display: block; height: 62px; background: #dfdfda; }
@media (max-width: 760px) {
.admin-models-page > main { width: 100%; padding-right: 16px; padding-left: 16px; }
.admin-models-summary { grid-template-columns: 1fr; }
.admin-models-summary > span { border-right: 0; border-bottom: 1px solid #c1c1ba; }
.admin-models-actions { align-items: stretch; flex-direction: column; }
.admin-models-actions > div:last-child { display: grid; }
}
+197
View File
@@ -0,0 +1,197 @@
import { useEffect, useMemo, useRef, useState } from "react";
import "./admin-models.css";
interface AdminSession { csrf_token: string }
interface RuntimeAvailability {
available_for_new_jobs: boolean;
checked_at: string;
reason: string;
}
interface ModelConfig {
config_version: number;
contract_evidence_ref: string | null;
contract_validation_status: "blocked" | "unverified" | "verified";
credit_cost: number;
display_name: string;
enabled: boolean;
error_mapping_profile: Record<string, string>;
gateway_account_ref: string;
is_default: boolean;
model_id: string;
prompt_max_length: number;
recommendation_priority: number;
reference_limits: { max_file_bytes: number; max_files: number; max_total_bytes: number };
route_profile: Record<string, unknown>;
runtime_availability: RuntimeAvailability;
safety_source: string;
supported_ratios: string[];
}
interface ModelConfiguration {
config_set_version: number;
configured_default_model_id: string;
models: ModelConfig[];
recommended_model_id: string | null;
}
async function responseJson<T>(url: string, init?: RequestInit) {
const response = await fetch(url, { credentials: "same-origin", ...init });
const body = await response.json() as T;
return { body, response };
}
function idempotencyKey() {
return crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", "");
}
function editableModel(model: ModelConfig) {
const { config_version: _configVersion, contract_evidence_ref: _evidence, contract_validation_status: _status, runtime_availability: _runtime, ...editable } = model;
return editable;
}
function runtimeLabel(runtime: RuntimeAvailability) {
return runtime.available_for_new_jobs ? "可用于新任务" : `不可用 · ${runtime.reason}`;
}
export function AdminModelsPage() {
const [session, setSession] = useState<AdminSession>();
const [configuration, setConfiguration] = useState<ModelConfiguration>();
const [draft, setDraft] = useState<ModelConfig[]>();
const [loadingFailed, setLoadingFailed] = useState(false);
const [saving, setSaving] = useState(false);
const [notice, setNotice] = useState("");
const [conflicted, setConflicted] = useState(false);
const priorityRefs = useRef<Record<string, HTMLInputElement | null>>({});
const defaultRefs = useRef<Record<string, HTMLInputElement | null>>({});
async function load() {
setLoadingFailed(false);
setConflicted(false);
try {
const [sessionResult, modelResult] = await Promise.all([
responseJson<AdminSession>("/api/v1/admin-auth/session"),
responseJson<ModelConfiguration>("/api/v1/models"),
]);
if (!sessionResult.response.ok || !modelResult.response.ok) throw new Error("load_failed");
setSession(sessionResult.body);
setConfiguration(modelResult.body);
setDraft(structuredClone(modelResult.body.models));
setNotice("");
} catch {
setLoadingFailed(true);
}
}
useEffect(() => { void load(); }, []);
const validation = useMemo(() => {
if (!draft) return { valid: false, message: "" };
const invalidPriority = draft.find((model) => !Number.isSafeInteger(model.recommendation_priority) || model.recommendation_priority <= 0);
if (invalidPriority) return { field: invalidPriority.model_id, kind: "priority" as const, message: "推荐优先级必须是正整数。", valid: false };
const duplicatePriority = draft.find((model, index) => draft.findIndex((item) => item.recommendation_priority === model.recommendation_priority) !== index);
if (duplicatePriority) return { field: duplicatePriority.model_id, kind: "priority" as const, message: "推荐优先级不能重复。", valid: false };
const defaults = draft.filter((model) => model.enabled && model.is_default);
if (defaults.length !== 1) return { field: draft.find((model) => model.is_default)?.model_id ?? draft[0]?.model_id, kind: "default" as const, message: "必须指定一个已启用的默认模型。", valid: false };
return { message: "", valid: true };
}, [draft]);
function updateModel(modelId: string, change: Partial<ModelConfig>) {
setDraft((current) => current?.map((model) => model.model_id === modelId ? { ...model, ...change } : model));
setNotice("");
}
function chooseDefault(modelId: string) {
setDraft((current) => current?.map((model) => ({ ...model, is_default: model.model_id === modelId })));
setNotice("");
}
async function save() {
if (!configuration || !draft || !session || !validation.valid || saving || conflicted) return;
setSaving(true);
setNotice("");
try {
const { body, response } = await responseJson<ModelConfiguration & { error?: { code?: string } }>("/api/v1/admin/models/configuration", {
body: JSON.stringify({ expected_config_set_version: configuration.config_set_version, models: draft.map(editableModel) }),
headers: {
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey(),
"X-CSRF-Token": session.csrf_token,
},
method: "PUT",
});
if (!response.ok) {
const code = body.error?.code;
if (code === "MODEL_CONFIG_VERSION_CONFLICT") {
setConflicted(true);
setNotice("配置已被其他管理员更新,请刷新最新配置后重新编辑。");
} else if (code === "MODEL_RECOMMENDATION_PRIORITY_INVALID" || code === "MODEL_RECOMMENDATION_PRIORITY_CONFLICT") {
const modelId = validation.field ?? draft[0]?.model_id;
if (modelId) priorityRefs.current[modelId]?.focus();
setNotice("请修正推荐优先级后再保存。");
} else if (code === "MODEL_DEFAULT_REPLACEMENT_REQUIRED" || code === "MODEL_DEFAULT_REPLACEMENT_INVALID") {
const modelId = validation.field ?? draft[0]?.model_id;
if (modelId) defaultRefs.current[modelId]?.focus();
setNotice("请指定一个已启用的替代默认模型。");
} else setNotice("操作未完成。");
return;
}
setConfiguration(body);
setDraft(structuredClone(body.models));
setNotice("模型配置已原子保存并记录审计。");
} catch {
setNotice("操作未完成。");
} finally {
setSaving(false);
}
}
return (
<div className="admin-models-page">
<header className="admin-product-header">
<a href="/admin">DADA ADMIN</a>
<nav aria-label="后台导航"><a href="/admin/users"></a><a aria-current="page" href="/admin/models"></a><a href="/admin/audit"></a></nav>
</header>
<main>
<header className="admin-models-heading">
<div><p>MODEL OPERATIONS</p><h1></h1></div>
{configuration ? <strong> v{configuration.config_set_version}</strong> : null}
</header>
{!configuration && !loadingFailed ? <div aria-label="模型配置加载中" className="admin-models-loading"><span /><span /><span /></div> : null}
{loadingFailed ? <p className="admin-models-alert" role="alert"><button onClick={() => void load()} type="button"></button></p> : null}
{configuration && draft ? (
<>
<div className="admin-models-summary" aria-label="模型配置摘要">
<span> <strong>{configuration.configured_default_model_id}</strong></span>
<span> <strong>{configuration.recommended_model_id ?? "无"}</strong></span>
<span> <strong>{configuration.models.filter((model) => model.runtime_availability.available_for_new_jobs).length} / 3</strong></span>
</div>
<div className="admin-models-table-wrap">
<table>
<thead><tr><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>
{draft.map((model) => (
<tr key={model.model_id}>
<th scope="row"><strong>{model.display_name}</strong><code>{model.model_id}</code></th>
<td><input aria-label={`启用 ${model.display_name}`} checked={model.enabled} onChange={(event) => updateModel(model.model_id, { enabled: event.target.checked })} type="checkbox" /></td>
<td><input aria-label={`设为默认 ${model.display_name}`} checked={model.is_default} disabled={!model.enabled} name="configured-default" onChange={() => chooseDefault(model.model_id)} ref={(node) => { defaultRefs.current[model.model_id] = node; }} type="radio" /></td>
<td><input aria-label={`${model.display_name} 推荐优先级`} inputMode="numeric" min="1" onChange={(event) => updateModel(model.model_id, { recommendation_priority: Number(event.target.value) })} ref={(node) => { priorityRefs.current[model.model_id] = node; }} type="number" value={model.recommendation_priority} /></td>
<td><span className={`model-state is-${model.contract_validation_status}`}>{model.contract_validation_status}</span></td>
<td><span className={`model-state ${model.runtime_availability.available_for_new_jobs ? "is-available" : "is-unavailable"}`}>{runtimeLabel(model.runtime_availability)}</span><small>{new Date(model.runtime_availability.checked_at).toLocaleString("zh-CN")}</small></td>
<td>{configuration.recommended_model_id === model.model_id ? "是" : "否"}</td>
<td><span>{model.credit_cost} </span><small>{model.supported_ratios.join(" / ")} · {model.reference_limits.max_files} </small></td>
<td>v{model.config_version}</td>
</tr>
))}
</tbody>
</table>
</div>
<footer className="admin-models-actions">
<div aria-live="polite">{validation.message ? <p className="admin-models-validation">{validation.message}</p> : null}{notice ? <p className="admin-models-notice" role={conflicted ? "alert" : "status"}>{notice}</p> : null}</div>
<div>{conflicted ? <button onClick={() => void load()} type="button"></button> : null}<button disabled={!validation.valid || saving || conflicted} onClick={() => void save()} type="button">{saving ? "保存中" : "保存完整配置集合"}</button></div>
</footer>
</>
) : null}
</main>
</div>
);
}
+1 -1
View File
@@ -102,7 +102,7 @@ export function AdminUsersPage() {
<div className="admin-users-page">
<header className="admin-product-header">
<a href="/admin">DADA ADMIN</a>
<nav aria-label="后台导航"><a aria-current="page" href="/admin/users"></a><a href="/admin/audit"></a></nav>
<nav aria-label="后台导航"><a aria-current="page" href="/admin/users"></a><a href="/admin/models"></a><a href="/admin/audit"></a></nav>
</header>
<main>
<header className="admin-users-heading">
+82
View File
@@ -190,6 +190,66 @@ export async function getGeneration(options: ClientOptions = {}): Promise<Genera
return response.json() as Promise<GenerationTaskResponse>;
}
export async function getModel(options: ClientOptions = {}): Promise<{
"config_version": number;
"contract_evidence_ref": string | null;
"contract_validation_status": ModelContractValidationStatus;
"credit_cost": number;
"display_name": string;
"enabled": boolean;
"error_mapping_profile": Record<string, never>;
"gateway_account_ref": string;
"is_default": boolean;
"model_id": ModelId;
"prompt_max_length": number;
"recommendation_priority": number;
"reference_limits": ModelReferenceLimits;
"route_profile": Record<string, never>;
"runtime_availability": ModelRuntimeAvailability;
"safety_source": string;
"supported_ratios": Array<string>;
}> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/models/{model_id}`, { method: "GET", headers: options.headers ?? {} });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<{
"config_version": number;
"contract_evidence_ref": string | null;
"contract_validation_status": ModelContractValidationStatus;
"credit_cost": number;
"display_name": string;
"enabled": boolean;
"error_mapping_profile": Record<string, never>;
"gateway_account_ref": string;
"is_default": boolean;
"model_id": ModelId;
"prompt_max_length": number;
"recommendation_priority": number;
"reference_limits": ModelReferenceLimits;
"route_profile": Record<string, never>;
"runtime_availability": ModelRuntimeAvailability;
"safety_source": string;
"supported_ratios": Array<string>;
}>;
}
export async function getModels(options: ClientOptions = {}): Promise<{
"config_set_version": number;
"configured_default_model_id": ModelId;
"models": Array<ModelConfig>;
"recommended_model_id": ModelId | null;
}> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/models`, { method: "GET", headers: options.headers ?? {} });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<{
"config_set_version": number;
"configured_default_model_id": ModelId;
"models": Array<ModelConfig>;
"recommended_model_id": ModelId | null;
}>;
}
export async function getMyCreditLedger(options: ClientOptions = {}): Promise<CreditLedgerResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/me/credit-ledger`, { method: "GET", headers: options.headers ?? {} });
@@ -248,6 +308,28 @@ export async function renameProject(body: ProjectRenameRequest, options: ClientO
return response.json() as Promise<ProjectRenameResponse>;
}
export async function replaceModelConfiguration(body: {
"expected_config_set_version": number;
"models": Array<ModelConfigCandidate>;
}, options: ClientOptions = {}): Promise<{
"config_set_version": number;
"configured_default_model_id": ModelId;
"models": Array<ModelConfig>;
"recommended_model_id": ModelId | null;
}> {
const request = options.fetch ?? globalThis.fetch;
const headers = new Headers(options.headers);
headers.set("Content-Type", "application/json");
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/models/configuration`, { body: JSON.stringify(body), method: "PUT", headers });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<{
"config_set_version": number;
"configured_default_model_id": ModelId;
"models": Array<ModelConfig>;
"recommended_model_id": ModelId | null;
}>;
}
export async function restoreProject(options: ClientOptions = {}): Promise<ProjectRestoreResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}/restore`, { method: "POST", headers: options.headers ?? {} });
+77
View File
@@ -273,6 +273,7 @@ export type CsrfHeaders = {
export type ErrorDetails = {
"capacity_status"?: "normal" | "warning" | "critical" | "full" | "unavailable";
"conflict_model_ids"?: Array<string>;
"current_task_ref"?: string;
"field_errors"?: Array<{
"field": string;
@@ -293,6 +294,7 @@ export type ErrorEnvelope = {
"correlation_id": string;
"details": {
"capacity_status"?: "normal" | "warning" | "critical" | "full" | "unavailable";
"conflict_model_ids"?: Array<string>;
"current_task_ref"?: string;
"field_errors"?: Array<{
"field": string;
@@ -448,6 +450,42 @@ export type LogoutResponse = {
"status": "logged_out";
};
export type ModelConfig = {
"config_version": number;
"contract_evidence_ref": string | null;
"contract_validation_status": ModelContractValidationStatus;
"credit_cost": number;
"display_name": string;
"enabled": boolean;
"error_mapping_profile": Record<string, never>;
"gateway_account_ref": string;
"is_default": boolean;
"model_id": ModelId;
"prompt_max_length": number;
"recommendation_priority": number;
"reference_limits": ModelReferenceLimits;
"route_profile": Record<string, never>;
"runtime_availability": ModelRuntimeAvailability;
"safety_source": string;
"supported_ratios": Array<string>;
};
export type ModelConfigCandidate = {
"credit_cost": number;
"display_name": string;
"enabled": boolean;
"error_mapping_profile": Record<string, never>;
"gateway_account_ref": string;
"is_default": boolean;
"model_id": ModelId;
"prompt_max_length": number;
"recommendation_priority": number;
"reference_limits": ModelReferenceLimits;
"route_profile": Record<string, never>;
"safety_source": string;
"supported_ratios": Array<string>;
};
export type ModelConfigSseEvent = {
"config_set_version": number;
"entity_ref": string;
@@ -456,6 +494,45 @@ export type ModelConfigSseEvent = {
"occurred_at": string;
};
export type ModelConfigUpdateHeaders = {
"idempotency-key": string;
"x-csrf-token": string;
};
export type ModelConfigUpdateRequest = {
"expected_config_set_version": number;
"models": Array<ModelConfigCandidate>;
};
export type ModelConfigurationResponse = {
"config_set_version": number;
"configured_default_model_id": ModelId;
"models": Array<ModelConfig>;
"recommended_model_id": ModelId | null;
};
export type ModelContractValidationStatus = "blocked" | "unverified" | "verified";
export type ModelId = string;
export type ModelParams = {
"model_id": ModelId;
};
export type ModelReferenceLimits = {
"max_file_bytes": number;
"max_files": number;
"max_total_bytes": number;
};
export type ModelRuntimeAvailability = {
"available_for_new_jobs": boolean;
"checked_at": string;
"reason": ModelRuntimeReason;
};
export type ModelRuntimeReason = "available" | "configured_disabled" | "contract_unverified" | "contract_blocked" | "gateway_balance_insufficient" | "gateway_paused" | "worker_degraded";
export type ModelRuntimeSseEvent = {
"entity_ref": string;
"event_id": number;
+2
View File
@@ -6,6 +6,7 @@ import { AdminAuthPage } from "./admin-auth.js";
import { UserAuthPage } from "./user-auth.js";
import { AccountSettingsPage } from "./account-settings.js";
import { AdminUsersPage } from "./admin-users.js";
import { AdminModelsPage } from "./admin-models.js";
import { CreditsPage } from "./credits-page.js";
import { ProjectDetailPage, ProjectsPage, WorkspacePage } from "./project-pages.js";
@@ -31,6 +32,7 @@ function renderAuthenticationEntry() {
else if (window.location.pathname === "/app/projects") authenticationPage = <ProjectsPage key={authRevision} />;
else if (window.location.pathname === "/app") authenticationPage = <WorkspacePage key={authRevision} />;
else if (window.location.pathname === "/admin/users") authenticationPage = <AdminUsersPage key={authRevision} />;
else if (window.location.pathname === "/admin/models") authenticationPage = <AdminModelsPage key={authRevision} />;
else if (window.location.pathname.startsWith("/admin")) authenticationPage = <AdminAuthPage key={authRevision} />;
else authenticationPage = <UserAuthPage key={authRevision} />;
appRoot.render(