feat: complete TASK-WP3-01 model configuration
This commit is contained in:
@@ -49,6 +49,17 @@ import {
|
||||
LogoutResponseSchema,
|
||||
ModelConfigSseEventSchema,
|
||||
ModelRuntimeSseEventSchema,
|
||||
ModelIdSchema,
|
||||
ModelContractValidationStatusSchema,
|
||||
ModelRuntimeReasonSchema,
|
||||
ModelReferenceLimitsSchema,
|
||||
ModelRuntimeAvailabilitySchema,
|
||||
ModelConfigurationResponseSchema,
|
||||
ModelConfigSchema,
|
||||
ModelConfigCandidateSchema,
|
||||
ModelConfigUpdateRequestSchema,
|
||||
ModelParamsSchema,
|
||||
ModelConfigUpdateHeadersSchema,
|
||||
FailedEmptyTrashRequestSchema,
|
||||
FailedEmptyTrashResponseSchema,
|
||||
ExportFormatSchema,
|
||||
@@ -151,6 +162,8 @@ import {
|
||||
registrationFieldError,
|
||||
} from "./registration-errors.js";
|
||||
import type { RegistrationService } from "./registration.js";
|
||||
import { ModelConfigurationError } from "./model-configuration.js";
|
||||
import type { ModelConfigurationService } from "./model-configuration.js";
|
||||
|
||||
const defaultBootstrap: BootstrapResponse = {
|
||||
app_version: "0.0.0",
|
||||
@@ -173,6 +186,7 @@ export interface CreateAppOptions {
|
||||
eventHub?: EventHub;
|
||||
generations?: GenerationSubmissionService;
|
||||
latestExports?: LatestExportService;
|
||||
models?: ModelConfigurationService;
|
||||
networkBoundary?: NetworkBoundaryOptions;
|
||||
publicAssets?: PublicAssetResolver;
|
||||
projects?: ProjectService;
|
||||
@@ -316,6 +330,22 @@ function creditFailure(reply: FastifyReply, correlationId: string, error: unknow
|
||||
return reply.code(status).send(null);
|
||||
}
|
||||
|
||||
function modelConfigurationFailure(reply: FastifyReply, correlationId: string, error: unknown) {
|
||||
if (!(error instanceof ModelConfigurationError)) {
|
||||
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId }));
|
||||
}
|
||||
const status = error.code === "MODEL_CONFIG_VERSION_CONFLICT" ? 412
|
||||
: error.code === "MODEL_RECOMMENDATION_PRIORITY_INVALID" ? 400
|
||||
: error.code === "IDEMPOTENCY_KEY_CONFLICT" ? 409 : 409;
|
||||
const details = {
|
||||
...(error.code === "MODEL_CONFIG_VERSION_CONFLICT" && typeof error.details.latest_version === "number"
|
||||
? { latest_version: error.details.latest_version } : {}),
|
||||
...(Array.isArray(error.details.conflict_model_ids) ? { conflict_model_ids: error.details.conflict_model_ids as string[] } : {}),
|
||||
...(Array.isArray(error.details.field_errors) ? { field_errors: error.details.field_errors as Array<{ field: string; message_key: string }> } : {}),
|
||||
};
|
||||
return reply.code(status).send(createErrorEnvelope({ code: error.code, correlationId, details }));
|
||||
}
|
||||
|
||||
function generationTaskResponse(task: GenerationTaskView) {
|
||||
return {
|
||||
confirmed_credit_cost: task.confirmedCreditCost,
|
||||
@@ -698,6 +728,17 @@ export async function createApp(options: CreateAppOptions = {}) {
|
||||
ProjectStateConflictResponseSchema,
|
||||
FailedEmptyTrashRequestSchema,
|
||||
FailedEmptyTrashResponseSchema,
|
||||
ModelIdSchema,
|
||||
ModelContractValidationStatusSchema,
|
||||
ModelRuntimeReasonSchema,
|
||||
ModelReferenceLimitsSchema,
|
||||
ModelRuntimeAvailabilitySchema,
|
||||
ModelConfigSchema,
|
||||
ModelConfigCandidateSchema,
|
||||
ModelConfigurationResponseSchema,
|
||||
ModelParamsSchema,
|
||||
ModelConfigUpdateRequestSchema,
|
||||
ModelConfigUpdateHeadersSchema,
|
||||
]) {
|
||||
app.addSchema(schema);
|
||||
}
|
||||
@@ -2163,6 +2204,107 @@ export async function createApp(options: CreateAppOptions = {}) {
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/api/v1/models",
|
||||
{
|
||||
schema: {
|
||||
operationId: "getModels",
|
||||
response: { 200: ModelConfigurationResponseSchema, 503: ErrorEnvelopeSchema, 426: ErrorEnvelopeSchema },
|
||||
tags: ["Models"],
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (!options.models) return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
|
||||
try {
|
||||
return options.models.read();
|
||||
} catch {
|
||||
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/api/v1/models/:model_id",
|
||||
{
|
||||
schema: {
|
||||
operationId: "getModel",
|
||||
params: ModelParamsSchema,
|
||||
response: { 200: ModelConfigSchema, 404: Type.Null(), 503: ErrorEnvelopeSchema, 426: ErrorEnvelopeSchema },
|
||||
tags: ["Models"],
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (!options.models) return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
|
||||
const model = options.models.readModel((request.params as { model_id: string }).model_id);
|
||||
return model ?? reply.code(404).send(null);
|
||||
},
|
||||
);
|
||||
|
||||
app.put(
|
||||
"/api/v1/admin/models/configuration",
|
||||
{
|
||||
attachValidation: true,
|
||||
schema: {
|
||||
operationId: "replaceModelConfiguration",
|
||||
body: ModelConfigUpdateRequestSchema,
|
||||
headers: ModelConfigUpdateHeadersSchema,
|
||||
response: {
|
||||
200: ModelConfigurationResponseSchema,
|
||||
400: ErrorEnvelopeSchema,
|
||||
401: ErrorEnvelopeSchema,
|
||||
403: ErrorEnvelopeSchema,
|
||||
409: ErrorEnvelopeSchema,
|
||||
412: ErrorEnvelopeSchema,
|
||||
429: ErrorEnvelopeSchema,
|
||||
503: ErrorEnvelopeSchema,
|
||||
426: ErrorEnvelopeSchema,
|
||||
},
|
||||
tags: ["Admin Models"],
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (request.validationError) {
|
||||
const validation = JSON.stringify(request.validationError.validation ?? []);
|
||||
const priority = validation.includes("recommendation_priority");
|
||||
return reply.code(priority ? 400 : 409).send(createErrorEnvelope({
|
||||
code: priority ? "MODEL_RECOMMENDATION_PRIORITY_INVALID" : "MODEL_DEFAULT_REPLACEMENT_INVALID",
|
||||
correlationId: request.id,
|
||||
...(priority ? { details: { field_errors: [{ field: "models.recommendation_priority", message_key: "model.priority.invalid" }] } } : {}),
|
||||
}));
|
||||
}
|
||||
if (!options.models || !options.registration) {
|
||||
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
|
||||
}
|
||||
const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName);
|
||||
const session = token ? options.registration.readAdminSession(token) : undefined;
|
||||
if (!token || !session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
|
||||
try {
|
||||
const headers = request.headers as { "idempotency-key": string; "x-csrf-token": string };
|
||||
const admin = options.registration.authorizeAdminMutation({ csrfToken: headers["x-csrf-token"], sessionToken: token });
|
||||
const body = request.body as {
|
||||
expected_config_set_version: number;
|
||||
models: Array<Record<string, unknown>>;
|
||||
};
|
||||
const result = options.models.replace({
|
||||
actorId: admin.userId,
|
||||
expectedConfigSetVersion: body.expected_config_set_version,
|
||||
idempotencyKey: headers["idempotency-key"],
|
||||
models: body.models as never,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error instanceof RegistrationError) {
|
||||
return reply.code(error.httpStatus).send(createErrorEnvelope({
|
||||
code: error.code,
|
||||
correlationId: request.id,
|
||||
details: { field_errors: [registrationFieldError(error.reason)] },
|
||||
}));
|
||||
}
|
||||
return modelConfigurationFailure(reply, request.id, error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/api/v1/bootstrap",
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user