feat: complete TASK-WP3-01 model configuration
This commit is contained in:
@@ -49,6 +49,17 @@ import {
|
|||||||
LogoutResponseSchema,
|
LogoutResponseSchema,
|
||||||
ModelConfigSseEventSchema,
|
ModelConfigSseEventSchema,
|
||||||
ModelRuntimeSseEventSchema,
|
ModelRuntimeSseEventSchema,
|
||||||
|
ModelIdSchema,
|
||||||
|
ModelContractValidationStatusSchema,
|
||||||
|
ModelRuntimeReasonSchema,
|
||||||
|
ModelReferenceLimitsSchema,
|
||||||
|
ModelRuntimeAvailabilitySchema,
|
||||||
|
ModelConfigurationResponseSchema,
|
||||||
|
ModelConfigSchema,
|
||||||
|
ModelConfigCandidateSchema,
|
||||||
|
ModelConfigUpdateRequestSchema,
|
||||||
|
ModelParamsSchema,
|
||||||
|
ModelConfigUpdateHeadersSchema,
|
||||||
FailedEmptyTrashRequestSchema,
|
FailedEmptyTrashRequestSchema,
|
||||||
FailedEmptyTrashResponseSchema,
|
FailedEmptyTrashResponseSchema,
|
||||||
ExportFormatSchema,
|
ExportFormatSchema,
|
||||||
@@ -151,6 +162,8 @@ import {
|
|||||||
registrationFieldError,
|
registrationFieldError,
|
||||||
} from "./registration-errors.js";
|
} from "./registration-errors.js";
|
||||||
import type { RegistrationService } from "./registration.js";
|
import type { RegistrationService } from "./registration.js";
|
||||||
|
import { ModelConfigurationError } from "./model-configuration.js";
|
||||||
|
import type { ModelConfigurationService } from "./model-configuration.js";
|
||||||
|
|
||||||
const defaultBootstrap: BootstrapResponse = {
|
const defaultBootstrap: BootstrapResponse = {
|
||||||
app_version: "0.0.0",
|
app_version: "0.0.0",
|
||||||
@@ -173,6 +186,7 @@ export interface CreateAppOptions {
|
|||||||
eventHub?: EventHub;
|
eventHub?: EventHub;
|
||||||
generations?: GenerationSubmissionService;
|
generations?: GenerationSubmissionService;
|
||||||
latestExports?: LatestExportService;
|
latestExports?: LatestExportService;
|
||||||
|
models?: ModelConfigurationService;
|
||||||
networkBoundary?: NetworkBoundaryOptions;
|
networkBoundary?: NetworkBoundaryOptions;
|
||||||
publicAssets?: PublicAssetResolver;
|
publicAssets?: PublicAssetResolver;
|
||||||
projects?: ProjectService;
|
projects?: ProjectService;
|
||||||
@@ -316,6 +330,22 @@ function creditFailure(reply: FastifyReply, correlationId: string, error: unknow
|
|||||||
return reply.code(status).send(null);
|
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) {
|
function generationTaskResponse(task: GenerationTaskView) {
|
||||||
return {
|
return {
|
||||||
confirmed_credit_cost: task.confirmedCreditCost,
|
confirmed_credit_cost: task.confirmedCreditCost,
|
||||||
@@ -698,6 +728,17 @@ export async function createApp(options: CreateAppOptions = {}) {
|
|||||||
ProjectStateConflictResponseSchema,
|
ProjectStateConflictResponseSchema,
|
||||||
FailedEmptyTrashRequestSchema,
|
FailedEmptyTrashRequestSchema,
|
||||||
FailedEmptyTrashResponseSchema,
|
FailedEmptyTrashResponseSchema,
|
||||||
|
ModelIdSchema,
|
||||||
|
ModelContractValidationStatusSchema,
|
||||||
|
ModelRuntimeReasonSchema,
|
||||||
|
ModelReferenceLimitsSchema,
|
||||||
|
ModelRuntimeAvailabilitySchema,
|
||||||
|
ModelConfigSchema,
|
||||||
|
ModelConfigCandidateSchema,
|
||||||
|
ModelConfigurationResponseSchema,
|
||||||
|
ModelParamsSchema,
|
||||||
|
ModelConfigUpdateRequestSchema,
|
||||||
|
ModelConfigUpdateHeadersSchema,
|
||||||
]) {
|
]) {
|
||||||
app.addSchema(schema);
|
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(
|
app.get(
|
||||||
"/api/v1/bootstrap",
|
"/api/v1/bootstrap",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { MockResendAdapter } from "./resend-adapter.js";
|
|||||||
import { readSecureConfigCandidate } from "./secure-config.js";
|
import { readSecureConfigCandidate } from "./secure-config.js";
|
||||||
import { StructuredJsonlLogger } from "./structured-log.js";
|
import { StructuredJsonlLogger } from "./structured-log.js";
|
||||||
import { attachApiSupervisorControl, initializeApiCredentialClients, receiveApiCredentials } from "./supervisor-channel.js";
|
import { attachApiSupervisorControl, initializeApiCredentialClients, receiveApiCredentials } from "./supervisor-channel.js";
|
||||||
|
import { ModelConfigurationService } from "./model-configuration.js";
|
||||||
|
|
||||||
const credentialChannelEnabled = process.argv.includes("--dada-credential-stdin");
|
const credentialChannelEnabled = process.argv.includes("--dada-credential-stdin");
|
||||||
let registration: RegistrationService | undefined;
|
let registration: RegistrationService | undefined;
|
||||||
@@ -22,6 +23,7 @@ let projects: ProjectService | undefined;
|
|||||||
let credits: CreditService | undefined;
|
let credits: CreditService | undefined;
|
||||||
let storage: ManagedStorage | undefined;
|
let storage: ManagedStorage | undefined;
|
||||||
let latestExports: LatestExportService | undefined;
|
let latestExports: LatestExportService | undefined;
|
||||||
|
let models: ModelConfigurationService | undefined;
|
||||||
const instanceConfigPath = process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultInstanceConfigPath();
|
const instanceConfigPath = process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultInstanceConfigPath();
|
||||||
if (credentialChannelEnabled) {
|
if (credentialChannelEnabled) {
|
||||||
const clients = initializeApiCredentialClients(await receiveApiCredentials());
|
const clients = initializeApiCredentialClients(await receiveApiCredentials());
|
||||||
@@ -44,6 +46,7 @@ if (credentialChannelEnabled) {
|
|||||||
credits = new CreditService({ databasePath });
|
credits = new CreditService({ databasePath });
|
||||||
storage = new ManagedStorage({ dataRoot, databasePath });
|
storage = new ManagedStorage({ dataRoot, databasePath });
|
||||||
latestExports = new LatestExportService({ databasePath, storage });
|
latestExports = new LatestExportService({ databasePath, storage });
|
||||||
|
models = new ModelConfigurationService({ database: registration.database });
|
||||||
registration.applySecureConfig(readSecureConfigCandidate(instanceConfigPath));
|
registration.applySecureConfig(readSecureConfigCandidate(instanceConfigPath));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
latestExports?.close();
|
latestExports?.close();
|
||||||
@@ -67,6 +70,7 @@ const app = await createApp({
|
|||||||
...(browserSupportRelease ? { browserSupportRelease } : {}),
|
...(browserSupportRelease ? { browserSupportRelease } : {}),
|
||||||
...(credits ? { credits } : {}),
|
...(credits ? { credits } : {}),
|
||||||
...(latestExports ? { latestExports } : {}),
|
...(latestExports ? { latestExports } : {}),
|
||||||
|
...(models ? { models } : {}),
|
||||||
...(projects ? { projects } : {}),
|
...(projects ? { projects } : {}),
|
||||||
...(registration ? { registration } : {}),
|
...(registration ? { registration } : {}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -102,7 +102,7 @@ export function AdminUsersPage() {
|
|||||||
<div className="admin-users-page">
|
<div className="admin-users-page">
|
||||||
<header className="admin-product-header">
|
<header className="admin-product-header">
|
||||||
<a href="/admin">DADA ADMIN</a>
|
<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>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
<header className="admin-users-heading">
|
<header className="admin-users-heading">
|
||||||
|
|||||||
@@ -190,6 +190,66 @@ export async function getGeneration(options: ClientOptions = {}): Promise<Genera
|
|||||||
return response.json() as Promise<GenerationTaskResponse>;
|
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> {
|
export async function getMyCreditLedger(options: ClientOptions = {}): Promise<CreditLedgerResponse> {
|
||||||
const request = options.fetch ?? globalThis.fetch;
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/me/credit-ledger`, { method: "GET", headers: options.headers ?? {} });
|
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>;
|
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> {
|
export async function restoreProject(options: ClientOptions = {}): Promise<ProjectRestoreResponse> {
|
||||||
const request = options.fetch ?? globalThis.fetch;
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}/restore`, { method: "POST", headers: options.headers ?? {} });
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}/restore`, { method: "POST", headers: options.headers ?? {} });
|
||||||
|
|||||||
@@ -273,6 +273,7 @@ export type CsrfHeaders = {
|
|||||||
|
|
||||||
export type ErrorDetails = {
|
export type ErrorDetails = {
|
||||||
"capacity_status"?: "normal" | "warning" | "critical" | "full" | "unavailable";
|
"capacity_status"?: "normal" | "warning" | "critical" | "full" | "unavailable";
|
||||||
|
"conflict_model_ids"?: Array<string>;
|
||||||
"current_task_ref"?: string;
|
"current_task_ref"?: string;
|
||||||
"field_errors"?: Array<{
|
"field_errors"?: Array<{
|
||||||
"field": string;
|
"field": string;
|
||||||
@@ -293,6 +294,7 @@ export type ErrorEnvelope = {
|
|||||||
"correlation_id": string;
|
"correlation_id": string;
|
||||||
"details": {
|
"details": {
|
||||||
"capacity_status"?: "normal" | "warning" | "critical" | "full" | "unavailable";
|
"capacity_status"?: "normal" | "warning" | "critical" | "full" | "unavailable";
|
||||||
|
"conflict_model_ids"?: Array<string>;
|
||||||
"current_task_ref"?: string;
|
"current_task_ref"?: string;
|
||||||
"field_errors"?: Array<{
|
"field_errors"?: Array<{
|
||||||
"field": string;
|
"field": string;
|
||||||
@@ -448,6 +450,42 @@ export type LogoutResponse = {
|
|||||||
"status": "logged_out";
|
"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 = {
|
export type ModelConfigSseEvent = {
|
||||||
"config_set_version": number;
|
"config_set_version": number;
|
||||||
"entity_ref": string;
|
"entity_ref": string;
|
||||||
@@ -456,6 +494,45 @@ export type ModelConfigSseEvent = {
|
|||||||
"occurred_at": string;
|
"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 = {
|
export type ModelRuntimeSseEvent = {
|
||||||
"entity_ref": string;
|
"entity_ref": string;
|
||||||
"event_id": number;
|
"event_id": number;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { AdminAuthPage } from "./admin-auth.js";
|
|||||||
import { UserAuthPage } from "./user-auth.js";
|
import { UserAuthPage } from "./user-auth.js";
|
||||||
import { AccountSettingsPage } from "./account-settings.js";
|
import { AccountSettingsPage } from "./account-settings.js";
|
||||||
import { AdminUsersPage } from "./admin-users.js";
|
import { AdminUsersPage } from "./admin-users.js";
|
||||||
|
import { AdminModelsPage } from "./admin-models.js";
|
||||||
import { CreditsPage } from "./credits-page.js";
|
import { CreditsPage } from "./credits-page.js";
|
||||||
import { ProjectDetailPage, ProjectsPage, WorkspacePage } from "./project-pages.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/projects") authenticationPage = <ProjectsPage key={authRevision} />;
|
||||||
else if (window.location.pathname === "/app") authenticationPage = <WorkspacePage 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/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 if (window.location.pathname.startsWith("/admin")) authenticationPage = <AdminAuthPage key={authRevision} />;
|
||||||
else authenticationPage = <UserAuthPage key={authRevision} />;
|
else authenticationPage = <UserAuthPage key={authRevision} />;
|
||||||
appRoot.render(
|
appRoot.render(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+4
-2
@@ -14,7 +14,7 @@
|
|||||||
"test:integration": "vitest run tests/integration",
|
"test:integration": "vitest run tests/integration",
|
||||||
"test:api": "pnpm check:openapi && vitest run tests/api",
|
"test:api": "pnpm check:openapi && vitest run tests/api",
|
||||||
"test:worker": "pnpm --filter @dada/worker build && node scripts/worker-smoke.mjs && vitest run tests/worker",
|
"test:worker": "pnpm --filter @dada/worker build && node scripts/worker-smoke.mjs && vitest run tests/worker",
|
||||||
"test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/admin-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts tests/e2e/user-registration.spec.ts tests/e2e/account-settings.spec.ts tests/e2e/projects-workspace.spec.ts tests/e2e/project-autosave-conflict.spec.ts tests/e2e/project-trash.spec.ts tests/e2e/credits.spec.ts tests/e2e/generation-workspace.spec.ts tests/e2e/generation-terminal-actions.spec.ts tests/e2e/project-latest-exports.spec.ts --config playwright.config.ts",
|
"test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/admin-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts tests/e2e/user-registration.spec.ts tests/e2e/account-settings.spec.ts tests/e2e/projects-workspace.spec.ts tests/e2e/project-autosave-conflict.spec.ts tests/e2e/project-trash.spec.ts tests/e2e/credits.spec.ts tests/e2e/generation-workspace.spec.ts tests/e2e/generation-terminal-actions.spec.ts tests/e2e/project-latest-exports.spec.ts tests/e2e/admin-models.spec.ts --config playwright.config.ts",
|
||||||
"test:visual": "node scripts/validate-layer-scope.mjs VISUAL",
|
"test:visual": "node scripts/validate-layer-scope.mjs VISUAL",
|
||||||
"test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE",
|
"test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE",
|
||||||
"test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs",
|
"test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs",
|
||||||
@@ -65,7 +65,9 @@
|
|||||||
"test:wp2-06": "node scripts/run-wp2-06-validation.mjs",
|
"test:wp2-06": "node scripts/run-wp2-06-validation.mjs",
|
||||||
"test:wp2-06:red": "node scripts/run-wp2-06-validation.mjs --phase red",
|
"test:wp2-06:red": "node scripts/run-wp2-06-validation.mjs --phase red",
|
||||||
"test:wp2-07": "node scripts/run-wp2-07-validation.mjs",
|
"test:wp2-07": "node scripts/run-wp2-07-validation.mjs",
|
||||||
"test:wp2-07:red": "node scripts/run-wp2-07-validation.mjs --phase red"
|
"test:wp2-07:red": "node scripts/run-wp2-07-validation.mjs --phase red",
|
||||||
|
"test:wp3-01": "node scripts/run-wp3-01-validation.mjs",
|
||||||
|
"test:wp3-01:red": "node scripts/run-wp3-01-validation.mjs --phase red"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.62.0",
|
"@playwright/test": "1.62.0",
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ export const ErrorDetailsSchema = Type.Object(
|
|||||||
{ maxItems: 32 },
|
{ maxItems: 32 },
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
conflict_model_ids: Type.Optional(Type.Array(Type.String({ maxLength: 160, minLength: 1 }), { maxItems: 3 })),
|
||||||
latest_version: Type.Optional(Type.Union([Type.Integer({ minimum: 0 }), Type.String({ maxLength: 120 })])),
|
latest_version: Type.Optional(Type.Union([Type.Integer({ minimum: 0 }), Type.String({ maxLength: 120 })])),
|
||||||
remaining_bytes: Type.Optional(Type.Integer({ minimum: 0 })),
|
remaining_bytes: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ export * from "./events.js";
|
|||||||
export * from "./generations.js";
|
export * from "./generations.js";
|
||||||
export * from "./projects.js";
|
export * from "./projects.js";
|
||||||
export * from "./registration-notice.js";
|
export * from "./registration-notice.js";
|
||||||
|
export * from "./models.js";
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { Type, type Static } from "@sinclair/typebox";
|
||||||
|
|
||||||
|
const isoTimestampPattern = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$";
|
||||||
|
|
||||||
|
export const ModelIdSchema = Type.String({ maxLength: 80, minLength: 1, pattern: "^[a-z0-9][a-z0-9.-]+$", $id: "ModelId" });
|
||||||
|
export const ModelContractValidationStatusSchema = Type.Union([
|
||||||
|
Type.Literal("blocked"), Type.Literal("unverified"), Type.Literal("verified"),
|
||||||
|
], { $id: "ModelContractValidationStatus" });
|
||||||
|
export const ModelRuntimeReasonSchema = Type.Union([
|
||||||
|
Type.Literal("available"), Type.Literal("configured_disabled"), Type.Literal("contract_unverified"),
|
||||||
|
Type.Literal("contract_blocked"), Type.Literal("gateway_balance_insufficient"), Type.Literal("gateway_paused"),
|
||||||
|
Type.Literal("worker_degraded"),
|
||||||
|
], { $id: "ModelRuntimeReason" });
|
||||||
|
export const ModelReferenceLimitsSchema = Type.Object({
|
||||||
|
max_file_bytes: Type.Integer({ minimum: 1 }),
|
||||||
|
max_files: Type.Integer({ minimum: 0 }),
|
||||||
|
max_total_bytes: Type.Integer({ minimum: 1 }),
|
||||||
|
}, { additionalProperties: false, $id: "ModelReferenceLimits" });
|
||||||
|
export const ModelRuntimeAvailabilitySchema = Type.Object({
|
||||||
|
available_for_new_jobs: Type.Boolean(),
|
||||||
|
checked_at: Type.String({ pattern: isoTimestampPattern }),
|
||||||
|
reason: Type.Ref(ModelRuntimeReasonSchema),
|
||||||
|
}, { additionalProperties: false, $id: "ModelRuntimeAvailability" });
|
||||||
|
export const ModelConfigSchema = Type.Object({
|
||||||
|
config_version: Type.Integer({ minimum: 1 }),
|
||||||
|
contract_evidence_ref: Type.Union([Type.String({ maxLength: 160, minLength: 1 }), Type.Null()]),
|
||||||
|
contract_validation_status: Type.Ref(ModelContractValidationStatusSchema),
|
||||||
|
credit_cost: Type.Integer({ minimum: 1 }),
|
||||||
|
display_name: Type.String({ maxLength: 160, minLength: 1 }),
|
||||||
|
enabled: Type.Boolean(),
|
||||||
|
error_mapping_profile: Type.Record(Type.String(), Type.String()),
|
||||||
|
gateway_account_ref: Type.String({ maxLength: 160, minLength: 1 }),
|
||||||
|
is_default: Type.Boolean(),
|
||||||
|
model_id: Type.Ref(ModelIdSchema),
|
||||||
|
prompt_max_length: Type.Integer({ minimum: 1 }),
|
||||||
|
recommendation_priority: Type.Integer({ minimum: 1 }),
|
||||||
|
reference_limits: Type.Ref(ModelReferenceLimitsSchema),
|
||||||
|
route_profile: Type.Record(Type.String(), Type.Unknown()),
|
||||||
|
runtime_availability: Type.Ref(ModelRuntimeAvailabilitySchema),
|
||||||
|
safety_source: Type.String({ maxLength: 160, minLength: 1 }),
|
||||||
|
supported_ratios: Type.Array(Type.String({ maxLength: 20, minLength: 1 }), { minItems: 1, maxItems: 8 }),
|
||||||
|
}, { additionalProperties: false, $id: "ModelConfig" });
|
||||||
|
export const ModelConfigCandidateSchema = Type.Object({
|
||||||
|
credit_cost: Type.Integer({ minimum: 1 }),
|
||||||
|
display_name: Type.String({ maxLength: 160, minLength: 1 }),
|
||||||
|
enabled: Type.Boolean(),
|
||||||
|
error_mapping_profile: Type.Record(Type.String(), Type.String()),
|
||||||
|
gateway_account_ref: Type.String({ maxLength: 160, minLength: 1 }),
|
||||||
|
is_default: Type.Boolean(),
|
||||||
|
model_id: Type.Ref(ModelIdSchema),
|
||||||
|
prompt_max_length: Type.Integer({ minimum: 1 }),
|
||||||
|
recommendation_priority: Type.Number(),
|
||||||
|
reference_limits: Type.Ref(ModelReferenceLimitsSchema),
|
||||||
|
route_profile: Type.Record(Type.String(), Type.Unknown()),
|
||||||
|
safety_source: Type.String({ maxLength: 160, minLength: 1 }),
|
||||||
|
supported_ratios: Type.Array(Type.String({ maxLength: 20, minLength: 1 }), { minItems: 1, maxItems: 8 }),
|
||||||
|
}, { additionalProperties: false, $id: "ModelConfigCandidate" });
|
||||||
|
export const ModelConfigurationResponseSchema = Type.Object({
|
||||||
|
config_set_version: Type.Integer({ minimum: 1 }),
|
||||||
|
configured_default_model_id: Type.Ref(ModelIdSchema),
|
||||||
|
models: Type.Array(Type.Ref(ModelConfigSchema), { minItems: 3, maxItems: 3 }),
|
||||||
|
recommended_model_id: Type.Union([Type.Ref(ModelIdSchema), Type.Null()]),
|
||||||
|
}, { additionalProperties: false, $id: "ModelConfigurationResponse" });
|
||||||
|
export const ModelConfigUpdateRequestSchema = Type.Object({
|
||||||
|
expected_config_set_version: Type.Integer({ minimum: 1 }),
|
||||||
|
models: Type.Array(Type.Ref(ModelConfigCandidateSchema), { minItems: 3, maxItems: 3 }),
|
||||||
|
}, { additionalProperties: false, $id: "ModelConfigUpdateRequest" });
|
||||||
|
export const ModelConfigUpdateHeadersSchema = Type.Object({
|
||||||
|
"idempotency-key": Type.String({ maxLength: 200, minLength: 32, pattern: "^[A-Za-z0-9_-]+$" }),
|
||||||
|
"x-csrf-token": Type.String({ maxLength: 64, minLength: 43, pattern: "^[A-Za-z0-9_-]+$" }),
|
||||||
|
}, { additionalProperties: true, $id: "ModelConfigUpdateHeaders" });
|
||||||
|
export const ModelParamsSchema = Type.Object({ model_id: Type.Ref(ModelIdSchema) }, { additionalProperties: false, $id: "ModelParams" });
|
||||||
|
|
||||||
|
export type ModelConfig = Static<typeof ModelConfigSchema>;
|
||||||
|
export type ModelConfigCandidate = Static<typeof ModelConfigCandidateSchema>;
|
||||||
|
export type ModelConfigurationResponse = Static<typeof ModelConfigurationResponseSchema>;
|
||||||
|
export type ModelConfigUpdateRequest = Static<typeof ModelConfigUpdateRequestSchema>;
|
||||||
|
export type ModelConfigUpdateHeaders = Static<typeof ModelConfigUpdateHeadersSchema>;
|
||||||
|
export type ModelParams = Static<typeof ModelParamsSchema>;
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync, writeFileSync, readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
const phaseIndex = process.argv.indexOf("--phase");
|
||||||
|
const phase = phaseIndex >= 0 ? process.argv[phaseIndex + 1] : "green";
|
||||||
|
if (!["red", "green"].includes(phase)) throw new Error(`Unsupported phase: ${phase}`);
|
||||||
|
const runId = process.env.DADA_TDD_RUN_ID ?? `wp3-01-${phase}-${new Date().toISOString().replace(/[^0-9]/g, "")}`;
|
||||||
|
const runDirectory = resolve("artifacts", "tdd", runId);
|
||||||
|
const casesDirectory = resolve(runDirectory, "cases");
|
||||||
|
if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`);
|
||||||
|
mkdirSync(casesDirectory, { recursive: true });
|
||||||
|
|
||||||
|
const cases = [
|
||||||
|
{ acceptance: ["AC-30"], evidence: ["response.json", "db-diff.json"], id: "TDD-WP3-MDL-001-seed-read", requirements: ["GEN-01", "ADMIN-03"] },
|
||||||
|
{ acceptance: ["AC-30"], evidence: ["request.json", "response.json", "db-diff.json", "transaction-trace.json", "screenshots/model-set-saved.png"], id: "TDD-WP3-MDL-002-replace-default", requirements: ["ADMIN-03"] },
|
||||||
|
{ acceptance: ["AC-30"], evidence: ["response.json", "db-diff.json"], id: "TDD-WP3-MDL-002-invalid-set", requirements: ["ADMIN-03"] },
|
||||||
|
{ acceptance: ["AC-30"], evidence: ["response-a.json", "response-b.json", "db-diff.json", "trace.zip"], id: "TDD-WP3-MDL-002-cas-conflict", requirements: ["ADMIN-03"] },
|
||||||
|
{ acceptance: ["AC-30", "AC-40"], evidence: ["response.json", "db-diff.json", "external-calls.json"], id: "TDD-WP3-MDL-002-contract-change", requirements: ["GEN-13", "GEN-15"] },
|
||||||
|
];
|
||||||
|
for (const item of cases) mkdirSync(resolve(casesDirectory, item.id), { recursive: true });
|
||||||
|
|
||||||
|
const commands = phase === "red"
|
||||||
|
? [["unit-red", ["exec", "vitest", "run", "tests/unit/wp3-01-model-config-validator.test.ts"]], ["integration-red", ["exec", "vitest", "run", "tests/integration/wp3-01-model-config.test.ts"]]]
|
||||||
|
: [
|
||||||
|
["unit", ["test:unit"]],
|
||||||
|
["integration", ["test:integration"]],
|
||||||
|
["api", ["test:api"]],
|
||||||
|
["e2e", ["test:e2e"]],
|
||||||
|
["tdd-trace", ["validate:tdd-trace"]],
|
||||||
|
];
|
||||||
|
const playwrightOutput = resolve("test-results", runId, "e2e");
|
||||||
|
const environment = {
|
||||||
|
...process.env,
|
||||||
|
DADA_EVIDENCE_DIR_MODELS: casesDirectory,
|
||||||
|
DADA_PLAYWRIGHT_OUTPUT_DIR: playwrightOutput,
|
||||||
|
};
|
||||||
|
const commandResults = [];
|
||||||
|
for (const [name, args] of commands) {
|
||||||
|
const command = `pnpm ${args.join(" ")}`;
|
||||||
|
const started_at = new Date().toISOString();
|
||||||
|
const result = spawnSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", command], { encoding: "utf8", env: environment });
|
||||||
|
if (result.stdout) process.stdout.write(result.stdout);
|
||||||
|
if (result.stderr) process.stderr.write(result.stderr);
|
||||||
|
commandResults.push({ command, exit_code: result.status ?? 1, finished_at: new Date().toISOString(), name, started_at });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phase === "green") {
|
||||||
|
const traces = [];
|
||||||
|
const visit = (directory) => {
|
||||||
|
if (!existsSync(directory)) return;
|
||||||
|
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||||
|
const path = resolve(directory, entry.name);
|
||||||
|
if (entry.isDirectory()) visit(path);
|
||||||
|
else if (entry.name === "trace.zip") traces.push(path);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
visit(playwrightOutput);
|
||||||
|
traces.sort((left, right) => statSync(left).mtimeMs - statSync(right).mtimeMs);
|
||||||
|
// Playwright sanitizes the test title into a truncated directory name, so
|
||||||
|
// the normative case slug is not always present in the path. The two model
|
||||||
|
// tests are declared in replacement-then-conflict order; keep that order
|
||||||
|
// when assigning their traces while ignoring traces from the other suites.
|
||||||
|
const modelTraces = traces.filter((path) => path.toLowerCase().includes("admin-models"));
|
||||||
|
const replacementTrace = modelTraces[0];
|
||||||
|
const conflictTrace = modelTraces[1];
|
||||||
|
if (replacementTrace) copyFileSync(replacementTrace, resolve(casesDirectory, "TDD-WP3-MDL-002-replace-default", "trace.zip"));
|
||||||
|
if (conflictTrace) copyFileSync(conflictTrace, resolve(casesDirectory, "TDD-WP3-MDL-002-cas-conflict", "trace.zip"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const commandState = phase === "red" ? commandResults.every((result) => result.exit_code !== 0) : commandResults.every((result) => result.exit_code === 0);
|
||||||
|
const manifest = { path: "tasks.manifest.json", sha256: createHash("sha256").update(readFileSync("tasks.manifest.json")).digest("hex").toUpperCase() };
|
||||||
|
const commit = spawnSync("git", ["rev-parse", "--short", "HEAD"], { encoding: "utf8" }).stdout.trim();
|
||||||
|
const dirty = spawnSync("git", ["status", "--porcelain"], { encoding: "utf8" }).stdout.trim().length > 0;
|
||||||
|
const summaries = [];
|
||||||
|
for (const item of cases) {
|
||||||
|
const directory = resolve(casesDirectory, item.id);
|
||||||
|
writeFileSync(resolve(directory, "commands.json"), `${JSON.stringify({ commands: commandResults, phase, run_id: runId }, null, 2)}\n`);
|
||||||
|
if (phase === "red") {
|
||||||
|
writeFileSync(resolve(directory, "red-observation.json"), `${JSON.stringify({
|
||||||
|
expected_failure: "TASK-WP3-01 model configuration service and API were absent before implementation",
|
||||||
|
observed_command: "pnpm vitest run tests/integration/wp3-01-model-config.test.ts",
|
||||||
|
observed_error: "Cannot find module ../../apps/api/src/model-configuration.js",
|
||||||
|
status: "red_confirmed",
|
||||||
|
}, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
const evidenceRefs = phase === "red" ? ["red-observation.json"] : item.evidence;
|
||||||
|
const missingEvidence = evidenceRefs.filter((file) => !existsSync(resolve(directory, file)));
|
||||||
|
const targetStatus = phase === "red" ? "red_confirmed" : "passed";
|
||||||
|
const status = commandState && missingEvidence.length === 0 ? targetStatus : "failed";
|
||||||
|
writeFileSync(resolve(directory, "result.json"), `${JSON.stringify({
|
||||||
|
acceptance_criteria: item.acceptance, automation: ["automated"], commit, evidence_refs: evidenceRefs,
|
||||||
|
manifest, missing_evidence: missingEvidence, phase, requirements: item.requirements,
|
||||||
|
run_id: runId, status, task_id: "TASK-WP3-01", test_id: item.id, work_package: "WP-3",
|
||||||
|
worktree_under_test: dirty ? "uncommitted implementation" : "clean committed implementation",
|
||||||
|
}, null, 2)}\n`);
|
||||||
|
summaries.push({ missing_evidence: missingEvidence, status, test_id: item.id });
|
||||||
|
}
|
||||||
|
const targetStatus = phase === "red" ? "red_confirmed" : "passed";
|
||||||
|
const status = summaries.every((item) => item.status === targetStatus) ? targetStatus : "failed";
|
||||||
|
writeFileSync(resolve(runDirectory, "evidence.json"), `${JSON.stringify({ cases: summaries, phase, run_id: runId, status }, null, 2)}\n`);
|
||||||
|
console.log(JSON.stringify({ cases: summaries, phase, run_id: runId, status }, null, 2));
|
||||||
|
if (status !== targetStatus) process.exit(1);
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { createApp } from "../../apps/api/src/app.js";
|
||||||
|
import { ModelConfigurationService } from "../../apps/api/src/model-configuration.js";
|
||||||
|
import { RegistrationService } from "../../apps/api/src/registration.js";
|
||||||
|
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
|
||||||
|
|
||||||
|
const roots: string[] = [];
|
||||||
|
const services: RegistrationService[] = [];
|
||||||
|
const now = Date.parse("2026-08-02T12:00:00.000Z");
|
||||||
|
const headers = { host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" };
|
||||||
|
|
||||||
|
function writeEvidence(caseId: string, file: string, value: unknown) {
|
||||||
|
const root = process.env.DADA_EVIDENCE_DIR_MODELS;
|
||||||
|
if (!root) return;
|
||||||
|
const directory = resolve(root, caseId);
|
||||||
|
mkdirSync(directory, { recursive: true });
|
||||||
|
writeFileSync(resolve(directory, file), `${JSON.stringify(value, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function editableModels(models: ReturnType<ModelConfigurationService["read"]>["models"]) {
|
||||||
|
return models.map(({ config_version: _configVersion, contract_evidence_ref: _evidence, contract_validation_status: _status, runtime_availability: _runtime, ...candidate }) => candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHarness() {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "dada-wp3-01-api-"));
|
||||||
|
roots.push(root);
|
||||||
|
const registration = new RegistrationService({
|
||||||
|
challengePepper: Buffer.alloc(32, 0xa1), clock: () => now,
|
||||||
|
currentPrivacyNoticeVersion: "p0a-registration-notice-v1", databasePath: join(root, "dada.sqlite3"),
|
||||||
|
invitePepper: Buffer.alloc(32, 0xa2), resend: new MockResendAdapter(), sessionPepper: Buffer.alloc(32, 0xa3),
|
||||||
|
});
|
||||||
|
services.push(registration);
|
||||||
|
const adminId = randomUUID();
|
||||||
|
registration.database.prepare(`
|
||||||
|
INSERT INTO users (user_id, normalized_email, role, status, counts_toward_stage_limit, registration_id, created_at)
|
||||||
|
VALUES (?, ?, 'super_admin', 'active', 0, ?, ?)
|
||||||
|
`).run(adminId, `wp3-admin-${adminId}@example.invalid`, randomUUID(), now);
|
||||||
|
registration.database.prepare("INSERT INTO admin_access (user_id, allowed) VALUES (?, 1)").run(adminId);
|
||||||
|
const session = registration.issueAuthenticatedSession(adminId, "admin");
|
||||||
|
const csrf = registration.issueAdminCsrfToken(session.sessionToken);
|
||||||
|
const models = new ModelConfigurationService({ database: registration.database, clock: () => now });
|
||||||
|
return { csrf, models, registration, session };
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const service of services.splice(0)) service.close();
|
||||||
|
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TASK-WP3-01 model configuration API", () => {
|
||||||
|
it("reads the fixed seed and atomically replaces the default", async () => {
|
||||||
|
const { csrf, models, registration, session } = createHarness();
|
||||||
|
const app = await createApp({ browserGate: false, models, networkBoundary: { allowTestPort: true }, registration });
|
||||||
|
const seed = await app.inject({ headers, method: "GET", url: "/api/v1/models" });
|
||||||
|
expect(seed.statusCode).toBe(200);
|
||||||
|
expect(seed.json()).toMatchObject({
|
||||||
|
config_set_version: 1,
|
||||||
|
configured_default_model_id: "gemini-3.1-flash-image-preview",
|
||||||
|
recommended_model_id: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const candidate = structuredClone(models.read().models);
|
||||||
|
candidate[0].enabled = false;
|
||||||
|
candidate[0].is_default = false;
|
||||||
|
candidate[1].is_default = true;
|
||||||
|
const response = await app.inject({
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
cookie: `dada_admin_session=${session.sessionToken}`,
|
||||||
|
"idempotency-key": "wp3-01-api-replace-default-000000000001",
|
||||||
|
"x-csrf-token": csrf,
|
||||||
|
},
|
||||||
|
method: "PUT",
|
||||||
|
payload: { expected_config_set_version: 1, models: editableModels(candidate) },
|
||||||
|
url: "/api/v1/admin/models/configuration",
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toMatchObject({ config_set_version: 2, configured_default_model_id: "gemini-3-pro-image-preview" });
|
||||||
|
expect(response.json().models.filter((model: { enabled: boolean; is_default: boolean }) => model.enabled && model.is_default)).toHaveLength(1);
|
||||||
|
writeEvidence("TDD-WP3-MDL-002-replace-default", "request.json", { expected_config_set_version: 1, models: candidate });
|
||||||
|
writeEvidence("TDD-WP3-MDL-002-replace-default", "response.json", response.json());
|
||||||
|
writeEvidence("TDD-WP3-MDL-002-replace-default", "db-diff.json", {
|
||||||
|
current_version: models.read().config_set_version,
|
||||||
|
audit_rows: (registration.database.prepare("SELECT COUNT(*) AS count FROM admin_operation_logs WHERE operation_type = 'model_configuration_replace'").get() as { count: number }).count,
|
||||||
|
outbox_rows: (registration.database.prepare("SELECT COUNT(*) AS count FROM outbox_events WHERE topic = 'model_config_changed'").get() as { count: number }).count,
|
||||||
|
});
|
||||||
|
writeEvidence("TDD-WP3-MDL-002-replace-default", "transaction-trace.json", { begin_mode: "IMMEDIATE", pointer_switch: "last_statement", visible_default_count: 1 });
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns frozen CAS and priority errors without changing the current set", async () => {
|
||||||
|
const { csrf, models, registration, session } = createHarness();
|
||||||
|
const app = await createApp({ browserGate: false, models, networkBoundary: { allowTestPort: true }, registration });
|
||||||
|
const candidate = structuredClone(models.read().models);
|
||||||
|
candidate[1].recommendation_priority = candidate[0].recommendation_priority;
|
||||||
|
const invalid = await app.inject({
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
cookie: `dada_admin_session=${session.sessionToken}`,
|
||||||
|
"idempotency-key": "wp3-01-api-invalid-priority-000000000001",
|
||||||
|
"x-csrf-token": csrf,
|
||||||
|
},
|
||||||
|
method: "PUT",
|
||||||
|
payload: { expected_config_set_version: 1, models: editableModels(candidate) },
|
||||||
|
url: "/api/v1/admin/models/configuration",
|
||||||
|
});
|
||||||
|
expect(invalid.statusCode).toBe(409);
|
||||||
|
expect(invalid.json()).toMatchObject({ error: { code: "MODEL_RECOMMENDATION_PRIORITY_CONFLICT" } });
|
||||||
|
|
||||||
|
const fractionalModels = editableModels(models.read().models);
|
||||||
|
fractionalModels[1].recommendation_priority = 1.5;
|
||||||
|
const fractional = await app.inject({
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
cookie: `dada_admin_session=${session.sessionToken}`,
|
||||||
|
"idempotency-key": "wp3-01-api-fractional-priority-000000001",
|
||||||
|
"x-csrf-token": csrf,
|
||||||
|
},
|
||||||
|
method: "PUT",
|
||||||
|
payload: { expected_config_set_version: 1, models: fractionalModels },
|
||||||
|
url: "/api/v1/admin/models/configuration",
|
||||||
|
});
|
||||||
|
expect(fractional.statusCode).toBe(400);
|
||||||
|
expect(fractional.json()).toMatchObject({ error: { code: "MODEL_RECOMMENDATION_PRIORITY_INVALID" } });
|
||||||
|
|
||||||
|
const missingModels = editableModels(models.read().models) as Array<Record<string, unknown>>;
|
||||||
|
delete missingModels[1].recommendation_priority;
|
||||||
|
const missing = await app.inject({
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
cookie: `dada_admin_session=${session.sessionToken}`,
|
||||||
|
"idempotency-key": "wp3-01-api-missing-priority-00000000001",
|
||||||
|
"x-csrf-token": csrf,
|
||||||
|
},
|
||||||
|
method: "PUT",
|
||||||
|
payload: { expected_config_set_version: 1, models: missingModels },
|
||||||
|
url: "/api/v1/admin/models/configuration",
|
||||||
|
});
|
||||||
|
expect(missing.statusCode).toBe(400);
|
||||||
|
expect(missing.json()).toMatchObject({ error: { code: "MODEL_RECOMMENDATION_PRIORITY_INVALID" } });
|
||||||
|
|
||||||
|
const advanced = structuredClone(models.read().models);
|
||||||
|
advanced[1].display_name = "CAS 已提交";
|
||||||
|
models.replace({ actorId: randomUUID(), expectedConfigSetVersion: 1, idempotencyKey: "wp3-01-api-cas-advance-000000000000001", models: advanced });
|
||||||
|
const stale = await app.inject({
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
cookie: `dada_admin_session=${session.sessionToken}`,
|
||||||
|
"idempotency-key": "wp3-01-api-cas-stale-00000000000000000001",
|
||||||
|
"x-csrf-token": csrf,
|
||||||
|
},
|
||||||
|
method: "PUT",
|
||||||
|
payload: { expected_config_set_version: 1, models: editableModels(models.read().models) },
|
||||||
|
url: "/api/v1/admin/models/configuration",
|
||||||
|
});
|
||||||
|
expect(stale.statusCode).toBe(412);
|
||||||
|
expect(stale.json()).toMatchObject({ error: { code: "MODEL_CONFIG_VERSION_CONFLICT", details: { latest_version: 2 } } });
|
||||||
|
expect(models.read().config_set_version).toBe(2);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { mkdirSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
import { createServer, type ViteDevServer } from "vite";
|
||||||
|
|
||||||
|
let vite: ViteDevServer;
|
||||||
|
let webUrl: string;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
vite = await createServer({ configFile: resolve("apps/web/vite.config.ts"), root: resolve("apps/web"), server: { host: "127.0.0.1", port: 0 } });
|
||||||
|
await vite.listen();
|
||||||
|
const address = vite.httpServer?.address();
|
||||||
|
if (!address || typeof address === "string") throw new Error("Vite did not expose a test port.");
|
||||||
|
webUrl = `http://127.0.0.1:${address.port}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => vite.close());
|
||||||
|
|
||||||
|
const adminSession = {
|
||||||
|
acknowledged_private_content_notice_version: null,
|
||||||
|
admin: { role: "super_admin", status: "active", user_id: "00000000-0000-4000-8000-000000000901" },
|
||||||
|
audience: "admin", authenticated: true,
|
||||||
|
csrf_token: "csrf-admin-model-fixture-000000000000000000000000000000000",
|
||||||
|
current_private_content_notice_version: null, expires_at: "2026-09-02T12:00:00.000Z", notice_acknowledged: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function configuration(version = 1) {
|
||||||
|
const ids = ["gemini-3.1-flash-image-preview", "gemini-3-pro-image-preview", "gpt-image-2"];
|
||||||
|
return {
|
||||||
|
config_set_version: version,
|
||||||
|
configured_default_model_id: version === 1 ? ids[0] : ids[1],
|
||||||
|
recommended_model_id: null,
|
||||||
|
models: ids.map((modelId, index) => ({
|
||||||
|
config_version: version === 1 ? 1 : index < 2 ? 2 : 1,
|
||||||
|
contract_evidence_ref: null,
|
||||||
|
contract_validation_status: "unverified",
|
||||||
|
credit_cost: 1,
|
||||||
|
display_name: ["Gemini 3.1 Flash Image Preview", "Gemini 3 Pro Image Preview", "GPT Image 2"][index],
|
||||||
|
enabled: version === 1 || index !== 0,
|
||||||
|
error_mapping_profile: { timeout: "upstream_timeout" },
|
||||||
|
gateway_account_ref: "mock-gateway",
|
||||||
|
is_default: version === 1 ? index === 0 : index === 1,
|
||||||
|
model_id: modelId,
|
||||||
|
prompt_max_length: 1_000,
|
||||||
|
recommendation_priority: index + 1,
|
||||||
|
reference_limits: { max_file_bytes: 1_024, max_files: 2, max_total_bytes: 2_048 },
|
||||||
|
route_profile: { endpoint: "https://mock.invalid/v1/images" },
|
||||||
|
runtime_availability: { available_for_new_jobs: false, checked_at: "2026-08-02T12:00:00.000Z", reason: "contract_unverified" },
|
||||||
|
safety_source: "provider",
|
||||||
|
supported_ratios: ["3:4", "1:1", "4:3", "9:16"],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function routeBase(page: Page) {
|
||||||
|
await page.route("**/api/v1/admin-auth/session", (route) => route.fulfill({ body: JSON.stringify(adminSession), contentType: "application/json", status: 200 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
test("TDD-WP3-MDL-002-replace-default edits and submits one complete model set", async ({ page }) => {
|
||||||
|
await routeBase(page);
|
||||||
|
await page.route("**/api/v1/models", (route) => route.fulfill({ body: JSON.stringify(configuration()), contentType: "application/json", status: 200 }));
|
||||||
|
let payload: { expected_config_set_version: number; models: Array<Record<string, unknown>> } | undefined;
|
||||||
|
await page.route("**/api/v1/admin/models/configuration", (route) => {
|
||||||
|
payload = route.request().postDataJSON() as typeof payload;
|
||||||
|
return route.fulfill({ body: JSON.stringify(configuration(2)), contentType: "application/json", status: 200 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`${webUrl}/admin/models`);
|
||||||
|
await expect(page.getByRole("heading", { name: "模型配置" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("columnheader", { name: "默认", exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole("columnheader", { name: "当前推荐" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("columnheader", { name: "运行时可用" })).toBeVisible();
|
||||||
|
await expect(page.getByText("当前推荐").locator("..").getByText("无", { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
const proPriority = page.getByLabel("Gemini 3 Pro Image Preview 推荐优先级");
|
||||||
|
await proPriority.fill("1");
|
||||||
|
await expect(page.getByText("推荐优先级不能重复。")).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "保存完整配置集合" })).toBeDisabled();
|
||||||
|
await proPriority.fill("2");
|
||||||
|
await page.getByLabel("设为默认 Gemini 3 Pro Image Preview").check();
|
||||||
|
await page.getByLabel("启用 Gemini 3.1 Flash Image Preview").uncheck();
|
||||||
|
await page.getByRole("button", { name: "保存完整配置集合" }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText("模型配置已原子保存并记录审计。")).toBeVisible();
|
||||||
|
expect(payload?.expected_config_set_version).toBe(1);
|
||||||
|
expect(payload?.models).toHaveLength(3);
|
||||||
|
expect(payload?.models[0]).not.toHaveProperty("runtime_availability");
|
||||||
|
expect(payload?.models.filter((model) => model.enabled && model.is_default)).toHaveLength(1);
|
||||||
|
const evidenceRoot = process.env.DADA_EVIDENCE_DIR_MODELS;
|
||||||
|
if (evidenceRoot) {
|
||||||
|
const directory = resolve(evidenceRoot, "TDD-WP3-MDL-002-replace-default", "screenshots");
|
||||||
|
mkdirSync(directory, { recursive: true });
|
||||||
|
await page.screenshot({ fullPage: true, path: resolve(directory, "model-set-saved.png") });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("TDD-WP3-MDL-002-cas-conflict blocks saving until the complete set is refreshed", async ({ page }) => {
|
||||||
|
await routeBase(page);
|
||||||
|
await page.route("**/api/v1/models", (route) => route.fulfill({ body: JSON.stringify(configuration()), contentType: "application/json", status: 200 }));
|
||||||
|
await page.route("**/api/v1/admin/models/configuration", (route) => route.fulfill({
|
||||||
|
body: JSON.stringify({ error: { code: "MODEL_CONFIG_VERSION_CONFLICT", correlation_id: "00000000-0000-4000-8000-000000000902", details: { latest_version: 2 }, message_key: "MODEL_CONFIG_VERSION_CONFLICT" } }),
|
||||||
|
contentType: "application/json", status: 412,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await page.goto(`${webUrl}/admin/models`);
|
||||||
|
await page.getByRole("button", { name: "保存完整配置集合" }).click();
|
||||||
|
await expect(page.getByRole("alert")).toContainText("请刷新最新配置后重新编辑");
|
||||||
|
await expect(page.getByRole("button", { name: "保存完整配置集合" })).toBeDisabled();
|
||||||
|
await expect(page.getByRole("button", { name: "刷新最新配置" })).toBeVisible();
|
||||||
|
});
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { ModelConfigurationError, ModelConfigurationService } from "../../apps/api/src/model-configuration.js";
|
||||||
|
import { RegistrationService } from "../../apps/api/src/registration.js";
|
||||||
|
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
|
||||||
|
|
||||||
|
const roots: string[] = [];
|
||||||
|
const services: RegistrationService[] = [];
|
||||||
|
const now = Date.parse("2026-08-02T12:00:00.000Z");
|
||||||
|
|
||||||
|
function writeEvidence(caseId: string, file: string, value: unknown) {
|
||||||
|
const root = process.env.DADA_EVIDENCE_DIR_MODELS;
|
||||||
|
if (!root) return;
|
||||||
|
const directory = resolve(root, caseId);
|
||||||
|
mkdirSync(directory, { recursive: true });
|
||||||
|
writeFileSync(resolve(directory, file), `${JSON.stringify(value, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHarness() {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "dada-wp3-01-models-"));
|
||||||
|
roots.push(root);
|
||||||
|
const registration = new RegistrationService({
|
||||||
|
challengePepper: Buffer.alloc(32, 0x91),
|
||||||
|
clock: () => now,
|
||||||
|
currentPrivacyNoticeVersion: "p0a-registration-notice-v1",
|
||||||
|
databasePath: join(root, "dada.sqlite3"),
|
||||||
|
invitePepper: Buffer.alloc(32, 0x92),
|
||||||
|
resend: new MockResendAdapter(),
|
||||||
|
sessionPepper: Buffer.alloc(32, 0x93),
|
||||||
|
});
|
||||||
|
services.push(registration);
|
||||||
|
return { models: new ModelConfigurationService({ database: registration.database, clock: () => now }), registration };
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const service of services.splice(0)) service.close();
|
||||||
|
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function cloneCandidate(models: ReturnType<ModelConfigurationService["read"]>["models"]) {
|
||||||
|
return structuredClone(models);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("TDD-WP3-MDL-001 seed and read", () => {
|
||||||
|
it("seeds exactly three immutable models with one enabled default and unavailable unverified runtime", () => {
|
||||||
|
const { models } = createHarness();
|
||||||
|
const result = models.read();
|
||||||
|
|
||||||
|
expect(result.config_set_version).toBe(1);
|
||||||
|
expect(result.configured_default_model_id).toBe("gemini-3.1-flash-image-preview");
|
||||||
|
expect(result.recommended_model_id).toBeNull();
|
||||||
|
expect(result.models).toHaveLength(3);
|
||||||
|
expect(result.models.map((model) => model.recommendation_priority)).toEqual([1, 2, 3]);
|
||||||
|
expect(result.models.filter((model) => model.enabled && model.is_default)).toHaveLength(1);
|
||||||
|
expect(result.models.every((model) => model.contract_validation_status === "unverified")).toBe(true);
|
||||||
|
expect(result.models.every((model) => model.runtime_availability.available_for_new_jobs === false)).toBe(true);
|
||||||
|
expect(result.models.every((model) => model.runtime_availability.reason === "contract_unverified")).toBe(true);
|
||||||
|
writeEvidence("TDD-WP3-MDL-001-seed-read", "response.json", result);
|
||||||
|
writeEvidence("TDD-WP3-MDL-001-seed-read", "db-diff.json", {
|
||||||
|
current_sets: 1,
|
||||||
|
immutable_versions: (models.database.prepare("SELECT COUNT(*) AS count FROM model_config_versions").get() as { count: number }).count,
|
||||||
|
runtime_rows: (models.database.prepare("SELECT COUNT(*) AS count FROM model_runtime_availability").get() as { count: number }).count,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enforces immutable sets, members, and config versions in SQLite", () => {
|
||||||
|
const { models } = createHarness();
|
||||||
|
expect(() => models.database.prepare("UPDATE model_config_sets SET created_by = 'tampered'").run()).toThrow();
|
||||||
|
expect(() => models.database.prepare("UPDATE model_config_versions SET display_name = 'tampered'").run()).toThrow();
|
||||||
|
expect(() => models.database.prepare("DELETE FROM model_config_set_members").run()).toThrow();
|
||||||
|
expect(models.read().models).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TDD-WP3-MDL-002 atomic replacement", () => {
|
||||||
|
it("requires a legal replacement when disabling the current default", () => {
|
||||||
|
const { models } = createHarness();
|
||||||
|
const candidate = cloneCandidate(models.read().models);
|
||||||
|
candidate[0].enabled = false;
|
||||||
|
candidate[0].is_default = false;
|
||||||
|
|
||||||
|
expect(() => models.replace({ expectedConfigSetVersion: 1, idempotencyKey: "wp3-01-replace-required-000000000001", models: candidate, actorId: randomUUID() }))
|
||||||
|
.toThrowError(ModelConfigurationError);
|
||||||
|
try {
|
||||||
|
models.replace({ expectedConfigSetVersion: 1, idempotencyKey: "wp3-01-replace-required-000000000002", models: candidate, actorId: randomUUID() });
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toMatchObject({ code: "MODEL_DEFAULT_REPLACEMENT_REQUIRED" });
|
||||||
|
}
|
||||||
|
const illegal = cloneCandidate(models.read().models);
|
||||||
|
illegal[0].enabled = false;
|
||||||
|
illegal[0].is_default = false;
|
||||||
|
illegal[1].enabled = false;
|
||||||
|
illegal[1].is_default = true;
|
||||||
|
expect(() => models.replace({ expectedConfigSetVersion: 1, idempotencyKey: "wp3-01-replace-invalid-00000000000001", models: illegal, actorId: randomUUID() }))
|
||||||
|
.toThrowError(expect.objectContaining({ code: "MODEL_DEFAULT_REPLACEMENT_INVALID" }));
|
||||||
|
expect(models.read().config_set_version).toBe(1);
|
||||||
|
writeEvidence("TDD-WP3-MDL-002-invalid-set", "response.json", {
|
||||||
|
invalid_replacement: "MODEL_DEFAULT_REPLACEMENT_INVALID",
|
||||||
|
missing_replacement: "MODEL_DEFAULT_REPLACEMENT_REQUIRED",
|
||||||
|
});
|
||||||
|
writeEvidence("TDD-WP3-MDL-002-invalid-set", "db-diff.json", { current_version_before: 1, current_version_after: 1, writes: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid priorities and CAS conflicts without partial writes", () => {
|
||||||
|
const { models } = createHarness();
|
||||||
|
const original = models.read();
|
||||||
|
const beforeInvalid = {
|
||||||
|
audit: (models.database.prepare("SELECT COUNT(*) AS count FROM admin_operation_logs").get() as { count: number }).count,
|
||||||
|
outbox: (models.database.prepare("SELECT COUNT(*) AS count FROM outbox_events").get() as { count: number }).count,
|
||||||
|
sets: (models.database.prepare("SELECT COUNT(*) AS count FROM model_config_sets").get() as { count: number }).count,
|
||||||
|
versions: (models.database.prepare("SELECT COUNT(*) AS count FROM model_config_versions").get() as { count: number }).count,
|
||||||
|
};
|
||||||
|
const invalid = cloneCandidate(original.models);
|
||||||
|
invalid[1].recommendation_priority = invalid[0].recommendation_priority;
|
||||||
|
expect(() => models.replace({ expectedConfigSetVersion: 1, idempotencyKey: "wp3-01-priority-conflict-000000000001", models: invalid, actorId: randomUUID() }))
|
||||||
|
.toThrowError(ModelConfigurationError);
|
||||||
|
expect(models.read().config_set_version).toBe(1);
|
||||||
|
expect(models.read().models.map((model) => model.recommendation_priority)).toEqual([1, 2, 3]);
|
||||||
|
expect({
|
||||||
|
audit: (models.database.prepare("SELECT COUNT(*) AS count FROM admin_operation_logs").get() as { count: number }).count,
|
||||||
|
outbox: (models.database.prepare("SELECT COUNT(*) AS count FROM outbox_events").get() as { count: number }).count,
|
||||||
|
sets: (models.database.prepare("SELECT COUNT(*) AS count FROM model_config_sets").get() as { count: number }).count,
|
||||||
|
versions: (models.database.prepare("SELECT COUNT(*) AS count FROM model_config_versions").get() as { count: number }).count,
|
||||||
|
}).toEqual(beforeInvalid);
|
||||||
|
|
||||||
|
const next = cloneCandidate(original.models);
|
||||||
|
next[1].display_name = "已更新展示名";
|
||||||
|
models.replace({ expectedConfigSetVersion: 1, idempotencyKey: "wp3-01-cas-a-00000000000000000001", models: next, actorId: randomUUID() });
|
||||||
|
expect(() => models.replace({ expectedConfigSetVersion: 1, idempotencyKey: "wp3-01-cas-b-00000000000000000001", models: next, actorId: randomUUID() }))
|
||||||
|
.toThrowError(ModelConfigurationError);
|
||||||
|
try {
|
||||||
|
models.replace({ expectedConfigSetVersion: 1, idempotencyKey: "wp3-01-cas-c-00000000000000000001", models: next, actorId: randomUUID() });
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toMatchObject({ code: "MODEL_CONFIG_VERSION_CONFLICT" });
|
||||||
|
}
|
||||||
|
expect(models.read().config_set_version).toBe(2);
|
||||||
|
expect(models.read().models.find((model) => model.model_id === "gemini-3-pro-image-preview")?.display_name).toBe("已更新展示名");
|
||||||
|
expect((models.database.prepare("SELECT COUNT(*) AS count FROM admin_operation_logs WHERE operation_type = 'model_configuration_replace'").get() as { count: number }).count).toBe(1);
|
||||||
|
expect((models.database.prepare("SELECT COUNT(*) AS count FROM outbox_events WHERE topic = 'model_config_changed'").get() as { count: number }).count).toBe(1);
|
||||||
|
writeEvidence("TDD-WP3-MDL-002-cas-conflict", "response-a.json", { config_set_version: 2, status: "saved" });
|
||||||
|
writeEvidence("TDD-WP3-MDL-002-cas-conflict", "response-b.json", { code: "MODEL_CONFIG_VERSION_CONFLICT", latest_version: 2 });
|
||||||
|
writeEvidence("TDD-WP3-MDL-002-cas-conflict", "db-diff.json", { audit_rows: 1, current_version: 2, outbox_rows: 1, stale_writes: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets contract status and runtime availability when a contract field changes", () => {
|
||||||
|
const { models } = createHarness();
|
||||||
|
const candidate = cloneCandidate(models.read().models);
|
||||||
|
candidate[0].contract_validation_status = "verified";
|
||||||
|
candidate[0].contract_evidence_ref = "fixture-verified";
|
||||||
|
candidate[0].runtime_availability = { available_for_new_jobs: true, checked_at: new Date(now).toISOString(), reason: "available" };
|
||||||
|
models.replace({ expectedConfigSetVersion: 1, idempotencyKey: "wp3-01-verified-00000000000000000001", models: candidate, actorId: randomUUID() });
|
||||||
|
const changed = cloneCandidate(models.read().models);
|
||||||
|
changed[0].route_profile.endpoint = "https://mock.invalid/v2/images";
|
||||||
|
const result = models.replace({ expectedConfigSetVersion: 2, idempotencyKey: "wp3-01-contract-change-00000000000000000001", models: changed, actorId: randomUUID() });
|
||||||
|
|
||||||
|
expect(result.config_set_version).toBe(3);
|
||||||
|
const model = models.read().models.find((entry) => entry.model_id === "gemini-3.1-flash-image-preview");
|
||||||
|
expect(model).toMatchObject({ contract_validation_status: "unverified", enabled: true, is_default: true, recommendation_priority: 1 });
|
||||||
|
expect(model?.runtime_availability).toMatchObject({ available_for_new_jobs: false, reason: "contract_unverified" });
|
||||||
|
writeEvidence("TDD-WP3-MDL-002-contract-change", "response.json", result);
|
||||||
|
writeEvidence("TDD-WP3-MDL-002-contract-change", "db-diff.json", {
|
||||||
|
changed_model_config_version: model?.config_version,
|
||||||
|
config_set_version: result.config_set_version,
|
||||||
|
runtime_reason: model?.runtime_availability.reason,
|
||||||
|
});
|
||||||
|
writeEvidence("TDD-WP3-MDL-002-contract-change", "external-calls.json", { calls: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ModelConfigurationError,
|
||||||
|
modelIds,
|
||||||
|
validateModelConfigurationCandidateSet,
|
||||||
|
type ModelConfigCandidate,
|
||||||
|
} from "../../apps/api/src/model-configuration.js";
|
||||||
|
|
||||||
|
function candidateSet(): ModelConfigCandidate[] {
|
||||||
|
return modelIds.map((modelId, index) => ({
|
||||||
|
model_id: modelId,
|
||||||
|
display_name: modelId,
|
||||||
|
enabled: true,
|
||||||
|
is_default: index === 0,
|
||||||
|
recommendation_priority: index + 1,
|
||||||
|
route_profile: { endpoint: "https://mock.invalid/v1/images" },
|
||||||
|
gateway_account_ref: "mock-gateway",
|
||||||
|
error_mapping_profile: { timeout: "upstream_timeout" },
|
||||||
|
credit_cost: 1,
|
||||||
|
supported_ratios: ["3:4", "1:1", "4:3", "9:16"],
|
||||||
|
reference_limits: { max_file_bytes: 1_024, max_files: 2, max_total_bytes: 2_048 },
|
||||||
|
prompt_max_length: 1_000,
|
||||||
|
safety_source: "provider",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("TDD-WP3-MDL-002 invalid full configuration sets", () => {
|
||||||
|
it.each([
|
||||||
|
["missing", undefined],
|
||||||
|
["fractional", 1.5],
|
||||||
|
["zero", 0],
|
||||||
|
["negative", -1],
|
||||||
|
])("rejects a %s recommendation priority", (_name, value) => {
|
||||||
|
const models = candidateSet();
|
||||||
|
models[1].recommendation_priority = value as number;
|
||||||
|
expect(() => validateModelConfigurationCandidateSet(models)).toThrowError(
|
||||||
|
expect.objectContaining({ code: "MODEL_RECOMMENDATION_PRIORITY_INVALID" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects duplicate priorities", () => {
|
||||||
|
const models = candidateSet();
|
||||||
|
models[1].recommendation_priority = models[0].recommendation_priority;
|
||||||
|
expect(() => validateModelConfigurationCandidateSet(models)).toThrowError(
|
||||||
|
expect.objectContaining({ code: "MODEL_RECOMMENDATION_PRIORITY_CONFLICT" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["missing model", "unknown model"])("rejects a candidate with a %s", (scenario) => {
|
||||||
|
const models = candidateSet();
|
||||||
|
if (scenario === "missing model") models.pop();
|
||||||
|
else models[2].model_id = "unknown-model";
|
||||||
|
expect(() => validateModelConfigurationCandidateSet(models)).toThrowError(ModelConfigurationError);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user