From 165e3680082a850f3198ea7cae9717ae574d3de3 Mon Sep 17 00:00:00 2001
From: Andre-Philippe Paquet
Date: Thu, 12 Nov 2026 13:14:46 -0500
Subject: [PATCH 01/40] unreviewed: refactor(proto): share the parameter
definition with engines
---
.../gen/dotcore/catalog/v1/application_pb.ts | 59 ++----
.../dotcore/catalog/v1/engine_release_pb.ts | 193 +-----------------
.../src/gen/dotcore/catalog/v1/model_pb.ts | 10 +-
.../gen/dotcore/catalog/v1/parameter_pb.ts | 82 +++++++-
.../src/gen/dotcore/model/v1/deployment_pb.ts | 10 +-
.../src/gen/dotcore/model/v1/service_pb.ts | 18 +-
proto/dotcore/catalog/v1/application.proto | 14 +-
proto/dotcore/catalog/v1/engine_release.proto | 66 +-----
proto/dotcore/catalog/v1/model.proto | 3 +-
proto/dotcore/catalog/v1/parameter.proto | 29 ++-
proto/dotcore/model/v1/deployment.proto | 3 +-
proto/dotcore/model/v1/service.proto | 8 +-
.../telemetry/v1/workload_resource.proto | 4 +-
13 files changed, 162 insertions(+), 337 deletions(-)
diff --git a/frontend/src/gen/dotcore/catalog/v1/application_pb.ts b/frontend/src/gen/dotcore/catalog/v1/application_pb.ts
index 2866da95..3b34fb91 100644
--- a/frontend/src/gen/dotcore/catalog/v1/application_pb.ts
+++ b/frontend/src/gen/dotcore/catalog/v1/application_pb.ts
@@ -18,7 +18,7 @@ import type { Message } from "@bufbuild/protobuf";
* Describes the file dotcore/catalog/v1/application.proto.
*/
export const file_dotcore_catalog_v1_application: GenFile = /*@__PURE__*/
- fileDesc("CiRkb3Rjb3JlL2NhdGFsb2cvdjEvYXBwbGljYXRpb24ucHJvdG8SEmRvdGNvcmUuY2F0YWxvZy52MSKlAQoLQXBwbGljYXRpb24SFgoOYXBwbGljYXRpb25faWQYASABKAkSFAoMZGlzcGxheV9uYW1lGAIgASgJEhMKC2Rlc2NyaXB0aW9uGAUgASgJEg0KBW5vdGVzGAYgASgJEg8KB3ZlcnNpb24YAyABKAkSMwoFZ3JhcGgYBCABKAsyJC5kb3Rjb3JlLmNhdGFsb2cudjEuQXBwbGljYXRpb25HcmFwaCLUAQoQQXBwbGljYXRpb25HcmFwaBI4CghzZXJ2aWNlcxgBIAMoCzImLmRvdGNvcmUuY2F0YWxvZy52MS5BcHBsaWNhdGlvblNlcnZpY2USSAoQcHJpbWFyeV9lbmRwb2ludBgCIAEoCzIuLmRvdGNvcmUuY2F0YWxvZy52MS5BcHBsaWNhdGlvblByaW1hcnlFbmRwb2ludBI8CgpwYXJhbWV0ZXJzGAMgAygLMiguZG90Y29yZS5jYXRhbG9nLnYxLkFwcGxpY2F0aW9uUGFyYW1ldGVyImUKFEFwcGxpY2F0aW9uUGFyYW1ldGVyEjsKCmRlZmluaXRpb24YASABKAsyJy5kb3Rjb3JlLmNhdGFsb2cudjEuUGFyYW1ldGVyRGVmaW5pdGlvbhIQCghhZHZhbmNlZBgCIAEoCCKxBAoSQXBwbGljYXRpb25TZXJ2aWNlEhQKDHNlcnZpY2VfbmFtZRgBIAEoCRJACghhcnRpZmFjdBgCIAEoCzIuLmRvdGNvcmUuY2F0YWxvZy52MS5BcHBsaWNhdGlvblNlcnZpY2VBcnRpZmFjdBIPCgdjb21tYW5kGAMgAygJEhEKCWFyZ3VtZW50cxgEIAMoCRI5CgVwb3J0cxgFIAMoCzIqLmRvdGNvcmUuY2F0YWxvZy52MS5BcHBsaWNhdGlvblNlcnZpY2VQb3J0ElMKFmNvbmZpZ3VyYXRpb25fYmluZGluZ3MYBiADKAsyMy5kb3Rjb3JlLmNhdGFsb2cudjEuQXBwbGljYXRpb25Db25maWd1cmF0aW9uQmluZGluZxI8ChBzdG9yYWdlX2JpbmRpbmdzGAcgAygLMiIuZG90Y29yZS5jYXRhbG9nLnYxLlN0b3JhZ2VCaW5kaW5nEkUKD3NlY3JldF9iaW5kaW5ncxgIIAMoCzIsLmRvdGNvcmUuY2F0YWxvZy52MS5BcHBsaWNhdGlvblNlY3JldEJpbmRpbmcSRgoMZGVwZW5kZW5jaWVzGAkgAygLMjAuZG90Y29yZS5jYXRhbG9nLnYxLkFwcGxpY2F0aW9uU2VydmljZURlcGVuZGVuY3kSQgoJcmVhZGluZXNzGAogASgLMi8uZG90Y29yZS5jYXRhbG9nLnYxLkFwcGxpY2F0aW9uU2VydmljZVJlYWRpbmVzcyLIAQoaQXBwbGljYXRpb25TZXJ2aWNlQXJ0aWZhY3QSNAoDb2NpGAEgASgLMiUuZG90Y29yZS5jYXRhbG9nLnYxLk9jaUltYWdlUmVmZXJlbmNlSAASLgoDbml4GAMgASgLMh8uZG90Y29yZS5jYXRhbG9nLnYxLk5peEFydGlmYWN0SAASPAoTc3VwcG9ydGVkX3BsYXRmb3JtcxgCIAMoCzIfLmRvdGNvcmUuY2F0YWxvZy52MS5PY2lQbGF0Zm9ybUIGCgRraW5kIkAKFkFwcGxpY2F0aW9uU2VydmljZVBvcnQSEQoJcG9ydF9uYW1lGAEgASgJEhMKC3BvcnRfbnVtYmVyGAIgASgNInsKHEFwcGxpY2F0aW9uU2VydmljZURlcGVuZGVuY3kSFAoMc2VydmljZV9uYW1lGAEgASgJEkUKCWNvbmRpdGlvbhgCIAEoDjIyLmRvdGNvcmUuY2F0YWxvZy52MS5BcHBsaWNhdGlvbkRlcGVuZGVuY3lDb25kaXRpb24iaAobQXBwbGljYXRpb25TZXJ2aWNlUmVhZGluZXNzEkAKCGh0dHBfZ2V0GAEgASgLMiwuZG90Y29yZS5jYXRhbG9nLnYxLkFwcGxpY2F0aW9uSHR0cFJlYWRpbmVzc0gAQgcKBWNoZWNrIjsKGEFwcGxpY2F0aW9uSHR0cFJlYWRpbmVzcxIRCglwb3J0X25hbWUYASABKAkSDAoEcGF0aBgCIAEoCSLuAQofQXBwbGljYXRpb25Db25maWd1cmF0aW9uQmluZGluZxIUCgxiaW5kaW5nX25hbWUYASABKAkSHAoUZW52aXJvbm1lbnRfdmFyaWFibGUYAiABKAkSFwoNbGl0ZXJhbF92YWx1ZRgDIAEoCUgAEh0KE3Jlc29sdmVkX3ZhbHVlX25hbWUYBCABKAlIABIWCgxwYXJhbWV0ZXJfaWQYBiABKAlIABI9CgVpbnB1dBgFIAEoCzIuLmRvdGNvcmUuY2F0YWxvZy52MS5BcHBsaWNhdGlvbkRlcGxveW1lbnRJbnB1dEIICgZzb3VyY2UicwoaQXBwbGljYXRpb25EZXBsb3ltZW50SW5wdXQSTQoPaW5mZXJlbmNlX3JvdXRlGAIgASgLMjIuZG90Y29yZS5jYXRhbG9nLnYxLkFwcGxpY2F0aW9uSW5mZXJlbmNlUm91dGVJbnB1dEgAQgYKBGtpbmQibQoeQXBwbGljYXRpb25JbmZlcmVuY2VSb3V0ZUlucHV0EksKCnByb2plY3Rpb24YASABKA4yNy5kb3Rjb3JlLmNhdGFsb2cudjEuQXBwbGljYXRpb25JbmZlcmVuY2VSb3V0ZVByb2plY3Rpb24iTgoYQXBwbGljYXRpb25TZWNyZXRCaW5kaW5nEhQKDGJpbmRpbmdfbmFtZRgBIAEoCRIcChRlbnZpcm9ubWVudF92YXJpYWJsZRgCIAEoCSKIAQoaQXBwbGljYXRpb25QcmltYXJ5RW5kcG9pbnQSFAoMc2VydmljZV9uYW1lGAEgASgJEhEKCXBvcnRfbmFtZRgCIAEoCRJBCghwcm90b2NvbBgDIAEoDjIvLmRvdGNvcmUuY2F0YWxvZy52MS5BcHBsaWNhdGlvbkVuZHBvaW50UHJvdG9jb2wqlAEKI0FwcGxpY2F0aW9uSW5mZXJlbmNlUm91dGVQcm9qZWN0aW9uEjYKMkFQUExJQ0FUSU9OX0lORkVSRU5DRV9ST1VURV9QUk9KRUNUSU9OX1VOU1BFQ0lGSUVEEAASNQoxQVBQTElDQVRJT05fSU5GRVJFTkNFX1JPVVRFX1BST0pFQ1RJT05fUk9VVEVfTkFNRRABKqwBCh5BcHBsaWNhdGlvbkRlcGVuZGVuY3lDb25kaXRpb24SMAosQVBQTElDQVRJT05fREVQRU5ERU5DWV9DT05ESVRJT05fVU5TUEVDSUZJRUQQABIsCihBUFBMSUNBVElPTl9ERVBFTkRFTkNZX0NPTkRJVElPTl9TVEFSVEVEEAESKgomQVBQTElDQVRJT05fREVQRU5ERU5DWV9DT05ESVRJT05fUkVBRFkQAip0ChtBcHBsaWNhdGlvbkVuZHBvaW50UHJvdG9jb2wSLQopQVBQTElDQVRJT05fRU5EUE9JTlRfUFJPVE9DT0xfVU5TUEVDSUZJRUQQABImCiJBUFBMSUNBVElPTl9FTkRQT0lOVF9QUk9UT0NPTF9IVFRQEAFiBnByb3RvMw", [file_dotcore_catalog_v1_nix, file_dotcore_catalog_v1_oci, file_dotcore_catalog_v1_parameter, file_dotcore_catalog_v1_storage]);
+ fileDesc("CiRkb3Rjb3JlL2NhdGFsb2cvdjEvYXBwbGljYXRpb24ucHJvdG8SEmRvdGNvcmUuY2F0YWxvZy52MSKlAQoLQXBwbGljYXRpb24SFgoOYXBwbGljYXRpb25faWQYASABKAkSFAoMZGlzcGxheV9uYW1lGAIgASgJEhMKC2Rlc2NyaXB0aW9uGAUgASgJEg0KBW5vdGVzGAYgASgJEg8KB3ZlcnNpb24YAyABKAkSMwoFZ3JhcGgYBCABKAsyJC5kb3Rjb3JlLmNhdGFsb2cudjEuQXBwbGljYXRpb25HcmFwaCLTAQoQQXBwbGljYXRpb25HcmFwaBI4CghzZXJ2aWNlcxgBIAMoCzImLmRvdGNvcmUuY2F0YWxvZy52MS5BcHBsaWNhdGlvblNlcnZpY2USSAoQcHJpbWFyeV9lbmRwb2ludBgCIAEoCzIuLmRvdGNvcmUuY2F0YWxvZy52MS5BcHBsaWNhdGlvblByaW1hcnlFbmRwb2ludBI7CgpwYXJhbWV0ZXJzGAMgAygLMicuZG90Y29yZS5jYXRhbG9nLnYxLlBhcmFtZXRlckRlZmluaXRpb24isQQKEkFwcGxpY2F0aW9uU2VydmljZRIUCgxzZXJ2aWNlX25hbWUYASABKAkSQAoIYXJ0aWZhY3QYAiABKAsyLi5kb3Rjb3JlLmNhdGFsb2cudjEuQXBwbGljYXRpb25TZXJ2aWNlQXJ0aWZhY3QSDwoHY29tbWFuZBgDIAMoCRIRCglhcmd1bWVudHMYBCADKAkSOQoFcG9ydHMYBSADKAsyKi5kb3Rjb3JlLmNhdGFsb2cudjEuQXBwbGljYXRpb25TZXJ2aWNlUG9ydBJTChZjb25maWd1cmF0aW9uX2JpbmRpbmdzGAYgAygLMjMuZG90Y29yZS5jYXRhbG9nLnYxLkFwcGxpY2F0aW9uQ29uZmlndXJhdGlvbkJpbmRpbmcSPAoQc3RvcmFnZV9iaW5kaW5ncxgHIAMoCzIiLmRvdGNvcmUuY2F0YWxvZy52MS5TdG9yYWdlQmluZGluZxJFCg9zZWNyZXRfYmluZGluZ3MYCCADKAsyLC5kb3Rjb3JlLmNhdGFsb2cudjEuQXBwbGljYXRpb25TZWNyZXRCaW5kaW5nEkYKDGRlcGVuZGVuY2llcxgJIAMoCzIwLmRvdGNvcmUuY2F0YWxvZy52MS5BcHBsaWNhdGlvblNlcnZpY2VEZXBlbmRlbmN5EkIKCXJlYWRpbmVzcxgKIAEoCzIvLmRvdGNvcmUuY2F0YWxvZy52MS5BcHBsaWNhdGlvblNlcnZpY2VSZWFkaW5lc3MiyAEKGkFwcGxpY2F0aW9uU2VydmljZUFydGlmYWN0EjQKA29jaRgBIAEoCzIlLmRvdGNvcmUuY2F0YWxvZy52MS5PY2lJbWFnZVJlZmVyZW5jZUgAEi4KA25peBgDIAEoCzIfLmRvdGNvcmUuY2F0YWxvZy52MS5OaXhBcnRpZmFjdEgAEjwKE3N1cHBvcnRlZF9wbGF0Zm9ybXMYAiADKAsyHy5kb3Rjb3JlLmNhdGFsb2cudjEuT2NpUGxhdGZvcm1CBgoEa2luZCJAChZBcHBsaWNhdGlvblNlcnZpY2VQb3J0EhEKCXBvcnRfbmFtZRgBIAEoCRITCgtwb3J0X251bWJlchgCIAEoDSJ7ChxBcHBsaWNhdGlvblNlcnZpY2VEZXBlbmRlbmN5EhQKDHNlcnZpY2VfbmFtZRgBIAEoCRJFCgljb25kaXRpb24YAiABKA4yMi5kb3Rjb3JlLmNhdGFsb2cudjEuQXBwbGljYXRpb25EZXBlbmRlbmN5Q29uZGl0aW9uImgKG0FwcGxpY2F0aW9uU2VydmljZVJlYWRpbmVzcxJACghodHRwX2dldBgBIAEoCzIsLmRvdGNvcmUuY2F0YWxvZy52MS5BcHBsaWNhdGlvbkh0dHBSZWFkaW5lc3NIAEIHCgVjaGVjayI7ChhBcHBsaWNhdGlvbkh0dHBSZWFkaW5lc3MSEQoJcG9ydF9uYW1lGAEgASgJEgwKBHBhdGgYAiABKAki7gEKH0FwcGxpY2F0aW9uQ29uZmlndXJhdGlvbkJpbmRpbmcSFAoMYmluZGluZ19uYW1lGAEgASgJEhwKFGVudmlyb25tZW50X3ZhcmlhYmxlGAIgASgJEhcKDWxpdGVyYWxfdmFsdWUYAyABKAlIABIdChNyZXNvbHZlZF92YWx1ZV9uYW1lGAQgASgJSAASFgoMcGFyYW1ldGVyX2lkGAYgASgJSAASPQoFaW5wdXQYBSABKAsyLi5kb3Rjb3JlLmNhdGFsb2cudjEuQXBwbGljYXRpb25EZXBsb3ltZW50SW5wdXRCCAoGc291cmNlInMKGkFwcGxpY2F0aW9uRGVwbG95bWVudElucHV0Ek0KD2luZmVyZW5jZV9yb3V0ZRgCIAEoCzIyLmRvdGNvcmUuY2F0YWxvZy52MS5BcHBsaWNhdGlvbkluZmVyZW5jZVJvdXRlSW5wdXRIAEIGCgRraW5kIm0KHkFwcGxpY2F0aW9uSW5mZXJlbmNlUm91dGVJbnB1dBJLCgpwcm9qZWN0aW9uGAEgASgOMjcuZG90Y29yZS5jYXRhbG9nLnYxLkFwcGxpY2F0aW9uSW5mZXJlbmNlUm91dGVQcm9qZWN0aW9uIk4KGEFwcGxpY2F0aW9uU2VjcmV0QmluZGluZxIUCgxiaW5kaW5nX25hbWUYASABKAkSHAoUZW52aXJvbm1lbnRfdmFyaWFibGUYAiABKAkiiAEKGkFwcGxpY2F0aW9uUHJpbWFyeUVuZHBvaW50EhQKDHNlcnZpY2VfbmFtZRgBIAEoCRIRCglwb3J0X25hbWUYAiABKAkSQQoIcHJvdG9jb2wYAyABKA4yLy5kb3Rjb3JlLmNhdGFsb2cudjEuQXBwbGljYXRpb25FbmRwb2ludFByb3RvY29sKpQBCiNBcHBsaWNhdGlvbkluZmVyZW5jZVJvdXRlUHJvamVjdGlvbhI2CjJBUFBMSUNBVElPTl9JTkZFUkVOQ0VfUk9VVEVfUFJPSkVDVElPTl9VTlNQRUNJRklFRBAAEjUKMUFQUExJQ0FUSU9OX0lORkVSRU5DRV9ST1VURV9QUk9KRUNUSU9OX1JPVVRFX05BTUUQASqsAQoeQXBwbGljYXRpb25EZXBlbmRlbmN5Q29uZGl0aW9uEjAKLEFQUExJQ0FUSU9OX0RFUEVOREVOQ1lfQ09ORElUSU9OX1VOU1BFQ0lGSUVEEAASLAooQVBQTElDQVRJT05fREVQRU5ERU5DWV9DT05ESVRJT05fU1RBUlRFRBABEioKJkFQUExJQ0FUSU9OX0RFUEVOREVOQ1lfQ09ORElUSU9OX1JFQURZEAIqdAobQXBwbGljYXRpb25FbmRwb2ludFByb3RvY29sEi0KKUFQUExJQ0FUSU9OX0VORFBPSU5UX1BST1RPQ09MX1VOU1BFQ0lGSUVEEAASJgoiQVBQTElDQVRJT05fRU5EUE9JTlRfUFJPVE9DT0xfSFRUUBABYgZwcm90bzM", [file_dotcore_catalog_v1_nix, file_dotcore_catalog_v1_oci, file_dotcore_catalog_v1_parameter, file_dotcore_catalog_v1_storage]);
/**
* An immutable artifact-manager catalog declaration for an approved application release.
@@ -97,11 +97,12 @@ export type ApplicationGraph = Message<"dotcore.catalog.v1.ApplicationGraph"> &
primaryEndpoint?: ApplicationPrimaryEndpoint;
/**
- * Typed parameters operators may set at deployment creation, in display order.
+ * Typed parameters operators may set at deployment creation, in display order; each declares a
+ * default and is basic or advanced.
*
- * @generated from field: repeated dotcore.catalog.v1.ApplicationParameter parameters = 3;
+ * @generated from field: repeated dotcore.catalog.v1.ParameterDefinition parameters = 3;
*/
- parameters: ApplicationParameter[];
+ parameters: ParameterDefinition[];
};
/**
@@ -111,34 +112,6 @@ export type ApplicationGraph = Message<"dotcore.catalog.v1.ApplicationGraph"> &
export const ApplicationGraphSchema: GenMessage = /*@__PURE__*/
messageDesc(file_dotcore_catalog_v1_application, 1);
-/**
- * One typed application parameter and how the console shows it.
- *
- * @generated from message dotcore.catalog.v1.ApplicationParameter
- */
-export type ApplicationParameter = Message<"dotcore.catalog.v1.ApplicationParameter"> & {
- /**
- * Typed definition; application parameters always declare a default.
- *
- * @generated from field: dotcore.catalog.v1.ParameterDefinition definition = 1;
- */
- definition?: ParameterDefinition;
-
- /**
- * Whether the console shows this parameter only in its advanced view.
- *
- * @generated from field: bool advanced = 2;
- */
- advanced: boolean;
-};
-
-/**
- * Describes the message dotcore.catalog.v1.ApplicationParameter.
- * Use `create(ApplicationParameterSchema)` to create a new message.
- */
-export const ApplicationParameterSchema: GenMessage = /*@__PURE__*/
- messageDesc(file_dotcore_catalog_v1_application, 2);
-
/**
* One named service in an immutable application graph.
*
@@ -222,7 +195,7 @@ export type ApplicationService = Message<"dotcore.catalog.v1.ApplicationService"
* Use `create(ApplicationServiceSchema)` to create a new message.
*/
export const ApplicationServiceSchema: GenMessage = /*@__PURE__*/
- messageDesc(file_dotcore_catalog_v1_application, 3);
+ messageDesc(file_dotcore_catalog_v1_application, 2);
/**
* One immutable artifact of an application service and the platforms it approves.
@@ -266,7 +239,7 @@ export type ApplicationServiceArtifact = Message<"dotcore.catalog.v1.Application
* Use `create(ApplicationServiceArtifactSchema)` to create a new message.
*/
export const ApplicationServiceArtifactSchema: GenMessage = /*@__PURE__*/
- messageDesc(file_dotcore_catalog_v1_application, 4);
+ messageDesc(file_dotcore_catalog_v1_application, 3);
/**
* One named internal TCP port served by an application service.
@@ -294,7 +267,7 @@ export type ApplicationServicePort = Message<"dotcore.catalog.v1.ApplicationServ
* Use `create(ApplicationServicePortSchema)` to create a new message.
*/
export const ApplicationServicePortSchema: GenMessage = /*@__PURE__*/
- messageDesc(file_dotcore_catalog_v1_application, 5);
+ messageDesc(file_dotcore_catalog_v1_application, 4);
/**
* One dependency on a named service in the same application graph.
@@ -322,7 +295,7 @@ export type ApplicationServiceDependency = Message<"dotcore.catalog.v1.Applicati
* Use `create(ApplicationServiceDependencySchema)` to create a new message.
*/
export const ApplicationServiceDependencySchema: GenMessage = /*@__PURE__*/
- messageDesc(file_dotcore_catalog_v1_application, 6);
+ messageDesc(file_dotcore_catalog_v1_application, 5);
/**
* Declares one protocol readiness check for an application service.
@@ -349,7 +322,7 @@ export type ApplicationServiceReadiness = Message<"dotcore.catalog.v1.Applicatio
* Use `create(ApplicationServiceReadinessSchema)` to create a new message.
*/
export const ApplicationServiceReadinessSchema: GenMessage = /*@__PURE__*/
- messageDesc(file_dotcore_catalog_v1_application, 7);
+ messageDesc(file_dotcore_catalog_v1_application, 6);
/**
* Declares an HTTP readiness request without asserting current readiness.
@@ -377,7 +350,7 @@ export type ApplicationHttpReadiness = Message<"dotcore.catalog.v1.ApplicationHt
* Use `create(ApplicationHttpReadinessSchema)` to create a new message.
*/
export const ApplicationHttpReadinessSchema: GenMessage = /*@__PURE__*/
- messageDesc(file_dotcore_catalog_v1_application, 8);
+ messageDesc(file_dotcore_catalog_v1_application, 7);
/**
* Declares one non-secret configuration source and its environment destination.
@@ -441,7 +414,7 @@ export type ApplicationConfigurationBinding = Message<"dotcore.catalog.v1.Applic
* Use `create(ApplicationConfigurationBindingSchema)` to create a new message.
*/
export const ApplicationConfigurationBindingSchema: GenMessage = /*@__PURE__*/
- messageDesc(file_dotcore_catalog_v1_application, 9);
+ messageDesc(file_dotcore_catalog_v1_application, 8);
/**
* Describes one deployment input projected into a configuration binding.
@@ -468,7 +441,7 @@ export type ApplicationDeploymentInput = Message<"dotcore.catalog.v1.Application
* Use `create(ApplicationDeploymentInputSchema)` to create a new message.
*/
export const ApplicationDeploymentInputSchema: GenMessage = /*@__PURE__*/
- messageDesc(file_dotcore_catalog_v1_application, 10);
+ messageDesc(file_dotcore_catalog_v1_application, 9);
/**
* Identifies a deployment input projected from an optionally linked inference route.
@@ -489,7 +462,7 @@ export type ApplicationInferenceRouteInput = Message<"dotcore.catalog.v1.Applica
* Use `create(ApplicationInferenceRouteInputSchema)` to create a new message.
*/
export const ApplicationInferenceRouteInputSchema: GenMessage = /*@__PURE__*/
- messageDesc(file_dotcore_catalog_v1_application, 11);
+ messageDesc(file_dotcore_catalog_v1_application, 10);
/**
* Declares a named unresolved secret reference and its environment destination.
@@ -517,7 +490,7 @@ export type ApplicationSecretBinding = Message<"dotcore.catalog.v1.ApplicationSe
* Use `create(ApplicationSecretBindingSchema)` to create a new message.
*/
export const ApplicationSecretBindingSchema: GenMessage = /*@__PURE__*/
- messageDesc(file_dotcore_catalog_v1_application, 12);
+ messageDesc(file_dotcore_catalog_v1_application, 11);
/**
* Selects the one declared service port that can become the primary endpoint.
@@ -552,7 +525,7 @@ export type ApplicationPrimaryEndpoint = Message<"dotcore.catalog.v1.Application
* Use `create(ApplicationPrimaryEndpointSchema)` to create a new message.
*/
export const ApplicationPrimaryEndpointSchema: GenMessage = /*@__PURE__*/
- messageDesc(file_dotcore_catalog_v1_application, 13);
+ messageDesc(file_dotcore_catalog_v1_application, 12);
/**
* One application-neutral value projected from a linked inference route.
diff --git a/frontend/src/gen/dotcore/catalog/v1/engine_release_pb.ts b/frontend/src/gen/dotcore/catalog/v1/engine_release_pb.ts
index 5a833ebb..2f46de42 100644
--- a/frontend/src/gen/dotcore/catalog/v1/engine_release_pb.ts
+++ b/frontend/src/gen/dotcore/catalog/v1/engine_release_pb.ts
@@ -8,6 +8,8 @@ import type { NixArtifact } from "./nix_pb";
import { file_dotcore_catalog_v1_nix } from "./nix_pb";
import type { OciImageReference } from "./oci_pb";
import { file_dotcore_catalog_v1_oci } from "./oci_pb";
+import type { ParameterDefinition } from "./parameter_pb";
+import { file_dotcore_catalog_v1_parameter } from "./parameter_pb";
import type { Selector } from "./selector_pb";
import { file_dotcore_catalog_v1_selector } from "./selector_pb";
import type { StorageBinding } from "./storage_pb";
@@ -20,7 +22,7 @@ import type { Message } from "@bufbuild/protobuf";
* Describes the file dotcore/catalog/v1/engine_release.proto.
*/
export const file_dotcore_catalog_v1_engine_release: GenFile = /*@__PURE__*/
- fileDesc("Cidkb3Rjb3JlL2NhdGFsb2cvdjEvZW5naW5lX3JlbGVhc2UucHJvdG8SEmRvdGNvcmUuY2F0YWxvZy52MSKpAgoNRW5naW5lUmVsZWFzZRIZChFlbmdpbmVfcmVsZWFzZV9pZBgBIAEoCRIyCgtlbmdpbmVfa2luZBgCIAEoDjIdLmRvdGNvcmUuZW5naW5lLnYxLkVuZ2luZUtpbmQSDwoHdmVyc2lvbhgDIAEoCRI1CglhcnRpZmFjdHMYBCADKAsyIi5kb3Rjb3JlLmNhdGFsb2cudjEuRW5naW5lQXJ0aWZhY3QSQwoQcGFyYW1ldGVyX3NjaGVtYRgFIAEoCzIpLmRvdGNvcmUuY2F0YWxvZy52MS5FbmdpbmVQYXJhbWV0ZXJTY2hlbWESPAoQc3RvcmFnZV9iaW5kaW5ncxgGIAMoCzIiLmRvdGNvcmUuY2F0YWxvZy52MS5TdG9yYWdlQmluZGluZyLEAQoORW5naW5lQXJ0aWZhY3QSEwoLYXJ0aWZhY3RfaWQYASABKAkSNAoDb2NpGAIgASgLMiUuZG90Y29yZS5jYXRhbG9nLnYxLk9jaUltYWdlUmVmZXJlbmNlSAASLgoDbml4GAQgASgLMh8uZG90Y29yZS5jYXRhbG9nLnYxLk5peEFydGlmYWN0SAASLwoJc2VsZWN0b3JzGAMgAygLMhwuZG90Y29yZS5jYXRhbG9nLnYxLlNlbGVjdG9yQgYKBGtpbmQiUAoXRW5naW5lQXJ0aWZhY3RSZWZlcmVuY2USGQoRZW5naW5lX3JlbGVhc2VfaWQYASABKAkSGgoSZW5naW5lX2FydGlmYWN0X2lkGAIgASgJIm0KFUVuZ2luZVBhcmFtZXRlclNjaGVtYRIRCglzY2hlbWFfaWQYASABKAkSQQoKcGFyYW1ldGVycxgCIAMoCzItLmRvdGNvcmUuY2F0YWxvZy52MS5FbmdpbmVQYXJhbWV0ZXJEZWZpbml0aW9uIpoCChlFbmdpbmVQYXJhbWV0ZXJEZWZpbml0aW9uEhQKDHBhcmFtZXRlcl9pZBgBIAEoCRIUCgxkaXNwbGF5X25hbWUYAiABKAkSVAoQdW5zaWduZWRfaW50ZWdlchgDIAEoCzI4LmRvdGNvcmUuY2F0YWxvZy52MS5VbnNpZ25lZEludGVnZXJFbmdpbmVQYXJhbWV0ZXJTY2hlbWFIABJDCgdib29sZWFuGAQgASgLMjAuZG90Y29yZS5jYXRhbG9nLnYxLkJvb2xlYW5FbmdpbmVQYXJhbWV0ZXJTY2hlbWFIABIPCgdleHBvc2VkGAUgASgIEhMKC2Rlc2NyaXB0aW9uGAYgASgJQhAKDnBhcmFtZXRlcl90eXBlIh4KHEJvb2xlYW5FbmdpbmVQYXJhbWV0ZXJTY2hlbWEihAEKJFVuc2lnbmVkSW50ZWdlckVuZ2luZVBhcmFtZXRlclNjaGVtYRIPCgdtaW5pbXVtGAEgASgEEg8KB21heGltdW0YAiABKAQSDAoEdW5pdBgDIAEoCRIaCg1kZWZhdWx0X3ZhbHVlGAQgASgESACIAQFCEAoOX2RlZmF1bHRfdmFsdWUiUQoVRW5naW5lUGFyYW1ldGVyVmFsdWVzEjgKBnZhbHVlcxgBIAMoCzIoLmRvdGNvcmUuY2F0YWxvZy52MS5FbmdpbmVQYXJhbWV0ZXJWYWx1ZSJkChRFbmdpbmVQYXJhbWV0ZXJWYWx1ZRIUCgxwYXJhbWV0ZXJfaWQYASABKAkSGgoQdW5zaWduZWRfaW50ZWdlchgCIAEoBEgAEhEKB2Jvb2xlYW4YAyABKAhIAEIHCgV2YWx1ZWIGcHJvdG8z", [file_dotcore_catalog_v1_nix, file_dotcore_catalog_v1_oci, file_dotcore_catalog_v1_selector, file_dotcore_catalog_v1_storage, file_dotcore_engine_v1_engine]);
+ fileDesc("Cidkb3Rjb3JlL2NhdGFsb2cvdjEvZW5naW5lX3JlbGVhc2UucHJvdG8SEmRvdGNvcmUuY2F0YWxvZy52MSKpAgoNRW5naW5lUmVsZWFzZRIZChFlbmdpbmVfcmVsZWFzZV9pZBgBIAEoCRIyCgtlbmdpbmVfa2luZBgCIAEoDjIdLmRvdGNvcmUuZW5naW5lLnYxLkVuZ2luZUtpbmQSDwoHdmVyc2lvbhgDIAEoCRI1CglhcnRpZmFjdHMYBCADKAsyIi5kb3Rjb3JlLmNhdGFsb2cudjEuRW5naW5lQXJ0aWZhY3QSQwoQcGFyYW1ldGVyX3NjaGVtYRgFIAEoCzIpLmRvdGNvcmUuY2F0YWxvZy52MS5FbmdpbmVQYXJhbWV0ZXJTY2hlbWESPAoQc3RvcmFnZV9iaW5kaW5ncxgGIAMoCzIiLmRvdGNvcmUuY2F0YWxvZy52MS5TdG9yYWdlQmluZGluZyLEAQoORW5naW5lQXJ0aWZhY3QSEwoLYXJ0aWZhY3RfaWQYASABKAkSNAoDb2NpGAIgASgLMiUuZG90Y29yZS5jYXRhbG9nLnYxLk9jaUltYWdlUmVmZXJlbmNlSAASLgoDbml4GAQgASgLMh8uZG90Y29yZS5jYXRhbG9nLnYxLk5peEFydGlmYWN0SAASLwoJc2VsZWN0b3JzGAMgAygLMhwuZG90Y29yZS5jYXRhbG9nLnYxLlNlbGVjdG9yQgYKBGtpbmQiUAoXRW5naW5lQXJ0aWZhY3RSZWZlcmVuY2USGQoRZW5naW5lX3JlbGVhc2VfaWQYASABKAkSGgoSZW5naW5lX2FydGlmYWN0X2lkGAIgASgJImcKFUVuZ2luZVBhcmFtZXRlclNjaGVtYRIRCglzY2hlbWFfaWQYASABKAkSOwoKcGFyYW1ldGVycxgCIAMoCzInLmRvdGNvcmUuY2F0YWxvZy52MS5QYXJhbWV0ZXJEZWZpbml0aW9uYgZwcm90bzM", [file_dotcore_catalog_v1_nix, file_dotcore_catalog_v1_oci, file_dotcore_catalog_v1_parameter, file_dotcore_catalog_v1_selector, file_dotcore_catalog_v1_storage, file_dotcore_engine_v1_engine]);
/**
* An immutable artifact-manager catalog declaration for reusable engine
@@ -172,11 +174,11 @@ export type EngineParameterSchema = Message<"dotcore.catalog.v1.EngineParameterS
schemaId: string;
/**
- * Supported typed engine parameters.
+ * Supported typed engine parameters, in display order.
*
- * @generated from field: repeated dotcore.catalog.v1.EngineParameterDefinition parameters = 2;
+ * @generated from field: repeated dotcore.catalog.v1.ParameterDefinition parameters = 2;
*/
- parameters: EngineParameterDefinition[];
+ parameters: ParameterDefinition[];
};
/**
@@ -186,186 +188,3 @@ export type EngineParameterSchema = Message<"dotcore.catalog.v1.EngineParameterS
export const EngineParameterSchemaSchema: GenMessage = /*@__PURE__*/
messageDesc(file_dotcore_catalog_v1_engine_release, 3);
-/**
- * Defines one typed engine parameter and its validation constraints.
- *
- * @generated from message dotcore.catalog.v1.EngineParameterDefinition
- */
-export type EngineParameterDefinition = Message<"dotcore.catalog.v1.EngineParameterDefinition"> & {
- /**
- * Stable engine parameter identity.
- *
- * @generated from field: string parameter_id = 1;
- */
- parameterId: string;
-
- /**
- * Human-readable parameter label.
- *
- * @generated from field: string display_name = 2;
- */
- displayName: string;
-
- /**
- * @generated from oneof dotcore.catalog.v1.EngineParameterDefinition.parameter_type
- */
- parameterType: {
- /**
- * Unsigned-integer constraints for this parameter.
- *
- * @generated from field: dotcore.catalog.v1.UnsignedIntegerEngineParameterSchema unsigned_integer = 3;
- */
- value: UnsignedIntegerEngineParameterSchema;
- case: "unsignedInteger";
- } | {
- /**
- * Boolean type for this parameter.
- *
- * @generated from field: dotcore.catalog.v1.BooleanEngineParameterSchema boolean = 4;
- */
- value: BooleanEngineParameterSchema;
- case: "boolean";
- } | { case: undefined; value?: undefined };
-
- /**
- * Whether the console exposes this parameter by default.
- *
- * @generated from field: bool exposed = 5;
- */
- exposed: boolean;
-
- /**
- * Optional practical guidance shown with this parameter.
- *
- * @generated from field: string description = 6;
- */
- description: string;
-};
-
-/**
- * Describes the message dotcore.catalog.v1.EngineParameterDefinition.
- * Use `create(EngineParameterDefinitionSchema)` to create a new message.
- */
-export const EngineParameterDefinitionSchema: GenMessage = /*@__PURE__*/
- messageDesc(file_dotcore_catalog_v1_engine_release, 4);
-
-/**
- * Defines a boolean engine parameter.
- *
- * @generated from message dotcore.catalog.v1.BooleanEngineParameterSchema
- */
-export type BooleanEngineParameterSchema = Message<"dotcore.catalog.v1.BooleanEngineParameterSchema"> & {
-};
-
-/**
- * Describes the message dotcore.catalog.v1.BooleanEngineParameterSchema.
- * Use `create(BooleanEngineParameterSchemaSchema)` to create a new message.
- */
-export const BooleanEngineParameterSchemaSchema: GenMessage = /*@__PURE__*/
- messageDesc(file_dotcore_catalog_v1_engine_release, 5);
-
-/**
- * Defines the inclusive range for an unsigned-integer engine parameter.
- *
- * @generated from message dotcore.catalog.v1.UnsignedIntegerEngineParameterSchema
- */
-export type UnsignedIntegerEngineParameterSchema = Message<"dotcore.catalog.v1.UnsignedIntegerEngineParameterSchema"> & {
- /**
- * Inclusive minimum accepted value.
- *
- * @generated from field: uint64 minimum = 1;
- */
- minimum: bigint;
-
- /**
- * Inclusive maximum accepted value.
- *
- * @generated from field: uint64 maximum = 2;
- */
- maximum: bigint;
-
- /**
- * Human-readable unit label for this value.
- *
- * @generated from field: string unit = 3;
- */
- unit: string;
-
- /**
- * Value used when neither the deployment nor its profile supplies one, within the range.
- *
- * @generated from field: optional uint64 default_value = 4;
- */
- defaultValue?: bigint;
-};
-
-/**
- * Describes the message dotcore.catalog.v1.UnsignedIntegerEngineParameterSchema.
- * Use `create(UnsignedIntegerEngineParameterSchemaSchema)` to create a new message.
- */
-export const UnsignedIntegerEngineParameterSchemaSchema: GenMessage = /*@__PURE__*/
- messageDesc(file_dotcore_catalog_v1_engine_release, 6);
-
-/**
- * Supplies a complete set of exact typed engine parameter values.
- *
- * @generated from message dotcore.catalog.v1.EngineParameterValues
- */
-export type EngineParameterValues = Message<"dotcore.catalog.v1.EngineParameterValues"> & {
- /**
- * Exact values keyed by stable parameter identity.
- *
- * @generated from field: repeated dotcore.catalog.v1.EngineParameterValue values = 1;
- */
- values: EngineParameterValue[];
-};
-
-/**
- * Describes the message dotcore.catalog.v1.EngineParameterValues.
- * Use `create(EngineParameterValuesSchema)` to create a new message.
- */
-export const EngineParameterValuesSchema: GenMessage = /*@__PURE__*/
- messageDesc(file_dotcore_catalog_v1_engine_release, 7);
-
-/**
- * Supplies one exact typed engine parameter value.
- *
- * @generated from message dotcore.catalog.v1.EngineParameterValue
- */
-export type EngineParameterValue = Message<"dotcore.catalog.v1.EngineParameterValue"> & {
- /**
- * Stable engine parameter identity.
- *
- * @generated from field: string parameter_id = 1;
- */
- parameterId: string;
-
- /**
- * @generated from oneof dotcore.catalog.v1.EngineParameterValue.value
- */
- value: {
- /**
- * Exact unsigned-integer value.
- *
- * @generated from field: uint64 unsigned_integer = 2;
- */
- value: bigint;
- case: "unsignedInteger";
- } | {
- /**
- * Exact boolean value, preserving false presence.
- *
- * @generated from field: bool boolean = 3;
- */
- value: boolean;
- case: "boolean";
- } | { case: undefined; value?: undefined };
-};
-
-/**
- * Describes the message dotcore.catalog.v1.EngineParameterValue.
- * Use `create(EngineParameterValueSchema)` to create a new message.
- */
-export const EngineParameterValueSchema: GenMessage = /*@__PURE__*/
- messageDesc(file_dotcore_catalog_v1_engine_release, 8);
-
diff --git a/frontend/src/gen/dotcore/catalog/v1/model_pb.ts b/frontend/src/gen/dotcore/catalog/v1/model_pb.ts
index d858eafa..d865b4ae 100644
--- a/frontend/src/gen/dotcore/catalog/v1/model_pb.ts
+++ b/frontend/src/gen/dotcore/catalog/v1/model_pb.ts
@@ -4,7 +4,7 @@
import type { GenEnum, GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2";
import { enumDesc, fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2";
-import type { EngineArtifactReference, EngineParameterValues } from "./engine_release_pb";
+import type { EngineArtifactReference } from "./engine_release_pb";
import { file_dotcore_catalog_v1_engine_release } from "./engine_release_pb";
import type { ModelParallelShape } from "./execution_pb";
import { file_dotcore_catalog_v1_execution } from "./execution_pb";
@@ -12,6 +12,8 @@ import type { HardwareRequirements } from "./hardware_class_pb";
import { file_dotcore_catalog_v1_hardware_class } from "./hardware_class_pb";
import type { InferenceRequestKind, ModelInputCapabilities } from "./model_capabilities_pb";
import { file_dotcore_catalog_v1_model_capabilities } from "./model_capabilities_pb";
+import type { ParameterValues } from "./parameter_pb";
+import { file_dotcore_catalog_v1_parameter } from "./parameter_pb";
import type { EngineModelConfiguration } from "../../engine/v1/engine_pb";
import { file_dotcore_engine_v1_engine } from "../../engine/v1/engine_pb";
import type { Message } from "@bufbuild/protobuf";
@@ -20,7 +22,7 @@ import type { Message } from "@bufbuild/protobuf";
* Describes the file dotcore/catalog/v1/model.proto.
*/
export const file_dotcore_catalog_v1_model: GenFile = /*@__PURE__*/
- fileDesc("Ch5kb3Rjb3JlL2NhdGFsb2cvdjEvbW9kZWwucHJvdG8SEmRvdGNvcmUuY2F0YWxvZy52MSL2AwoFTW9kZWwSEAoIbW9kZWxfaWQYASABKAkSFAoMZGlzcGxheV9uYW1lGAIgASgJEg8KB3ZlcnNpb24YAyABKAkSHgoWbWF4aW11bV9jb250ZXh0X3Rva2VucxgMIAEoDRI0CglhcnRpZmFjdHMYBCADKAsyIS5kb3Rjb3JlLmNhdGFsb2cudjEuTW9kZWxBcnRpZmFjdBIyCghwcm9maWxlcxgFIAMoCzIgLmRvdGNvcmUuY2F0YWxvZy52MS5Nb2RlbFByb2ZpbGUSRwoTYXV4aWxpYXJ5X2FydGlmYWN0cxgHIAMoCzIqLmRvdGNvcmUuY2F0YWxvZy52MS5Nb2RlbEF1eGlsaWFyeUFydGlmYWN0EkQKDmNvbmZpZ3VyYXRpb25zGAggAygLMiwuZG90Y29yZS5jYXRhbG9nLnYxLk1vZGVsRW5naW5lQ29uZmlndXJhdGlvbhIwCgdvcHRpb25zGAkgAygLMh8uZG90Y29yZS5jYXRhbG9nLnYxLk1vZGVsT3B0aW9uEjsKDGludGVsbGlnZW5jZRgKIAEoCzIlLmRvdGNvcmUuY2F0YWxvZy52MS5JbnRlbGxpZ2VuY2VTY29yZRIsCgdsaWNlbnNlGAsgASgLMhsuZG90Y29yZS5jYXRhbG9nLnYxLkxpY2Vuc2UiJAoRSW50ZWxsaWdlbmNlU2NvcmUSDwoHb3ZlcmFsbBgBIAEoASIoCgdMaWNlbnNlEgoKAmlkGAEgASgJEhEKCXRlcm1zX3VybBgCIAEoCSKAAQoNTW9kZWxBcnRpZmFjdBI9CgZzb3VyY2UYASABKAsyLS5kb3Rjb3JlLmNhdGFsb2cudjEuSHVnZ2luZ0ZhY2VBcnRpZmFjdFNvdXJjZRITCgthcnRpZmFjdF9pZBgCIAEoCRIbChN3ZWlnaHRfcXVhbnRpemF0aW9uGAMgASgJInYKFk1vZGVsQXV4aWxpYXJ5QXJ0aWZhY3QSHQoVYXV4aWxpYXJ5X2FydGlmYWN0X2lkGAEgASgJEj0KBnNvdXJjZRgCIAEoCzItLmRvdGNvcmUuY2F0YWxvZy52MS5IdWdnaW5nRmFjZUFydGlmYWN0U291cmNlIs4BChlIdWdnaW5nRmFjZUFydGlmYWN0U291cmNlEhIKCnJlcG9zaXRvcnkYASABKAkSEAoIcmV2aXNpb24YAiABKAkSOwoEZmlsZRgDIAEoCzIrLmRvdGNvcmUuY2F0YWxvZy52MS5IdWdnaW5nRmFjZUZpbGVBcnRpZmFjdEgAEkMKCHNuYXBzaG90GAQgASgLMi8uZG90Y29yZS5jYXRhbG9nLnYxLkh1Z2dpbmdGYWNlU25hcHNob3RBcnRpZmFjdEgAQgkKB2NvbnRlbnQiQgoXSHVnZ2luZ0ZhY2VGaWxlQXJ0aWZhY3QSDAoEcGF0aBgBIAEoCRIZChFwcm92ZW5hbmNlX2RpZ2VzdBgCIAEoCSJZChtIdWdnaW5nRmFjZVNuYXBzaG90QXJ0aWZhY3QSOgoFZmlsZXMYASADKAsyKy5kb3Rjb3JlLmNhdGFsb2cudjEuSHVnZ2luZ0ZhY2VTbmFwc2hvdEZpbGUiQgoXSHVnZ2luZ0ZhY2VTbmFwc2hvdEZpbGUSDAoEcGF0aBgBIAEoCRIZChFwcm92ZW5hbmNlX2RpZ2VzdBgCIAEoCSKiBQoYTW9kZWxFbmdpbmVDb25maWd1cmF0aW9uEhgKEGNvbmZpZ3VyYXRpb25faWQYASABKAkSRQoQZW5naW5lX2FydGlmYWN0cxgCIAMoCzIrLmRvdGNvcmUuY2F0YWxvZy52MS5FbmdpbmVBcnRpZmFjdFJlZmVyZW5jZRJDChFhcnRpZmFjdF9iaW5kaW5ncxgDIAMoCzIoLmRvdGNvcmUuY2F0YWxvZy52MS5Nb2RlbEFydGlmYWN0QmluZGluZxI9ChBoYXJkd2FyZV9zdXBwb3J0GAkgAygLMiMuZG90Y29yZS5jYXRhbG9nLnYxLkhhcmR3YXJlU3VwcG9ydBJJChRlbmdpbmVfY29uZmlndXJhdGlvbhgFIAEoCzIrLmRvdGNvcmUuZW5naW5lLnYxLkVuZ2luZU1vZGVsQ29uZmlndXJhdGlvbhJOChZleGVjdXRpb25fY2FwYWJpbGl0aWVzGAYgASgLMi4uZG90Y29yZS5jYXRhbG9nLnYxLk1vZGVsRXhlY3V0aW9uQ2FwYWJpbGl0aWVzEkYKEmlucHV0X2NhcGFiaWxpdGllcxgHIAEoCzIqLmRvdGNvcmUuY2F0YWxvZy52MS5Nb2RlbElucHV0Q2FwYWJpbGl0aWVzEj8KDXJlcXVlc3Rfa2luZHMYCyADKA4yKC5kb3Rjb3JlLmNhdGFsb2cudjEuSW5mZXJlbmNlUmVxdWVzdEtpbmQSPQoHb3B0aW9ucxgIIAMoCzIsLmRvdGNvcmUuY2F0YWxvZy52MS5Nb2RlbENvbmZpZ3VyYXRpb25PcHRpb24SIwoWbWF4aW11bV9jb250ZXh0X3Rva2VucxgKIAEoDUgAiAEBQhkKF19tYXhpbXVtX2NvbnRleHRfdG9rZW5zImUKFE1vZGVsQXJ0aWZhY3RCaW5kaW5nEhIKCmJpbmRpbmdfaWQYASABKAkSGQoRbW9kZWxfYXJ0aWZhY3RfaWQYAiABKAkSHgoWYXV4aWxpYXJ5X2FydGlmYWN0X2lkcxgDIAMoCSK/AgoMTW9kZWxQcm9maWxlEhIKCnByb2ZpbGVfaWQYASABKAkSGAoQY29uZmlndXJhdGlvbl9pZBgCIAEoCRIpCiFkZWZhdWx0X21vZGVsX2FydGlmYWN0X2JpbmRpbmdfaWQYAyABKAkSTAoZZW5naW5lX3BhcmFtZXRlcl9kZWZhdWx0cxgEIAEoCzIpLmRvdGNvcmUuY2F0YWxvZy52MS5FbmdpbmVQYXJhbWV0ZXJWYWx1ZXMSFAoMZGlzcGxheV9uYW1lGAUgASgJEhMKC2Rlc2NyaXB0aW9uGAYgASgJEj4KD29wdGlvbl9kZWZhdWx0cxgHIAMoCzIlLmRvdGNvcmUuY2F0YWxvZy52MS5Nb2RlbE9wdGlvblZhbHVlcxIdChVleHBvc2VkX3BhcmFtZXRlcl9pZHMYCCADKAkimgIKC01vZGVsT3B0aW9uEhEKCW9wdGlvbl9pZBgBIAEoCRIUCgxkaXNwbGF5X25hbWUYAiABKAkSNwoGdmlzaW9uGAMgASgLMiUuZG90Y29yZS5jYXRhbG9nLnYxLlZpc2lvbk1vZGVsT3B0aW9uSAASUgoUc3BlY3VsYXRpdmVfZGVjb2RpbmcYBCABKAsyMi5kb3Rjb3JlLmNhdGFsb2cudjEuU3BlY3VsYXRpdmVEZWNvZGluZ01vZGVsT3B0aW9uSAASTQoSa3ZfY2FjaGVfcHJlY2lzaW9uGAUgASgLMi8uZG90Y29yZS5jYXRhbG9nLnYxLkt2Q2FjaGVQcmVjaXNpb25Nb2RlbE9wdGlvbkgAQgYKBGtpbmQiEwoRVmlzaW9uTW9kZWxPcHRpb24iXwoeU3BlY3VsYXRpdmVEZWNvZGluZ01vZGVsT3B0aW9uEj0KBm1ldGhvZBgBIAEoDjItLmRvdGNvcmUuY2F0YWxvZy52MS5TcGVjdWxhdGl2ZURlY29kaW5nTWV0aG9kIh0KG0t2Q2FjaGVQcmVjaXNpb25Nb2RlbE9wdGlvbiLsAQoYTW9kZWxDb25maWd1cmF0aW9uT3B0aW9uEhEKCW9wdGlvbl9pZBgBIAEoCRJGCgpwYXJhbWV0ZXJzGAIgAygLMjIuZG90Y29yZS5jYXRhbG9nLnYxLk1vZGVsT3B0aW9uUGFyYW1ldGVyRGVmaW5pdGlvbhI9Cg5kZWZhdWx0X3ZhbHVlcxgDIAEoCzIlLmRvdGNvcmUuY2F0YWxvZy52MS5Nb2RlbE9wdGlvblZhbHVlcxIdChVzdXBwb3J0ZWRfYmluZGluZ19pZHMYBCADKAkSFwoPY29tcGF0aWJsZV93aGVuGAUgASgJItQCCh5Nb2RlbE9wdGlvblBhcmFtZXRlckRlZmluaXRpb24SFAoMcGFyYW1ldGVyX2lkGAEgASgJEhQKDGRpc3BsYXlfbmFtZRgCIAEoCRJICgdib29sZWFuGAMgASgLMjUuZG90Y29yZS5jYXRhbG9nLnYxLkJvb2xlYW5Nb2RlbE9wdGlvblBhcmFtZXRlclNjaGVtYUgAElkKEHVuc2lnbmVkX2ludGVnZXIYBCABKAsyPS5kb3Rjb3JlLmNhdGFsb2cudjEuVW5zaWduZWRJbnRlZ2VyTW9kZWxPcHRpb25QYXJhbWV0ZXJTY2hlbWFIABJPCgtzdHJpbmdfZW51bRgFIAEoCzI4LmRvdGNvcmUuY2F0YWxvZy52MS5TdHJpbmdFbnVtTW9kZWxPcHRpb25QYXJhbWV0ZXJTY2hlbWFIAEIQCg5wYXJhbWV0ZXJfdHlwZSIjCiFCb29sZWFuTW9kZWxPcHRpb25QYXJhbWV0ZXJTY2hlbWEiWwopVW5zaWduZWRJbnRlZ2VyTW9kZWxPcHRpb25QYXJhbWV0ZXJTY2hlbWESDwoHbWluaW11bRgBIAEoBBIPCgdtYXhpbXVtGAIgASgEEgwKBHVuaXQYAyABKAkiNgokU3RyaW5nRW51bU1vZGVsT3B0aW9uUGFyYW1ldGVyU2NoZW1hEg4KBnZhbHVlcxgBIAMoCSJlChFNb2RlbE9wdGlvblZhbHVlcxIRCglvcHRpb25faWQYASABKAkSPQoGdmFsdWVzGAIgAygLMi0uZG90Y29yZS5jYXRhbG9nLnYxLk1vZGVsT3B0aW9uUGFyYW1ldGVyVmFsdWUiewoZTW9kZWxPcHRpb25QYXJhbWV0ZXJWYWx1ZRIUCgxwYXJhbWV0ZXJfaWQYASABKAkSEQoHYm9vbGVhbhgCIAEoCEgAEhoKEHVuc2lnbmVkX2ludGVnZXIYAyABKARIABIQCgZzdHJpbmcYBCABKAlIAEIHCgV2YWx1ZSJnChpNb2RlbEV4ZWN1dGlvbkNhcGFiaWxpdGllcxJJChlzdXBwb3J0ZWRfcGFyYWxsZWxfc2hhcGVzGAEgAygOMiYuZG90Y29yZS5jYXRhbG9nLnYxLk1vZGVsUGFyYWxsZWxTaGFwZSJ1Cg9IYXJkd2FyZVN1cHBvcnQSGQoRaGFyZHdhcmVfY2xhc3NfaWQYASABKAkSRwoVaGFyZHdhcmVfcmVxdWlyZW1lbnRzGAIgASgLMiguZG90Y29yZS5jYXRhbG9nLnYxLkhhcmR3YXJlUmVxdWlyZW1lbnRzKm0KGVNwZWN1bGF0aXZlRGVjb2RpbmdNZXRob2QSKwonU1BFQ1VMQVRJVkVfREVDT0RJTkdfTUVUSE9EX1VOU1BFQ0lGSUVEEAASIwofU1BFQ1VMQVRJVkVfREVDT0RJTkdfTUVUSE9EX01UUBABYgZwcm90bzM", [file_dotcore_catalog_v1_engine_release, file_dotcore_catalog_v1_execution, file_dotcore_catalog_v1_hardware_class, file_dotcore_catalog_v1_model_capabilities, file_dotcore_engine_v1_engine]);
+ fileDesc("Ch5kb3Rjb3JlL2NhdGFsb2cvdjEvbW9kZWwucHJvdG8SEmRvdGNvcmUuY2F0YWxvZy52MSL2AwoFTW9kZWwSEAoIbW9kZWxfaWQYASABKAkSFAoMZGlzcGxheV9uYW1lGAIgASgJEg8KB3ZlcnNpb24YAyABKAkSHgoWbWF4aW11bV9jb250ZXh0X3Rva2VucxgMIAEoDRI0CglhcnRpZmFjdHMYBCADKAsyIS5kb3Rjb3JlLmNhdGFsb2cudjEuTW9kZWxBcnRpZmFjdBIyCghwcm9maWxlcxgFIAMoCzIgLmRvdGNvcmUuY2F0YWxvZy52MS5Nb2RlbFByb2ZpbGUSRwoTYXV4aWxpYXJ5X2FydGlmYWN0cxgHIAMoCzIqLmRvdGNvcmUuY2F0YWxvZy52MS5Nb2RlbEF1eGlsaWFyeUFydGlmYWN0EkQKDmNvbmZpZ3VyYXRpb25zGAggAygLMiwuZG90Y29yZS5jYXRhbG9nLnYxLk1vZGVsRW5naW5lQ29uZmlndXJhdGlvbhIwCgdvcHRpb25zGAkgAygLMh8uZG90Y29yZS5jYXRhbG9nLnYxLk1vZGVsT3B0aW9uEjsKDGludGVsbGlnZW5jZRgKIAEoCzIlLmRvdGNvcmUuY2F0YWxvZy52MS5JbnRlbGxpZ2VuY2VTY29yZRIsCgdsaWNlbnNlGAsgASgLMhsuZG90Y29yZS5jYXRhbG9nLnYxLkxpY2Vuc2UiJAoRSW50ZWxsaWdlbmNlU2NvcmUSDwoHb3ZlcmFsbBgBIAEoASIoCgdMaWNlbnNlEgoKAmlkGAEgASgJEhEKCXRlcm1zX3VybBgCIAEoCSKAAQoNTW9kZWxBcnRpZmFjdBI9CgZzb3VyY2UYASABKAsyLS5kb3Rjb3JlLmNhdGFsb2cudjEuSHVnZ2luZ0ZhY2VBcnRpZmFjdFNvdXJjZRITCgthcnRpZmFjdF9pZBgCIAEoCRIbChN3ZWlnaHRfcXVhbnRpemF0aW9uGAMgASgJInYKFk1vZGVsQXV4aWxpYXJ5QXJ0aWZhY3QSHQoVYXV4aWxpYXJ5X2FydGlmYWN0X2lkGAEgASgJEj0KBnNvdXJjZRgCIAEoCzItLmRvdGNvcmUuY2F0YWxvZy52MS5IdWdnaW5nRmFjZUFydGlmYWN0U291cmNlIs4BChlIdWdnaW5nRmFjZUFydGlmYWN0U291cmNlEhIKCnJlcG9zaXRvcnkYASABKAkSEAoIcmV2aXNpb24YAiABKAkSOwoEZmlsZRgDIAEoCzIrLmRvdGNvcmUuY2F0YWxvZy52MS5IdWdnaW5nRmFjZUZpbGVBcnRpZmFjdEgAEkMKCHNuYXBzaG90GAQgASgLMi8uZG90Y29yZS5jYXRhbG9nLnYxLkh1Z2dpbmdGYWNlU25hcHNob3RBcnRpZmFjdEgAQgkKB2NvbnRlbnQiQgoXSHVnZ2luZ0ZhY2VGaWxlQXJ0aWZhY3QSDAoEcGF0aBgBIAEoCRIZChFwcm92ZW5hbmNlX2RpZ2VzdBgCIAEoCSJZChtIdWdnaW5nRmFjZVNuYXBzaG90QXJ0aWZhY3QSOgoFZmlsZXMYASADKAsyKy5kb3Rjb3JlLmNhdGFsb2cudjEuSHVnZ2luZ0ZhY2VTbmFwc2hvdEZpbGUiQgoXSHVnZ2luZ0ZhY2VTbmFwc2hvdEZpbGUSDAoEcGF0aBgBIAEoCRIZChFwcm92ZW5hbmNlX2RpZ2VzdBgCIAEoCSKiBQoYTW9kZWxFbmdpbmVDb25maWd1cmF0aW9uEhgKEGNvbmZpZ3VyYXRpb25faWQYASABKAkSRQoQZW5naW5lX2FydGlmYWN0cxgCIAMoCzIrLmRvdGNvcmUuY2F0YWxvZy52MS5FbmdpbmVBcnRpZmFjdFJlZmVyZW5jZRJDChFhcnRpZmFjdF9iaW5kaW5ncxgDIAMoCzIoLmRvdGNvcmUuY2F0YWxvZy52MS5Nb2RlbEFydGlmYWN0QmluZGluZxI9ChBoYXJkd2FyZV9zdXBwb3J0GAkgAygLMiMuZG90Y29yZS5jYXRhbG9nLnYxLkhhcmR3YXJlU3VwcG9ydBJJChRlbmdpbmVfY29uZmlndXJhdGlvbhgFIAEoCzIrLmRvdGNvcmUuZW5naW5lLnYxLkVuZ2luZU1vZGVsQ29uZmlndXJhdGlvbhJOChZleGVjdXRpb25fY2FwYWJpbGl0aWVzGAYgASgLMi4uZG90Y29yZS5jYXRhbG9nLnYxLk1vZGVsRXhlY3V0aW9uQ2FwYWJpbGl0aWVzEkYKEmlucHV0X2NhcGFiaWxpdGllcxgHIAEoCzIqLmRvdGNvcmUuY2F0YWxvZy52MS5Nb2RlbElucHV0Q2FwYWJpbGl0aWVzEj8KDXJlcXVlc3Rfa2luZHMYCyADKA4yKC5kb3Rjb3JlLmNhdGFsb2cudjEuSW5mZXJlbmNlUmVxdWVzdEtpbmQSPQoHb3B0aW9ucxgIIAMoCzIsLmRvdGNvcmUuY2F0YWxvZy52MS5Nb2RlbENvbmZpZ3VyYXRpb25PcHRpb24SIwoWbWF4aW11bV9jb250ZXh0X3Rva2VucxgKIAEoDUgAiAEBQhkKF19tYXhpbXVtX2NvbnRleHRfdG9rZW5zImUKFE1vZGVsQXJ0aWZhY3RCaW5kaW5nEhIKCmJpbmRpbmdfaWQYASABKAkSGQoRbW9kZWxfYXJ0aWZhY3RfaWQYAiABKAkSHgoWYXV4aWxpYXJ5X2FydGlmYWN0X2lkcxgDIAMoCSK5AgoMTW9kZWxQcm9maWxlEhIKCnByb2ZpbGVfaWQYASABKAkSGAoQY29uZmlndXJhdGlvbl9pZBgCIAEoCRIpCiFkZWZhdWx0X21vZGVsX2FydGlmYWN0X2JpbmRpbmdfaWQYAyABKAkSRgoZZW5naW5lX3BhcmFtZXRlcl9kZWZhdWx0cxgEIAEoCzIjLmRvdGNvcmUuY2F0YWxvZy52MS5QYXJhbWV0ZXJWYWx1ZXMSFAoMZGlzcGxheV9uYW1lGAUgASgJEhMKC2Rlc2NyaXB0aW9uGAYgASgJEj4KD29wdGlvbl9kZWZhdWx0cxgHIAMoCzIlLmRvdGNvcmUuY2F0YWxvZy52MS5Nb2RlbE9wdGlvblZhbHVlcxIdChVleHBvc2VkX3BhcmFtZXRlcl9pZHMYCCADKAkimgIKC01vZGVsT3B0aW9uEhEKCW9wdGlvbl9pZBgBIAEoCRIUCgxkaXNwbGF5X25hbWUYAiABKAkSNwoGdmlzaW9uGAMgASgLMiUuZG90Y29yZS5jYXRhbG9nLnYxLlZpc2lvbk1vZGVsT3B0aW9uSAASUgoUc3BlY3VsYXRpdmVfZGVjb2RpbmcYBCABKAsyMi5kb3Rjb3JlLmNhdGFsb2cudjEuU3BlY3VsYXRpdmVEZWNvZGluZ01vZGVsT3B0aW9uSAASTQoSa3ZfY2FjaGVfcHJlY2lzaW9uGAUgASgLMi8uZG90Y29yZS5jYXRhbG9nLnYxLkt2Q2FjaGVQcmVjaXNpb25Nb2RlbE9wdGlvbkgAQgYKBGtpbmQiEwoRVmlzaW9uTW9kZWxPcHRpb24iXwoeU3BlY3VsYXRpdmVEZWNvZGluZ01vZGVsT3B0aW9uEj0KBm1ldGhvZBgBIAEoDjItLmRvdGNvcmUuY2F0YWxvZy52MS5TcGVjdWxhdGl2ZURlY29kaW5nTWV0aG9kIh0KG0t2Q2FjaGVQcmVjaXNpb25Nb2RlbE9wdGlvbiLsAQoYTW9kZWxDb25maWd1cmF0aW9uT3B0aW9uEhEKCW9wdGlvbl9pZBgBIAEoCRJGCgpwYXJhbWV0ZXJzGAIgAygLMjIuZG90Y29yZS5jYXRhbG9nLnYxLk1vZGVsT3B0aW9uUGFyYW1ldGVyRGVmaW5pdGlvbhI9Cg5kZWZhdWx0X3ZhbHVlcxgDIAEoCzIlLmRvdGNvcmUuY2F0YWxvZy52MS5Nb2RlbE9wdGlvblZhbHVlcxIdChVzdXBwb3J0ZWRfYmluZGluZ19pZHMYBCADKAkSFwoPY29tcGF0aWJsZV93aGVuGAUgASgJItQCCh5Nb2RlbE9wdGlvblBhcmFtZXRlckRlZmluaXRpb24SFAoMcGFyYW1ldGVyX2lkGAEgASgJEhQKDGRpc3BsYXlfbmFtZRgCIAEoCRJICgdib29sZWFuGAMgASgLMjUuZG90Y29yZS5jYXRhbG9nLnYxLkJvb2xlYW5Nb2RlbE9wdGlvblBhcmFtZXRlclNjaGVtYUgAElkKEHVuc2lnbmVkX2ludGVnZXIYBCABKAsyPS5kb3Rjb3JlLmNhdGFsb2cudjEuVW5zaWduZWRJbnRlZ2VyTW9kZWxPcHRpb25QYXJhbWV0ZXJTY2hlbWFIABJPCgtzdHJpbmdfZW51bRgFIAEoCzI4LmRvdGNvcmUuY2F0YWxvZy52MS5TdHJpbmdFbnVtTW9kZWxPcHRpb25QYXJhbWV0ZXJTY2hlbWFIAEIQCg5wYXJhbWV0ZXJfdHlwZSIjCiFCb29sZWFuTW9kZWxPcHRpb25QYXJhbWV0ZXJTY2hlbWEiWwopVW5zaWduZWRJbnRlZ2VyTW9kZWxPcHRpb25QYXJhbWV0ZXJTY2hlbWESDwoHbWluaW11bRgBIAEoBBIPCgdtYXhpbXVtGAIgASgEEgwKBHVuaXQYAyABKAkiNgokU3RyaW5nRW51bU1vZGVsT3B0aW9uUGFyYW1ldGVyU2NoZW1hEg4KBnZhbHVlcxgBIAMoCSJlChFNb2RlbE9wdGlvblZhbHVlcxIRCglvcHRpb25faWQYASABKAkSPQoGdmFsdWVzGAIgAygLMi0uZG90Y29yZS5jYXRhbG9nLnYxLk1vZGVsT3B0aW9uUGFyYW1ldGVyVmFsdWUiewoZTW9kZWxPcHRpb25QYXJhbWV0ZXJWYWx1ZRIUCgxwYXJhbWV0ZXJfaWQYASABKAkSEQoHYm9vbGVhbhgCIAEoCEgAEhoKEHVuc2lnbmVkX2ludGVnZXIYAyABKARIABIQCgZzdHJpbmcYBCABKAlIAEIHCgV2YWx1ZSJnChpNb2RlbEV4ZWN1dGlvbkNhcGFiaWxpdGllcxJJChlzdXBwb3J0ZWRfcGFyYWxsZWxfc2hhcGVzGAEgAygOMiYuZG90Y29yZS5jYXRhbG9nLnYxLk1vZGVsUGFyYWxsZWxTaGFwZSJ1Cg9IYXJkd2FyZVN1cHBvcnQSGQoRaGFyZHdhcmVfY2xhc3NfaWQYASABKAkSRwoVaGFyZHdhcmVfcmVxdWlyZW1lbnRzGAIgASgLMiguZG90Y29yZS5jYXRhbG9nLnYxLkhhcmR3YXJlUmVxdWlyZW1lbnRzKm0KGVNwZWN1bGF0aXZlRGVjb2RpbmdNZXRob2QSKwonU1BFQ1VMQVRJVkVfREVDT0RJTkdfTUVUSE9EX1VOU1BFQ0lGSUVEEAASIwofU1BFQ1VMQVRJVkVfREVDT0RJTkdfTUVUSE9EX01UUBABYgZwcm90bzM", [file_dotcore_catalog_v1_engine_release, file_dotcore_catalog_v1_execution, file_dotcore_catalog_v1_hardware_class, file_dotcore_catalog_v1_model_capabilities, file_dotcore_catalog_v1_parameter, file_dotcore_engine_v1_engine]);
/**
* An immutable artifact-manager catalog declaration. The inference controller
@@ -510,9 +512,9 @@ export type ModelProfile = Message<"dotcore.catalog.v1.ModelProfile"> & {
/**
* Typed engine parameter defaults for this profile, overlaid on the engine defaults.
*
- * @generated from field: dotcore.catalog.v1.EngineParameterValues engine_parameter_defaults = 4;
+ * @generated from field: dotcore.catalog.v1.ParameterValues engine_parameter_defaults = 4;
*/
- engineParameterDefaults?: EngineParameterValues;
+ engineParameterDefaults?: ParameterValues;
/**
* Optional human-readable name for this profile.
diff --git a/frontend/src/gen/dotcore/catalog/v1/parameter_pb.ts b/frontend/src/gen/dotcore/catalog/v1/parameter_pb.ts
index 0028fa5b..8632aef4 100644
--- a/frontend/src/gen/dotcore/catalog/v1/parameter_pb.ts
+++ b/frontend/src/gen/dotcore/catalog/v1/parameter_pb.ts
@@ -2,19 +2,20 @@
// @generated from file dotcore/catalog/v1/parameter.proto (package dotcore.catalog.v1, syntax proto3)
/* eslint-disable */
-import type { GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2";
-import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2";
+import type { GenEnum, GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2";
+import { enumDesc, fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2";
import type { Message } from "@bufbuild/protobuf";
/**
* Describes the file dotcore/catalog/v1/parameter.proto.
*/
export const file_dotcore_catalog_v1_parameter: GenFile = /*@__PURE__*/
- fileDesc("CiJkb3Rjb3JlL2NhdGFsb2cvdjEvcGFyYW1ldGVyLnByb3RvEhJkb3Rjb3JlLmNhdGFsb2cudjEivQIKE1BhcmFtZXRlckRlZmluaXRpb24SFAoMcGFyYW1ldGVyX2lkGAEgASgJEhQKDGRpc3BsYXlfbmFtZRgCIAEoCRITCgtkZXNjcmlwdGlvbhgDIAEoCRI9Cgdib29sZWFuGAQgASgLMiouZG90Y29yZS5jYXRhbG9nLnYxLkJvb2xlYW5QYXJhbWV0ZXJTY2hlbWFIABJOChB1bnNpZ25lZF9pbnRlZ2VyGAUgASgLMjIuZG90Y29yZS5jYXRhbG9nLnYxLlVuc2lnbmVkSW50ZWdlclBhcmFtZXRlclNjaGVtYUgAEkQKC3N0cmluZ19lbnVtGAYgASgLMi0uZG90Y29yZS5jYXRhbG9nLnYxLlN0cmluZ0VudW1QYXJhbWV0ZXJTY2hlbWFIAEIQCg5wYXJhbWV0ZXJfdHlwZSJGChZCb29sZWFuUGFyYW1ldGVyU2NoZW1hEhoKDWRlZmF1bHRfdmFsdWUYASABKAhIAIgBAUIQCg5fZGVmYXVsdF92YWx1ZSJ+Ch5VbnNpZ25lZEludGVnZXJQYXJhbWV0ZXJTY2hlbWESDwoHbWluaW11bRgBIAEoBBIPCgdtYXhpbXVtGAIgASgEEgwKBHVuaXQYAyABKAkSGgoNZGVmYXVsdF92YWx1ZRgEIAEoBEgAiAEBQhAKDl9kZWZhdWx0X3ZhbHVlIlkKGVN0cmluZ0VudW1QYXJhbWV0ZXJTY2hlbWESDgoGdmFsdWVzGAEgAygJEhoKDWRlZmF1bHRfdmFsdWUYAiABKAlIAIgBAUIQCg5fZGVmYXVsdF92YWx1ZSJwCg5QYXJhbWV0ZXJWYWx1ZRIUCgxwYXJhbWV0ZXJfaWQYASABKAkSEQoHYm9vbGVhbhgCIAEoCEgAEhoKEHVuc2lnbmVkX2ludGVnZXIYAyABKARIABIQCgZzdHJpbmcYBCABKAlIAEIHCgV2YWx1ZWIGcHJvdG8z");
+ fileDesc("CiJkb3Rjb3JlL2NhdGFsb2cvdjEvcGFyYW1ldGVyLnByb3RvEhJkb3Rjb3JlLmNhdGFsb2cudjEi+gIKE1BhcmFtZXRlckRlZmluaXRpb24SFAoMcGFyYW1ldGVyX2lkGAEgASgJEhQKDGRpc3BsYXlfbmFtZRgCIAEoCRITCgtkZXNjcmlwdGlvbhgDIAEoCRI7Cgp2aXNpYmlsaXR5GAcgASgOMicuZG90Y29yZS5jYXRhbG9nLnYxLlBhcmFtZXRlclZpc2liaWxpdHkSPQoHYm9vbGVhbhgEIAEoCzIqLmRvdGNvcmUuY2F0YWxvZy52MS5Cb29sZWFuUGFyYW1ldGVyU2NoZW1hSAASTgoQdW5zaWduZWRfaW50ZWdlchgFIAEoCzIyLmRvdGNvcmUuY2F0YWxvZy52MS5VbnNpZ25lZEludGVnZXJQYXJhbWV0ZXJTY2hlbWFIABJECgtzdHJpbmdfZW51bRgGIAEoCzItLmRvdGNvcmUuY2F0YWxvZy52MS5TdHJpbmdFbnVtUGFyYW1ldGVyU2NoZW1hSABCEAoOcGFyYW1ldGVyX3R5cGUiRgoWQm9vbGVhblBhcmFtZXRlclNjaGVtYRIaCg1kZWZhdWx0X3ZhbHVlGAEgASgISACIAQFCEAoOX2RlZmF1bHRfdmFsdWUifgoeVW5zaWduZWRJbnRlZ2VyUGFyYW1ldGVyU2NoZW1hEg8KB21pbmltdW0YASABKAQSDwoHbWF4aW11bRgCIAEoBBIMCgR1bml0GAMgASgJEhoKDWRlZmF1bHRfdmFsdWUYBCABKARIAIgBAUIQCg5fZGVmYXVsdF92YWx1ZSJZChlTdHJpbmdFbnVtUGFyYW1ldGVyU2NoZW1hEg4KBnZhbHVlcxgBIAMoCRIaCg1kZWZhdWx0X3ZhbHVlGAIgASgJSACIAQFCEAoOX2RlZmF1bHRfdmFsdWUiRQoPUGFyYW1ldGVyVmFsdWVzEjIKBnZhbHVlcxgBIAMoCzIiLmRvdGNvcmUuY2F0YWxvZy52MS5QYXJhbWV0ZXJWYWx1ZSJwCg5QYXJhbWV0ZXJWYWx1ZRIUCgxwYXJhbWV0ZXJfaWQYASABKAkSEQoHYm9vbGVhbhgCIAEoCEgAEhoKEHVuc2lnbmVkX2ludGVnZXIYAyABKARIABIQCgZzdHJpbmcYBCABKAlIAEIHCgV2YWx1ZSqfAQoTUGFyYW1ldGVyVmlzaWJpbGl0eRIkCiBQQVJBTUVURVJfVklTSUJJTElUWV9VTlNQRUNJRklFRBAAEh4KGlBBUkFNRVRFUl9WSVNJQklMSVRZX0JBU0lDEAESIQodUEFSQU1FVEVSX1ZJU0lCSUxJVFlfQURWQU5DRUQQAhIfChtQQVJBTUVURVJfVklTSUJJTElUWV9ISURERU4QA2IGcHJvdG8z");
/**
- * Defines one owner-neutral typed parameter: its identity, label, guidance, and value constraints.
- * Owners decide whether a default is required and how the parameter is shown.
+ * Defines one owner-neutral typed parameter: its identity, label, guidance, visibility and value
+ * constraints. Owners decide which types and visibilities they accept and whether a default is
+ * required.
*
* @generated from message dotcore.catalog.v1.ParameterDefinition
*/
@@ -40,6 +41,13 @@ export type ParameterDefinition = Message<"dotcore.catalog.v1.ParameterDefinitio
*/
description: string;
+ /**
+ * Where the console shows this parameter.
+ *
+ * @generated from field: dotcore.catalog.v1.ParameterVisibility visibility = 7;
+ */
+ visibility: ParameterVisibility;
+
/**
* @generated from oneof dotcore.catalog.v1.ParameterDefinition.parameter_type
*/
@@ -168,6 +176,27 @@ export type StringEnumParameterSchema = Message<"dotcore.catalog.v1.StringEnumPa
export const StringEnumParameterSchemaSchema: GenMessage = /*@__PURE__*/
messageDesc(file_dotcore_catalog_v1_parameter, 3);
+/**
+ * Supplies a set of exact typed parameter values.
+ *
+ * @generated from message dotcore.catalog.v1.ParameterValues
+ */
+export type ParameterValues = Message<"dotcore.catalog.v1.ParameterValues"> & {
+ /**
+ * Exact values keyed by stable parameter identity.
+ *
+ * @generated from field: repeated dotcore.catalog.v1.ParameterValue values = 1;
+ */
+ values: ParameterValue[];
+};
+
+/**
+ * Describes the message dotcore.catalog.v1.ParameterValues.
+ * Use `create(ParameterValuesSchema)` to create a new message.
+ */
+export const ParameterValuesSchema: GenMessage = /*@__PURE__*/
+ messageDesc(file_dotcore_catalog_v1_parameter, 4);
+
/**
* Supplies one exact typed parameter value.
*
@@ -216,5 +245,46 @@ export type ParameterValue = Message<"dotcore.catalog.v1.ParameterValue"> & {
* Use `create(ParameterValueSchema)` to create a new message.
*/
export const ParameterValueSchema: GenMessage = /*@__PURE__*/
- messageDesc(file_dotcore_catalog_v1_parameter, 4);
+ messageDesc(file_dotcore_catalog_v1_parameter, 5);
+
+/**
+ * Where the console shows a parameter.
+ *
+ * @generated from enum dotcore.catalog.v1.ParameterVisibility
+ */
+export enum ParameterVisibility {
+ /**
+ * Invalid; every parameter declares its visibility.
+ *
+ * @generated from enum value: PARAMETER_VISIBILITY_UNSPECIFIED = 0;
+ */
+ UNSPECIFIED = 0,
+
+ /**
+ * Shown in the main view.
+ *
+ * @generated from enum value: PARAMETER_VISIBILITY_BASIC = 1;
+ */
+ BASIC = 1,
+
+ /**
+ * Shown only in the advanced view.
+ *
+ * @generated from enum value: PARAMETER_VISIBILITY_ADVANCED = 2;
+ */
+ ADVANCED = 2,
+
+ /**
+ * Never shown; the value comes from its default, a profile or sizing.
+ *
+ * @generated from enum value: PARAMETER_VISIBILITY_HIDDEN = 3;
+ */
+ HIDDEN = 3,
+}
+
+/**
+ * Describes the enum dotcore.catalog.v1.ParameterVisibility.
+ */
+export const ParameterVisibilitySchema: GenEnum = /*@__PURE__*/
+ enumDesc(file_dotcore_catalog_v1_parameter, 0);
diff --git a/frontend/src/gen/dotcore/model/v1/deployment_pb.ts b/frontend/src/gen/dotcore/model/v1/deployment_pb.ts
index 28ac4d22..a705e6f8 100644
--- a/frontend/src/gen/dotcore/model/v1/deployment_pb.ts
+++ b/frontend/src/gen/dotcore/model/v1/deployment_pb.ts
@@ -4,7 +4,7 @@
import type { GenEnum, GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2";
import { enumDesc, fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2";
-import type { EngineArtifact, EngineParameterSchema, EngineParameterValues } from "../../catalog/v1/engine_release_pb";
+import type { EngineArtifact, EngineParameterSchema } from "../../catalog/v1/engine_release_pb";
import { file_dotcore_catalog_v1_engine_release } from "../../catalog/v1/engine_release_pb";
import type { ModelParallelShape } from "../../catalog/v1/execution_pb";
import { file_dotcore_catalog_v1_execution } from "../../catalog/v1/execution_pb";
@@ -16,6 +16,8 @@ import type { InferenceRequestKind, ModelInputCapabilities } from "../../catalog
import { file_dotcore_catalog_v1_model_capabilities } from "../../catalog/v1/model_capabilities_pb";
import type { OciPlatform } from "../../catalog/v1/oci_pb";
import { file_dotcore_catalog_v1_oci } from "../../catalog/v1/oci_pb";
+import type { ParameterValues } from "../../catalog/v1/parameter_pb";
+import { file_dotcore_catalog_v1_parameter } from "../../catalog/v1/parameter_pb";
import type { Selector } from "../../catalog/v1/selector_pb";
import { file_dotcore_catalog_v1_selector } from "../../catalog/v1/selector_pb";
import type { StorageBinding } from "../../catalog/v1/storage_pb";
@@ -42,7 +44,7 @@ import type { Message } from "@bufbuild/protobuf";
* Describes the file dotcore/model/v1/deployment.proto.
*/
export const file_dotcore_model_v1_deployment: GenFile = /*@__PURE__*/
- fileDesc("CiFkb3Rjb3JlL21vZGVsL3YxL2RlcGxveW1lbnQucHJvdG8SEGRvdGNvcmUubW9kZWwudjEirwUKGk1vZGVsRGVwbG95bWVudERlY2xhcmF0aW9uEhsKE21vZGVsX2RlcGxveW1lbnRfaWQYASABKAkSOAoJcGxhY2VtZW50GAIgASgLMiUuZG90Y29yZS5kZXBsb3ltZW50LnYxLkV4YWN0UGxhY2VtZW50EkgKGHJlcXVlc3RlZF9wYXJhbGxlbF9zaGFwZRgDIAEoDjImLmRvdGNvcmUuY2F0YWxvZy52MS5Nb2RlbFBhcmFsbGVsU2hhcGUSOwoIc25hcHNob3QYBCABKAsyKS5kb3Rjb3JlLm1vZGVsLnYxLk1vZGVsRGVwbG95bWVudFNuYXBzaG90EkQKDWRlc2lyZWRfc3RhdGUYBSABKA4yLS5kb3Rjb3JlLm1vZGVsLnYxLk1vZGVsRGVwbG95bWVudERlc2lyZWRTdGF0ZRJbCh51cHN0cmVhbV9kZXNpcmVkX2NvbmZpZ3VyYXRpb24YBiABKAsyMy5kb3Rjb3JlLm1vZGVsLnYxLlVwc3RyZWFtTW9kZWxEZXNpcmVkQ29uZmlndXJhdGlvbhIdChVtb2RlbF9kZXBsb3ltZW50X25hbWUYByABKAkSHAoUbGlmZWN5Y2xlX2dlbmVyYXRpb24YCCABKAQSUgoUbGlmZWN5Y2xlX3RyYW5zaXRpb24YCSABKA4yNC5kb3Rjb3JlLm1vZGVsLnYxLk1vZGVsRGVwbG95bWVudExpZmVjeWNsZVRyYW5zaXRpb24SIwobbGlmZWN5Y2xlX3JlY292ZXJ5X3JlcXVpcmVkGAogASgIEloKH25vZGVfcmVzb2x2ZWRfbGF1bmNoX2lkZW50aXRpZXMYCyADKAsyMS5kb3Rjb3JlLmRlcGxveW1lbnQudjEuTm9kZVJlc29sdmVkTGF1bmNoSWRlbnRpdHkiygQKD01vZGVsRGVwbG95bWVudBIbChNtb2RlbF9kZXBsb3ltZW50X2lkGAEgASgJEjcKBmludGVudBgCIAEoCzInLmRvdGNvcmUubW9kZWwudjEuTW9kZWxEZXBsb3ltZW50SW50ZW50EjsKCHNuYXBzaG90GAMgASgLMikuZG90Y29yZS5tb2RlbC52MS5Nb2RlbERlcGxveW1lbnRTbmFwc2hvdBI1CgVzdGF0ZRgEIAEoCzImLmRvdGNvcmUubW9kZWwudjEuTW9kZWxEZXBsb3ltZW50U3RhdGUSRAoNZGVzaXJlZF9zdGF0ZRgFIAEoDjItLmRvdGNvcmUubW9kZWwudjEuTW9kZWxEZXBsb3ltZW50RGVzaXJlZFN0YXRlElsKHnVwc3RyZWFtX2Rlc2lyZWRfY29uZmlndXJhdGlvbhgGIAEoCzIzLmRvdGNvcmUubW9kZWwudjEuVXBzdHJlYW1Nb2RlbERlc2lyZWRDb25maWd1cmF0aW9uEh0KFW1vZGVsX2RlcGxveW1lbnRfbmFtZRgHIAEoCRJPChJhY2NlbGVyYXRvcl9tZW1vcnkYCCABKAsyMy5kb3Rjb3JlLmRlcGxveW1lbnQudjEuQWNjZWxlcmF0b3JNZW1vcnlPYnNlcnZhdGlvbhJaCh9ub2RlX3Jlc29sdmVkX2xhdW5jaF9pZGVudGl0aWVzGAkgAygLMjEuZG90Y29yZS5kZXBsb3ltZW50LnYxLk5vZGVSZXNvbHZlZExhdW5jaElkZW50aXR5IpsBChVNb2RlbERlcGxveW1lbnRJbnRlbnQSOAoJcGxhY2VtZW50GAEgASgLMiUuZG90Y29yZS5kZXBsb3ltZW50LnYxLkV4YWN0UGxhY2VtZW50EkgKGHJlcXVlc3RlZF9wYXJhbGxlbF9zaGFwZRgCIAEoDjImLmRvdGNvcmUuY2F0YWxvZy52MS5Nb2RlbFBhcmFsbGVsU2hhcGUiswEKF01vZGVsRGVwbG95bWVudFNuYXBzaG90EkcKBWxvY2FsGAEgASgLMjYuZG90Y29yZS5tb2RlbC52MS5SZXNvbHZlZExvY2FsTW9kZWxEZXBsb3ltZW50U25hcHNob3RIABJFCgh1cHN0cmVhbRgEIAEoCzIxLmRvdGNvcmUubW9kZWwudjEuVXBzdHJlYW1Nb2RlbERlcGxveW1lbnRTbmFwc2hvdEgAQggKBnNvdXJjZSKkCQokUmVzb2x2ZWRMb2NhbE1vZGVsRGVwbG95bWVudFNuYXBzaG90EhAKCG1vZGVsX2lkGAEgASgJEhoKEm1vZGVsX2Rpc3BsYXlfbmFtZRgCIAEoCRIVCg1tb2RlbF92ZXJzaW9uGAMgASgJEhgKEGNvbmZpZ3VyYXRpb25faWQYBCABKAkSEgoKcHJvZmlsZV9pZBgFIAEoCRJBCg9lbmdpbmVfYXJ0aWZhY3QYBiABKAsyKC5kb3Rjb3JlLm1vZGVsLnYxLlJlc29sdmVkRW5naW5lQXJ0aWZhY3QSOQoObW9kZWxfYXJ0aWZhY3QYByABKAsyIS5kb3Rjb3JlLmNhdGFsb2cudjEuTW9kZWxBcnRpZmFjdBJHChNhdXhpbGlhcnlfYXJ0aWZhY3RzGAggAygLMiouZG90Y29yZS5jYXRhbG9nLnYxLk1vZGVsQXV4aWxpYXJ5QXJ0aWZhY3QSQwoQaGFyZHdhcmVfc3VwcG9ydBgVIAEoCzIpLmRvdGNvcmUubW9kZWwudjEuUmVzb2x2ZWRIYXJkd2FyZVN1cHBvcnQSSgoVcmVzb2x2ZWRfZW5naW5lX3N0YXRlGAogASgLMisuZG90Y29yZS5lbmdpbmUudjEuUmVzb2x2ZWRFbmdpbmVNb2RlbFN0YXRlEk4KFmV4ZWN1dGlvbl9jYXBhYmlsaXRpZXMYCyABKAsyLi5kb3Rjb3JlLmNhdGFsb2cudjEuTW9kZWxFeGVjdXRpb25DYXBhYmlsaXRpZXMSRgoSaW5wdXRfY2FwYWJpbGl0aWVzGAwgASgLMiouZG90Y29yZS5jYXRhbG9nLnYxLk1vZGVsSW5wdXRDYXBhYmlsaXRpZXMSPwoNcmVxdWVzdF9raW5kcxgWIAMoDjIoLmRvdGNvcmUuY2F0YWxvZy52MS5JbmZlcmVuY2VSZXF1ZXN0S2luZBJKChdlbmdpbmVfcGFyYW1ldGVyX3ZhbHVlcxgNIAEoCzIpLmRvdGNvcmUuY2F0YWxvZy52MS5FbmdpbmVQYXJhbWV0ZXJWYWx1ZXMSIgoaZW5naW5lX3BhcmFtZXRlcl9zY2hlbWFfaWQYDiABKAkSSgoXZW5naW5lX3BhcmFtZXRlcl9zY2hlbWEYDyABKAsyKS5kb3Rjb3JlLmNhdGFsb2cudjEuRW5naW5lUGFyYW1ldGVyU2NoZW1hEkYKE2Rlc2lyZWRfbGF1bmNoX3BsYW4YECABKAsyKS5kb3Rjb3JlLm1vZGVsLnYxLlBvcnRhYmxlTW9kZWxMYXVuY2hQbGFuEh4KFmN1c3RvbV9hcmd1bWVudF90b2tlbnMYESADKAkSPwoQcmVzb2x2ZWRfb3B0aW9ucxgSIAMoCzIlLmRvdGNvcmUubW9kZWwudjEuUmVzb2x2ZWRNb2RlbE9wdGlvbhJBCg9zZWxlY3RlZF90YXJnZXQYEyABKAsyKC5kb3Rjb3JlLm1vZGVsLnYxLlJlc29sdmVkU2VsZWN0ZWRUYXJnZXQSMAoGc2l6aW5nGBQgASgLMiAuZG90Y29yZS5tb2RlbC52MS5SZXNvbHZlZFNpemluZyJMChNSZXNvbHZlZE1vZGVsT3B0aW9uEjUKBnZhbHVlcxgBIAEoCzIlLmRvdGNvcmUuY2F0YWxvZy52MS5Nb2RlbE9wdGlvblZhbHVlcyKtAQoXUmVzb2x2ZWRIYXJkd2FyZVN1cHBvcnQSGQoRaGFyZHdhcmVfY2xhc3NfaWQYASABKAkSLgoIc2VsZWN0b3IYAiABKAsyHC5kb3Rjb3JlLmNhdGFsb2cudjEuU2VsZWN0b3ISRwoVaGFyZHdhcmVfcmVxdWlyZW1lbnRzGAMgASgLMiguZG90Y29yZS5jYXRhbG9nLnYxLkhhcmR3YXJlUmVxdWlyZW1lbnRzItIBChZSZXNvbHZlZFNlbGVjdGVkVGFyZ2V0El4KH2FjY2VsZXJhdG9yX21lbW9yeV9hcmNoaXRlY3R1cmUYASABKA4yNS5kb3Rjb3JlLm9ic2VydmF0aW9uLnYxLkFjY2VsZXJhdG9yTWVtb3J5QXJjaGl0ZWN0dXJlEjAKI2FjY2VsZXJhdG9yX21lbW9yeV9wb29sX3RvdGFsX2J5dGVzGAIgASgESACIAQFCJgokX2FjY2VsZXJhdG9yX21lbW9yeV9wb29sX3RvdGFsX2J5dGVzIrABCg5SZXNvbHZlZFNpemluZxIlChhhY2NlbGVyYXRvcl9tZW1vcnlfYnl0ZXMYASABKARIAIgBARIkChxkZXJpdmVkX2VuZ2luZV9wYXJhbWV0ZXJfaWRzGAIgAygJEh4KEWhvc3RfbWVtb3J5X2J5dGVzGAMgASgESAGIAQFCGwoZX2FjY2VsZXJhdG9yX21lbW9yeV9ieXRlc0IUChJfaG9zdF9tZW1vcnlfYnl0ZXMizwMKFlJlc29sdmVkRW5naW5lQXJ0aWZhY3QSGQoRZW5naW5lX3JlbGVhc2VfaWQYASABKAkSMgoLZW5naW5lX2tpbmQYAiABKA4yHS5kb3Rjb3JlLmVuZ2luZS52MS5FbmdpbmVLaW5kEhYKDmVuZ2luZV92ZXJzaW9uGAMgASgJEjQKCGFydGlmYWN0GAQgASgLMiIuZG90Y29yZS5jYXRhbG9nLnYxLkVuZ2luZUFydGlmYWN0EjEKCHBsYXRmb3JtGAcgASgLMh8uZG90Y29yZS5jYXRhbG9nLnYxLk9jaVBsYXRmb3JtEhMKC2FjY2VsZXJhdG9yGAggASgJEisKI21pbmltdW1fbnZpZGlhX2RyaXZlcl9tYWpvcl92ZXJzaW9uGAkgASgNEkwKEnByZXBhcmF0aW9uX3BvbGljeRgFIAEoDjIwLmRvdGNvcmUuZGVwbG95bWVudC52MS5BcnRpZmFjdFByZXBhcmF0aW9uUG9saWN5EhcKD2xhdW5jaF9pZGVudGl0eRgGIAEoCRI8ChBzdG9yYWdlX2JpbmRpbmdzGAogAygLMiIuZG90Y29yZS5jYXRhbG9nLnYxLlN0b3JhZ2VCaW5kaW5nIsMBChdQb3J0YWJsZU1vZGVsTGF1bmNoUGxhbhJFCglhcmd1bWVudHMYASADKAsyMi5kb3Rjb3JlLm1vZGVsLnYxLlBvcnRhYmxlTW9kZWxMYXVuY2hBcmd1bWVudFRva2VuEhYKDmZvcm1hdF92ZXJzaW9uGAIgASgNEkkKEWFydGlmYWN0X2JpbmRpbmdzGAMgAygLMi4uZG90Y29yZS5tb2RlbC52MS5Qb3J0YWJsZU1vZGVsQXJ0aWZhY3RCaW5kaW5nIsIBCiBQb3J0YWJsZU1vZGVsTGF1bmNoQXJndW1lbnRUb2tlbhIRCgdsaXRlcmFsGAEgASgJSAASHQoTYXJ0aWZhY3RfYmluZGluZ19pZBgCIAEoCUgAEjMKEWVuZ2luZV9leGVjdXRhYmxlGAMgASgLMhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5SAASFgoMc2VydmljZV9ob3N0GAQgASgJSAASFgoMc2VydmljZV9wb3J0GAUgASgJSABCBwoFdmFsdWUijwEKHFBvcnRhYmxlTW9kZWxBcnRpZmFjdEJpbmRpbmcSEgoKYmluZGluZ19pZBgBIAEoCRIbChFtb2RlbF9hcnRpZmFjdF9pZBgCIAEoCUgAEh8KFWF1eGlsaWFyeV9hcnRpZmFjdF9pZBgDIAEoCUgAEhEKCXJlYWRfb25seRgFIAEoCEIKCghhcnRpZmFjdCJtCh9VcHN0cmVhbU1vZGVsRGVwbG95bWVudFNuYXBzaG90EkoKF3Vwc3RyZWFtX21vZGVsX3RlbXBsYXRlGAEgASgLMikuZG90Y29yZS5jYXRhbG9nLnYxLlVwc3RyZWFtTW9kZWxUZW1wbGF0ZSKlAgohVXBzdHJlYW1Nb2RlbERlc2lyZWRDb25maWd1cmF0aW9uEhkKEXVwc3RyZWFtX21vZGVsX2lkGAEgASgJEg8KB2FwaV9rZXkYAiABKAkSTAoSaW5wdXRfY2FwYWJpbGl0aWVzGAQgASgLMjAuZG90Y29yZS5tb2RlbC52MS5VcHN0cmVhbU1vZGVsSW5wdXRDYXBhYmlsaXRpZXMSSQoIZW5kcG9pbnQYBSABKAsyNy5kb3Rjb3JlLm1vZGVsLnYxLk9wZW5BaUNvbXBhdGlibGVFbmRwb2ludENvbmZpZ3VyYXRpb24SOwoIYm91bmRhcnkYBiABKAsyKS5kb3Rjb3JlLmNhdGFsb2cudjEuVXBzdHJlYW1Nb2RlbEJvdW5kYXJ5Ik8KHlVwc3RyZWFtTW9kZWxJbnB1dENhcGFiaWxpdGllcxIVCg1zdXBwb3J0c190ZXh0GAEgASgIEhYKDnN1cHBvcnRzX2ltYWdlGAIgASgIIlYKJU9wZW5BaUNvbXBhdGlibGVFbmRwb2ludENvbmZpZ3VyYXRpb24SEAoIYmFzZV91cmwYASABKAkSGwoTYWxsb3dfaW5zZWN1cmVfaHR0cBgCIAEoCCqkAgoiTW9kZWxEZXBsb3ltZW50TGlmZWN5Y2xlVHJhbnNpdGlvbhI1CjFNT0RFTF9ERVBMT1lNRU5UX0xJRkVDWUNMRV9UUkFOU0lUSU9OX1VOU1BFQ0lGSUVEEAASMAosTU9ERUxfREVQTE9ZTUVOVF9MSUZFQ1lDTEVfVFJBTlNJVElPTl9DUkVBVEUQARIvCitNT0RFTF9ERVBMT1lNRU5UX0xJRkVDWUNMRV9UUkFOU0lUSU9OX1BBVVNFEAISMAosTU9ERUxfREVQTE9ZTUVOVF9MSUZFQ1lDTEVfVFJBTlNJVElPTl9SRVNVTUUQAxIyCi5NT0RFTF9ERVBMT1lNRU5UX0xJRkVDWUNMRV9UUkFOU0lUSU9OX1RFQVJET1dOEAQq0QEKG01vZGVsRGVwbG95bWVudERlc2lyZWRTdGF0ZRIuCipNT0RFTF9ERVBMT1lNRU5UX0RFU0lSRURfU1RBVEVfVU5TUEVDSUZJRUQQABIqCiZNT0RFTF9ERVBMT1lNRU5UX0RFU0lSRURfU1RBVEVfUFJFU0VOVBABEisKJ01PREVMX0RFUExPWU1FTlRfREVTSVJFRF9TVEFURV9URUFSRE9XThACEikKJU1PREVMX0RFUExPWU1FTlRfREVTSVJFRF9TVEFURV9QQVVTRUQQA2IGcHJvdG8z", [file_dotcore_catalog_v1_engine_release, file_dotcore_catalog_v1_execution, file_dotcore_catalog_v1_hardware_class, file_dotcore_catalog_v1_model, file_dotcore_catalog_v1_model_capabilities, file_dotcore_catalog_v1_oci, file_dotcore_catalog_v1_selector, file_dotcore_catalog_v1_storage, file_dotcore_catalog_v1_upstream_model_template, file_dotcore_deployment_v1_accelerator_memory, file_dotcore_deployment_v1_artifact, file_dotcore_deployment_v1_placement, file_dotcore_engine_v1_engine, file_dotcore_model_v1_state, file_dotcore_observation_v1_observation, file_google_protobuf_empty]);
+ fileDesc("CiFkb3Rjb3JlL21vZGVsL3YxL2RlcGxveW1lbnQucHJvdG8SEGRvdGNvcmUubW9kZWwudjEirwUKGk1vZGVsRGVwbG95bWVudERlY2xhcmF0aW9uEhsKE21vZGVsX2RlcGxveW1lbnRfaWQYASABKAkSOAoJcGxhY2VtZW50GAIgASgLMiUuZG90Y29yZS5kZXBsb3ltZW50LnYxLkV4YWN0UGxhY2VtZW50EkgKGHJlcXVlc3RlZF9wYXJhbGxlbF9zaGFwZRgDIAEoDjImLmRvdGNvcmUuY2F0YWxvZy52MS5Nb2RlbFBhcmFsbGVsU2hhcGUSOwoIc25hcHNob3QYBCABKAsyKS5kb3Rjb3JlLm1vZGVsLnYxLk1vZGVsRGVwbG95bWVudFNuYXBzaG90EkQKDWRlc2lyZWRfc3RhdGUYBSABKA4yLS5kb3Rjb3JlLm1vZGVsLnYxLk1vZGVsRGVwbG95bWVudERlc2lyZWRTdGF0ZRJbCh51cHN0cmVhbV9kZXNpcmVkX2NvbmZpZ3VyYXRpb24YBiABKAsyMy5kb3Rjb3JlLm1vZGVsLnYxLlVwc3RyZWFtTW9kZWxEZXNpcmVkQ29uZmlndXJhdGlvbhIdChVtb2RlbF9kZXBsb3ltZW50X25hbWUYByABKAkSHAoUbGlmZWN5Y2xlX2dlbmVyYXRpb24YCCABKAQSUgoUbGlmZWN5Y2xlX3RyYW5zaXRpb24YCSABKA4yNC5kb3Rjb3JlLm1vZGVsLnYxLk1vZGVsRGVwbG95bWVudExpZmVjeWNsZVRyYW5zaXRpb24SIwobbGlmZWN5Y2xlX3JlY292ZXJ5X3JlcXVpcmVkGAogASgIEloKH25vZGVfcmVzb2x2ZWRfbGF1bmNoX2lkZW50aXRpZXMYCyADKAsyMS5kb3Rjb3JlLmRlcGxveW1lbnQudjEuTm9kZVJlc29sdmVkTGF1bmNoSWRlbnRpdHkiygQKD01vZGVsRGVwbG95bWVudBIbChNtb2RlbF9kZXBsb3ltZW50X2lkGAEgASgJEjcKBmludGVudBgCIAEoCzInLmRvdGNvcmUubW9kZWwudjEuTW9kZWxEZXBsb3ltZW50SW50ZW50EjsKCHNuYXBzaG90GAMgASgLMikuZG90Y29yZS5tb2RlbC52MS5Nb2RlbERlcGxveW1lbnRTbmFwc2hvdBI1CgVzdGF0ZRgEIAEoCzImLmRvdGNvcmUubW9kZWwudjEuTW9kZWxEZXBsb3ltZW50U3RhdGUSRAoNZGVzaXJlZF9zdGF0ZRgFIAEoDjItLmRvdGNvcmUubW9kZWwudjEuTW9kZWxEZXBsb3ltZW50RGVzaXJlZFN0YXRlElsKHnVwc3RyZWFtX2Rlc2lyZWRfY29uZmlndXJhdGlvbhgGIAEoCzIzLmRvdGNvcmUubW9kZWwudjEuVXBzdHJlYW1Nb2RlbERlc2lyZWRDb25maWd1cmF0aW9uEh0KFW1vZGVsX2RlcGxveW1lbnRfbmFtZRgHIAEoCRJPChJhY2NlbGVyYXRvcl9tZW1vcnkYCCABKAsyMy5kb3Rjb3JlLmRlcGxveW1lbnQudjEuQWNjZWxlcmF0b3JNZW1vcnlPYnNlcnZhdGlvbhJaCh9ub2RlX3Jlc29sdmVkX2xhdW5jaF9pZGVudGl0aWVzGAkgAygLMjEuZG90Y29yZS5kZXBsb3ltZW50LnYxLk5vZGVSZXNvbHZlZExhdW5jaElkZW50aXR5IpsBChVNb2RlbERlcGxveW1lbnRJbnRlbnQSOAoJcGxhY2VtZW50GAEgASgLMiUuZG90Y29yZS5kZXBsb3ltZW50LnYxLkV4YWN0UGxhY2VtZW50EkgKGHJlcXVlc3RlZF9wYXJhbGxlbF9zaGFwZRgCIAEoDjImLmRvdGNvcmUuY2F0YWxvZy52MS5Nb2RlbFBhcmFsbGVsU2hhcGUiswEKF01vZGVsRGVwbG95bWVudFNuYXBzaG90EkcKBWxvY2FsGAEgASgLMjYuZG90Y29yZS5tb2RlbC52MS5SZXNvbHZlZExvY2FsTW9kZWxEZXBsb3ltZW50U25hcHNob3RIABJFCgh1cHN0cmVhbRgEIAEoCzIxLmRvdGNvcmUubW9kZWwudjEuVXBzdHJlYW1Nb2RlbERlcGxveW1lbnRTbmFwc2hvdEgAQggKBnNvdXJjZSKeCQokUmVzb2x2ZWRMb2NhbE1vZGVsRGVwbG95bWVudFNuYXBzaG90EhAKCG1vZGVsX2lkGAEgASgJEhoKEm1vZGVsX2Rpc3BsYXlfbmFtZRgCIAEoCRIVCg1tb2RlbF92ZXJzaW9uGAMgASgJEhgKEGNvbmZpZ3VyYXRpb25faWQYBCABKAkSEgoKcHJvZmlsZV9pZBgFIAEoCRJBCg9lbmdpbmVfYXJ0aWZhY3QYBiABKAsyKC5kb3Rjb3JlLm1vZGVsLnYxLlJlc29sdmVkRW5naW5lQXJ0aWZhY3QSOQoObW9kZWxfYXJ0aWZhY3QYByABKAsyIS5kb3Rjb3JlLmNhdGFsb2cudjEuTW9kZWxBcnRpZmFjdBJHChNhdXhpbGlhcnlfYXJ0aWZhY3RzGAggAygLMiouZG90Y29yZS5jYXRhbG9nLnYxLk1vZGVsQXV4aWxpYXJ5QXJ0aWZhY3QSQwoQaGFyZHdhcmVfc3VwcG9ydBgVIAEoCzIpLmRvdGNvcmUubW9kZWwudjEuUmVzb2x2ZWRIYXJkd2FyZVN1cHBvcnQSSgoVcmVzb2x2ZWRfZW5naW5lX3N0YXRlGAogASgLMisuZG90Y29yZS5lbmdpbmUudjEuUmVzb2x2ZWRFbmdpbmVNb2RlbFN0YXRlEk4KFmV4ZWN1dGlvbl9jYXBhYmlsaXRpZXMYCyABKAsyLi5kb3Rjb3JlLmNhdGFsb2cudjEuTW9kZWxFeGVjdXRpb25DYXBhYmlsaXRpZXMSRgoSaW5wdXRfY2FwYWJpbGl0aWVzGAwgASgLMiouZG90Y29yZS5jYXRhbG9nLnYxLk1vZGVsSW5wdXRDYXBhYmlsaXRpZXMSPwoNcmVxdWVzdF9raW5kcxgWIAMoDjIoLmRvdGNvcmUuY2F0YWxvZy52MS5JbmZlcmVuY2VSZXF1ZXN0S2luZBJEChdlbmdpbmVfcGFyYW1ldGVyX3ZhbHVlcxgNIAEoCzIjLmRvdGNvcmUuY2F0YWxvZy52MS5QYXJhbWV0ZXJWYWx1ZXMSIgoaZW5naW5lX3BhcmFtZXRlcl9zY2hlbWFfaWQYDiABKAkSSgoXZW5naW5lX3BhcmFtZXRlcl9zY2hlbWEYDyABKAsyKS5kb3Rjb3JlLmNhdGFsb2cudjEuRW5naW5lUGFyYW1ldGVyU2NoZW1hEkYKE2Rlc2lyZWRfbGF1bmNoX3BsYW4YECABKAsyKS5kb3Rjb3JlLm1vZGVsLnYxLlBvcnRhYmxlTW9kZWxMYXVuY2hQbGFuEh4KFmN1c3RvbV9hcmd1bWVudF90b2tlbnMYESADKAkSPwoQcmVzb2x2ZWRfb3B0aW9ucxgSIAMoCzIlLmRvdGNvcmUubW9kZWwudjEuUmVzb2x2ZWRNb2RlbE9wdGlvbhJBCg9zZWxlY3RlZF90YXJnZXQYEyABKAsyKC5kb3Rjb3JlLm1vZGVsLnYxLlJlc29sdmVkU2VsZWN0ZWRUYXJnZXQSMAoGc2l6aW5nGBQgASgLMiAuZG90Y29yZS5tb2RlbC52MS5SZXNvbHZlZFNpemluZyJMChNSZXNvbHZlZE1vZGVsT3B0aW9uEjUKBnZhbHVlcxgBIAEoCzIlLmRvdGNvcmUuY2F0YWxvZy52MS5Nb2RlbE9wdGlvblZhbHVlcyKtAQoXUmVzb2x2ZWRIYXJkd2FyZVN1cHBvcnQSGQoRaGFyZHdhcmVfY2xhc3NfaWQYASABKAkSLgoIc2VsZWN0b3IYAiABKAsyHC5kb3Rjb3JlLmNhdGFsb2cudjEuU2VsZWN0b3ISRwoVaGFyZHdhcmVfcmVxdWlyZW1lbnRzGAMgASgLMiguZG90Y29yZS5jYXRhbG9nLnYxLkhhcmR3YXJlUmVxdWlyZW1lbnRzItIBChZSZXNvbHZlZFNlbGVjdGVkVGFyZ2V0El4KH2FjY2VsZXJhdG9yX21lbW9yeV9hcmNoaXRlY3R1cmUYASABKA4yNS5kb3Rjb3JlLm9ic2VydmF0aW9uLnYxLkFjY2VsZXJhdG9yTWVtb3J5QXJjaGl0ZWN0dXJlEjAKI2FjY2VsZXJhdG9yX21lbW9yeV9wb29sX3RvdGFsX2J5dGVzGAIgASgESACIAQFCJgokX2FjY2VsZXJhdG9yX21lbW9yeV9wb29sX3RvdGFsX2J5dGVzIrABCg5SZXNvbHZlZFNpemluZxIlChhhY2NlbGVyYXRvcl9tZW1vcnlfYnl0ZXMYASABKARIAIgBARIkChxkZXJpdmVkX2VuZ2luZV9wYXJhbWV0ZXJfaWRzGAIgAygJEh4KEWhvc3RfbWVtb3J5X2J5dGVzGAMgASgESAGIAQFCGwoZX2FjY2VsZXJhdG9yX21lbW9yeV9ieXRlc0IUChJfaG9zdF9tZW1vcnlfYnl0ZXMizwMKFlJlc29sdmVkRW5naW5lQXJ0aWZhY3QSGQoRZW5naW5lX3JlbGVhc2VfaWQYASABKAkSMgoLZW5naW5lX2tpbmQYAiABKA4yHS5kb3Rjb3JlLmVuZ2luZS52MS5FbmdpbmVLaW5kEhYKDmVuZ2luZV92ZXJzaW9uGAMgASgJEjQKCGFydGlmYWN0GAQgASgLMiIuZG90Y29yZS5jYXRhbG9nLnYxLkVuZ2luZUFydGlmYWN0EjEKCHBsYXRmb3JtGAcgASgLMh8uZG90Y29yZS5jYXRhbG9nLnYxLk9jaVBsYXRmb3JtEhMKC2FjY2VsZXJhdG9yGAggASgJEisKI21pbmltdW1fbnZpZGlhX2RyaXZlcl9tYWpvcl92ZXJzaW9uGAkgASgNEkwKEnByZXBhcmF0aW9uX3BvbGljeRgFIAEoDjIwLmRvdGNvcmUuZGVwbG95bWVudC52MS5BcnRpZmFjdFByZXBhcmF0aW9uUG9saWN5EhcKD2xhdW5jaF9pZGVudGl0eRgGIAEoCRI8ChBzdG9yYWdlX2JpbmRpbmdzGAogAygLMiIuZG90Y29yZS5jYXRhbG9nLnYxLlN0b3JhZ2VCaW5kaW5nIsMBChdQb3J0YWJsZU1vZGVsTGF1bmNoUGxhbhJFCglhcmd1bWVudHMYASADKAsyMi5kb3Rjb3JlLm1vZGVsLnYxLlBvcnRhYmxlTW9kZWxMYXVuY2hBcmd1bWVudFRva2VuEhYKDmZvcm1hdF92ZXJzaW9uGAIgASgNEkkKEWFydGlmYWN0X2JpbmRpbmdzGAMgAygLMi4uZG90Y29yZS5tb2RlbC52MS5Qb3J0YWJsZU1vZGVsQXJ0aWZhY3RCaW5kaW5nIsIBCiBQb3J0YWJsZU1vZGVsTGF1bmNoQXJndW1lbnRUb2tlbhIRCgdsaXRlcmFsGAEgASgJSAASHQoTYXJ0aWZhY3RfYmluZGluZ19pZBgCIAEoCUgAEjMKEWVuZ2luZV9leGVjdXRhYmxlGAMgASgLMhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5SAASFgoMc2VydmljZV9ob3N0GAQgASgJSAASFgoMc2VydmljZV9wb3J0GAUgASgJSABCBwoFdmFsdWUijwEKHFBvcnRhYmxlTW9kZWxBcnRpZmFjdEJpbmRpbmcSEgoKYmluZGluZ19pZBgBIAEoCRIbChFtb2RlbF9hcnRpZmFjdF9pZBgCIAEoCUgAEh8KFWF1eGlsaWFyeV9hcnRpZmFjdF9pZBgDIAEoCUgAEhEKCXJlYWRfb25seRgFIAEoCEIKCghhcnRpZmFjdCJtCh9VcHN0cmVhbU1vZGVsRGVwbG95bWVudFNuYXBzaG90EkoKF3Vwc3RyZWFtX21vZGVsX3RlbXBsYXRlGAEgASgLMikuZG90Y29yZS5jYXRhbG9nLnYxLlVwc3RyZWFtTW9kZWxUZW1wbGF0ZSKlAgohVXBzdHJlYW1Nb2RlbERlc2lyZWRDb25maWd1cmF0aW9uEhkKEXVwc3RyZWFtX21vZGVsX2lkGAEgASgJEg8KB2FwaV9rZXkYAiABKAkSTAoSaW5wdXRfY2FwYWJpbGl0aWVzGAQgASgLMjAuZG90Y29yZS5tb2RlbC52MS5VcHN0cmVhbU1vZGVsSW5wdXRDYXBhYmlsaXRpZXMSSQoIZW5kcG9pbnQYBSABKAsyNy5kb3Rjb3JlLm1vZGVsLnYxLk9wZW5BaUNvbXBhdGlibGVFbmRwb2ludENvbmZpZ3VyYXRpb24SOwoIYm91bmRhcnkYBiABKAsyKS5kb3Rjb3JlLmNhdGFsb2cudjEuVXBzdHJlYW1Nb2RlbEJvdW5kYXJ5Ik8KHlVwc3RyZWFtTW9kZWxJbnB1dENhcGFiaWxpdGllcxIVCg1zdXBwb3J0c190ZXh0GAEgASgIEhYKDnN1cHBvcnRzX2ltYWdlGAIgASgIIlYKJU9wZW5BaUNvbXBhdGlibGVFbmRwb2ludENvbmZpZ3VyYXRpb24SEAoIYmFzZV91cmwYASABKAkSGwoTYWxsb3dfaW5zZWN1cmVfaHR0cBgCIAEoCCqkAgoiTW9kZWxEZXBsb3ltZW50TGlmZWN5Y2xlVHJhbnNpdGlvbhI1CjFNT0RFTF9ERVBMT1lNRU5UX0xJRkVDWUNMRV9UUkFOU0lUSU9OX1VOU1BFQ0lGSUVEEAASMAosTU9ERUxfREVQTE9ZTUVOVF9MSUZFQ1lDTEVfVFJBTlNJVElPTl9DUkVBVEUQARIvCitNT0RFTF9ERVBMT1lNRU5UX0xJRkVDWUNMRV9UUkFOU0lUSU9OX1BBVVNFEAISMAosTU9ERUxfREVQTE9ZTUVOVF9MSUZFQ1lDTEVfVFJBTlNJVElPTl9SRVNVTUUQAxIyCi5NT0RFTF9ERVBMT1lNRU5UX0xJRkVDWUNMRV9UUkFOU0lUSU9OX1RFQVJET1dOEAQq0QEKG01vZGVsRGVwbG95bWVudERlc2lyZWRTdGF0ZRIuCipNT0RFTF9ERVBMT1lNRU5UX0RFU0lSRURfU1RBVEVfVU5TUEVDSUZJRUQQABIqCiZNT0RFTF9ERVBMT1lNRU5UX0RFU0lSRURfU1RBVEVfUFJFU0VOVBABEisKJ01PREVMX0RFUExPWU1FTlRfREVTSVJFRF9TVEFURV9URUFSRE9XThACEikKJU1PREVMX0RFUExPWU1FTlRfREVTSVJFRF9TVEFURV9QQVVTRUQQA2IGcHJvdG8z", [file_dotcore_catalog_v1_engine_release, file_dotcore_catalog_v1_execution, file_dotcore_catalog_v1_hardware_class, file_dotcore_catalog_v1_model, file_dotcore_catalog_v1_model_capabilities, file_dotcore_catalog_v1_oci, file_dotcore_catalog_v1_parameter, file_dotcore_catalog_v1_selector, file_dotcore_catalog_v1_storage, file_dotcore_catalog_v1_upstream_model_template, file_dotcore_deployment_v1_accelerator_memory, file_dotcore_deployment_v1_artifact, file_dotcore_deployment_v1_placement, file_dotcore_engine_v1_engine, file_dotcore_model_v1_state, file_dotcore_observation_v1_observation, file_google_protobuf_empty]);
/**
* Declarative active model deployment in a desired site. It is written only by
@@ -381,9 +383,9 @@ export type ResolvedLocalModelDeploymentSnapshot = Message<"dotcore.model.v1.Res
/**
* Complete exact resolved typed engine parameter values.
*
- * @generated from field: dotcore.catalog.v1.EngineParameterValues engine_parameter_values = 13;
+ * @generated from field: dotcore.catalog.v1.ParameterValues engine_parameter_values = 13;
*/
- engineParameterValues?: EngineParameterValues;
+ engineParameterValues?: ParameterValues;
/**
* Engine parameter schema identity used for resolution.
diff --git a/frontend/src/gen/dotcore/model/v1/service_pb.ts b/frontend/src/gen/dotcore/model/v1/service_pb.ts
index ebc6738e..75607c74 100644
--- a/frontend/src/gen/dotcore/model/v1/service_pb.ts
+++ b/frontend/src/gen/dotcore/model/v1/service_pb.ts
@@ -6,12 +6,12 @@ import type { GenEnum, GenFile, GenMessage, GenService } from "@bufbuild/protobu
import { enumDesc, fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2";
import type { PlacementPreview } from "../../capacity/v1/placement_preview_pb";
import { file_dotcore_capacity_v1_placement_preview } from "../../capacity/v1/placement_preview_pb";
-import type { EngineParameterValues } from "../../catalog/v1/engine_release_pb";
-import { file_dotcore_catalog_v1_engine_release } from "../../catalog/v1/engine_release_pb";
import type { ModelOptionValues } from "../../catalog/v1/model_pb";
import { file_dotcore_catalog_v1_model } from "../../catalog/v1/model_pb";
import type { OciPlatform } from "../../catalog/v1/oci_pb";
import { file_dotcore_catalog_v1_oci } from "../../catalog/v1/oci_pb";
+import type { ParameterValues } from "../../catalog/v1/parameter_pb";
+import { file_dotcore_catalog_v1_parameter } from "../../catalog/v1/parameter_pb";
import type { UseCase } from "../../catalog/v1/recommendation_pb";
import { file_dotcore_catalog_v1_recommendation } from "../../catalog/v1/recommendation_pb";
import type { Selector } from "../../catalog/v1/selector_pb";
@@ -30,7 +30,7 @@ import type { Message } from "@bufbuild/protobuf";
* Describes the file dotcore/model/v1/service.proto.
*/
export const file_dotcore_model_v1_service: GenFile = /*@__PURE__*/
- fileDesc("Ch5kb3Rjb3JlL21vZGVsL3YxL3NlcnZpY2UucHJvdG8SEGRvdGNvcmUubW9kZWwudjEiHQobTGlzdE1vZGVsRGVwbG95bWVudHNSZXF1ZXN0IlYKHExpc3RNb2RlbERlcGxveW1lbnRzUmVzcG9uc2USNgoLZGVwbG95bWVudHMYASADKAsyIS5kb3Rjb3JlLm1vZGVsLnYxLk1vZGVsRGVwbG95bWVudCK1AgocQ3JlYXRlTW9kZWxEZXBsb3ltZW50UmVxdWVzdBJECgVsb2NhbBgBIAEoCzIzLmRvdGNvcmUubW9kZWwudjEuQ3JlYXRlTG9jYWxNb2RlbERlcGxveW1lbnRSZXF1ZXN0SAASSgoIdXBzdHJlYW0YAiABKAsyNi5kb3Rjb3JlLm1vZGVsLnYxLkNyZWF0ZVVwc3RyZWFtTW9kZWxEZXBsb3ltZW50UmVxdWVzdEgAEjsKCGV4cG9zdXJlGAMgASgLMikuZG90Y29yZS5tb2RlbC52MS5Nb2RlbERlcGxveW1lbnRFeHBvc3VyZRIiChVtb2RlbF9kZXBsb3ltZW50X25hbWUYBCABKAlIAYgBAUIICgZzb3VyY2VCGAoWX21vZGVsX2RlcGxveW1lbnRfbmFtZSLPAQohQ3JlYXRlTG9jYWxNb2RlbERlcGxveW1lbnRSZXF1ZXN0EhAKCG1vZGVsX2lkGAEgASgJEhIKCnByb2ZpbGVfaWQYAiABKAkSOAoJcGxhY2VtZW50GAMgASgLMiUuZG90Y29yZS5kZXBsb3ltZW50LnYxLkV4YWN0UGxhY2VtZW50EkoKDWNvbmZpZ3VyYXRpb24YBCABKAsyMy5kb3Rjb3JlLm1vZGVsLnYxLkxvY2FsTW9kZWxEZXBsb3ltZW50Q29uZmlndXJhdGlvbiLzAQohTG9jYWxNb2RlbERlcGxveW1lbnRDb25maWd1cmF0aW9uEiEKGW1vZGVsX2FydGlmYWN0X2JpbmRpbmdfaWQYASABKAkSSgoXZW5naW5lX3BhcmFtZXRlcl92YWx1ZXMYAiABKAsyKS5kb3Rjb3JlLmNhdGFsb2cudjEuRW5naW5lUGFyYW1ldGVyVmFsdWVzEh4KFmN1c3RvbV9hcmd1bWVudF90b2tlbnMYAyADKAkSPwoQb3B0aW9uX292ZXJyaWRlcxgEIAMoCzIlLmRvdGNvcmUuY2F0YWxvZy52MS5Nb2RlbE9wdGlvblZhbHVlcyKeAQokQ3JlYXRlVXBzdHJlYW1Nb2RlbERlcGxveW1lbnRSZXF1ZXN0EiIKGnVwc3RyZWFtX21vZGVsX3RlbXBsYXRlX2lkGAEgASgJElIKFWRlc2lyZWRfY29uZmlndXJhdGlvbhgCIAEoCzIzLmRvdGNvcmUubW9kZWwudjEuVXBzdHJlYW1Nb2RlbERlc2lyZWRDb25maWd1cmF0aW9uIpgBChdNb2RlbERlcGxveW1lbnRFeHBvc3VyZRI3CgZkaXJlY3QYASABKAsyJS5kb3Rjb3JlLm1vZGVsLnYxLkRpcmVjdFJvdXRlRXhwb3N1cmVIABI8CgZoaWRkZW4YAiABKAsyKi5kb3Rjb3JlLm1vZGVsLnYxLkhpZGRlbkRlcGxveW1lbnRFeHBvc3VyZUgAQgYKBGtpbmQiKQoTRGlyZWN0Um91dGVFeHBvc3VyZRISCgpyb3V0ZV9uYW1lGAEgASgJIhoKGEhpZGRlbkRlcGxveW1lbnRFeHBvc3VyZSJWCh1DcmVhdGVNb2RlbERlcGxveW1lbnRSZXNwb25zZRI1CgpkZXBsb3ltZW50GAEgASgLMiEuZG90Y29yZS5tb2RlbC52MS5Nb2RlbERlcGxveW1lbnQiaAoiUHJldmlld0xvY2FsTW9kZWxEZXBsb3ltZW50UmVxdWVzdBJCCgVsb2NhbBgBIAEoCzIzLmRvdGNvcmUubW9kZWwudjEuQ3JlYXRlTG9jYWxNb2RlbERlcGxveW1lbnRSZXF1ZXN0IrwBCiNQcmV2aWV3TG9jYWxNb2RlbERlcGxveW1lbnRSZXNwb25zZRJRChFyZXNvbHZlZF9zbmFwc2hvdBgBIAEoCzI2LmRvdGNvcmUubW9kZWwudjEuUmVzb2x2ZWRMb2NhbE1vZGVsRGVwbG95bWVudFNuYXBzaG90EkIKCmFkdmlzb3JpZXMYAiADKA4yLi5kb3Rjb3JlLm1vZGVsLnYxLkxvY2FsTW9kZWxEZXBsb3ltZW50QWR2aXNvcnki0AEKIkVzdGltYXRlTG9jYWxNb2RlbFJlc291cmNlc1JlcXVlc3QSEAoIbW9kZWxfaWQYASABKAkSEgoKcHJvZmlsZV9pZBgCIAEoCRJKCg1jb25maWd1cmF0aW9uGAMgASgLMjMuZG90Y29yZS5tb2RlbC52MS5Mb2NhbE1vZGVsRGVwbG95bWVudENvbmZpZ3VyYXRpb24SOAoJcGxhY2VtZW50GAQgASgLMiUuZG90Y29yZS5kZXBsb3ltZW50LnYxLkV4YWN0UGxhY2VtZW50IroBCiNFc3RpbWF0ZUxvY2FsTW9kZWxSZXNvdXJjZXNSZXNwb25zZRJRChhoYXJkd2FyZV9jbGFzc19lc3RpbWF0ZXMYASADKAsyLy5kb3Rjb3JlLm1vZGVsLnYxLkhhcmR3YXJlQ2xhc3NSZXNvdXJjZUVzdGltYXRlEkAKEXBsYWNlbWVudF9wcmV2aWV3GAIgASgLMiUuZG90Y29yZS5jYXBhY2l0eS52MS5QbGFjZW1lbnRQcmV2aWV3Iv8BCh1IYXJkd2FyZUNsYXNzUmVzb3VyY2VFc3RpbWF0ZRIZChFoYXJkd2FyZV9jbGFzc19pZBgBIAEoCRI7Cgllc3RpbWF0ZXMYAiADKAsyKC5kb3Rjb3JlLm1vZGVsLnYxLlNjYWxhclJlc291cmNlRXN0aW1hdGUSUgofZGVyaXZlZF9lbmdpbmVfcGFyYW1ldGVyX3ZhbHVlcxgDIAEoCzIpLmRvdGNvcmUuY2F0YWxvZy52MS5FbmdpbmVQYXJhbWV0ZXJWYWx1ZXMSHQoQbGF1bmNoX3JlamVjdGlvbhgEIAEoCUgAiAEBQhMKEV9sYXVuY2hfcmVqZWN0aW9uIkcKFlNjYWxhclJlc291cmNlRXN0aW1hdGUSEAoIcmVzb3VyY2UYASABKAkSDQoFdmFsdWUYAiABKAQSDAoEdW5pdBgDIAEoCSK1AQokTGlzdE1vZGVsUHJvZmlsZUNvbXBhdGliaWxpdHlSZXF1ZXN0EhUKCG1vZGVsX2lkGAEgASgJSACIAQESNQoGdGFyZ2V0GAIgASgLMiUuZG90Y29yZS5kZXBsb3ltZW50LnYxLkV4YWN0UGxhY2VtZW50EjIKCHNjZW5hcmlvGAMgASgLMiAuZG90Y29yZS5tb2RlbC52MS5TaXppbmdTY2VuYXJpb0ILCglfbW9kZWxfaWQidgoOU2l6aW5nU2NlbmFyaW8SHAoUdG90YWxfY29udGV4dF90b2tlbnMYASABKA0SJwoaY29udGV4dF90b2tlbnNfcGVyX3JlcXVlc3QYAiABKA1IAIgBAUIdChtfY29udGV4dF90b2tlbnNfcGVyX3JlcXVlc3Qi1QEKJUxpc3RNb2RlbFByb2ZpbGVDb21wYXRpYmlsaXR5UmVzcG9uc2USNgoHdGFyZ2V0cxgBIAMoCzIlLmRvdGNvcmUubW9kZWwudjEuVGFyZ2V0Q29tcGF0aWJpbGl0eRI8CgdlbnRyaWVzGAIgAygLMisuZG90Y29yZS5tb2RlbC52MS5Nb2RlbFByb2ZpbGVDb21wYXRpYmlsaXR5EjYKCHByb2ZpbGVzGAMgAygLMiQuZG90Y29yZS5tb2RlbC52MS5Nb2RlbFByb2ZpbGVTaXppbmciUQofTGlzdE1vZGVsUmVjb21tZW5kYXRpb25zUmVxdWVzdBIuCgl1c2VfY2FzZXMYASADKA4yGy5kb3Rjb3JlLmNhdGFsb2cudjEuVXNlQ2FzZSJkCiBMaXN0TW9kZWxSZWNvbW1lbmRhdGlvbnNSZXNwb25zZRJACgl1c2VfY2FzZXMYASADKAsyLS5kb3Rjb3JlLm1vZGVsLnYxLlVzZUNhc2VNb2RlbFJlY29tbWVuZGF0aW9ucyKFAQobVXNlQ2FzZU1vZGVsUmVjb21tZW5kYXRpb25zEi0KCHVzZV9jYXNlGAEgASgOMhsuZG90Y29yZS5jYXRhbG9nLnYxLlVzZUNhc2USNwoEcm93cxgCIAMoCzIpLmRvdGNvcmUubW9kZWwudjEuUmVjb21tZW5kZWRNb2RlbFByb2ZpbGUifAoXUmVjb21tZW5kZWRNb2RlbFByb2ZpbGUSEAoIbW9kZWxfaWQYASABKAkSEgoKcHJvZmlsZV9pZBgCIAEoCRI7Cgd0YXJnZXRzGAMgAygLMiouZG90Y29yZS5tb2RlbC52MS5SZWNvbW1lbmRlZFByb2ZpbGVUYXJnZXQi6AEKGFJlY29tbWVuZGVkUHJvZmlsZVRhcmdldBI1CgZ0YXJnZXQYASABKAsyJS5kb3Rjb3JlLmRlcGxveW1lbnQudjEuRXhhY3RQbGFjZW1lbnQSNQoDZml0GAIgASgOMiguZG90Y29yZS5tb2RlbC52MS5Nb2RlbFJlY29tbWVuZGF0aW9uRml0EhAKCGRlcGxveWVkGAMgASgIEioKHXN0YXJ0aW5nX3RvdGFsX2NvbnRleHRfdG9rZW5zGAQgASgESACIAQFCIAoeX3N0YXJ0aW5nX3RvdGFsX2NvbnRleHRfdG9rZW5zIvUBChNUYXJnZXRDb21wYXRpYmlsaXR5EjUKBnRhcmdldBgBIAEoCzIlLmRvdGNvcmUuZGVwbG95bWVudC52MS5FeGFjdFBsYWNlbWVudBI6CgZzdGF0dXMYAiABKA4yKi5kb3Rjb3JlLm1vZGVsLnYxLk1vZGVsQ29tcGF0aWJpbGl0eVN0YXR1cxI6CgZyZWFzb24YAyABKAsyKi5kb3Rjb3JlLm1vZGVsLnYxLk1vZGVsQ29tcGF0aWJpbGl0eVJlYXNvbhIvCgtvYnNlcnZlZF9hdBgEIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAi/gUKGU1vZGVsUHJvZmlsZUNvbXBhdGliaWxpdHkSEAoIbW9kZWxfaWQYASABKAkSEgoKcHJvZmlsZV9pZBgCIAEoCRI1CgZ0YXJnZXQYAyABKAsyJS5kb3Rjb3JlLmRlcGxveW1lbnQudjEuRXhhY3RQbGFjZW1lbnQSOgoGc3RhdHVzGAQgASgOMiouZG90Y29yZS5tb2RlbC52MS5Nb2RlbENvbXBhdGliaWxpdHlTdGF0dXMSOgoGcmVhc29uGAUgASgLMiouZG90Y29yZS5tb2RlbC52MS5Nb2RlbENvbXBhdGliaWxpdHlSZWFzb24SQgoKYWR2aXNvcmllcxgGIAMoDjIuLmRvdGNvcmUubW9kZWwudjEuTG9jYWxNb2RlbERlcGxveW1lbnRBZHZpc29yeRIZChFoYXJkd2FyZV9jbGFzc19pZBgHIAEoCRI0CgptZW1vcnlfZml0GAggASgOMiAuZG90Y29yZS5kZXBsb3ltZW50LnYxLk1lbW9yeUZpdBI5Cg9mcmVlX21lbW9yeV9maXQYCyABKA4yIC5kb3Rjb3JlLmRlcGxveW1lbnQudjEuTWVtb3J5Rml0Ek0KEnVuc3VwcG9ydGVkX3JlYXNvbhgJIAEoDjIxLmRvdGNvcmUubW9kZWwudjEuU2l6aW5nU2NlbmFyaW9VbnN1cHBvcnRlZFJlYXNvbhIpChxsYXJnZXN0X3RvdGFsX2NvbnRleHRfdG9rZW5zGAogASgNSACIAQESKgodc3RhcnRpbmdfdG90YWxfY29udGV4dF90b2tlbnMYDCABKA1IAYgBARJTCh1zdGFydGluZ190b3RhbF9jb250ZXh0X3NvdXJjZRgNIAEoDjIsLmRvdGNvcmUubW9kZWwudjEuU3RhcnRpbmdUb3RhbENvbnRleHRTb3VyY2VCHwodX2xhcmdlc3RfdG90YWxfY29udGV4dF90b2tlbnNCIAoeX3N0YXJ0aW5nX3RvdGFsX2NvbnRleHRfdG9rZW5zItACChJNb2RlbFByb2ZpbGVTaXppbmcSEAoIbW9kZWxfaWQYASABKAkSEgoKcHJvZmlsZV9pZBgCIAEoCRJKChdlbmdpbmVfcGFyYW1ldGVyX3ZhbHVlcxgDIAEoCzIpLmRvdGNvcmUuY2F0YWxvZy52MS5FbmdpbmVQYXJhbWV0ZXJWYWx1ZXMSUQoYaGFyZHdhcmVfY2xhc3NfZXN0aW1hdGVzGAQgAygLMi8uZG90Y29yZS5tb2RlbC52MS5IYXJkd2FyZUNsYXNzUmVzb3VyY2VFc3RpbWF0ZRJNChJ1bnN1cHBvcnRlZF9yZWFzb24YBSABKA4yMS5kb3Rjb3JlLm1vZGVsLnYxLlNpemluZ1NjZW5hcmlvVW5zdXBwb3J0ZWRSZWFzb24SJgoedW5zdXBwb3J0ZWRfaGFyZHdhcmVfY2xhc3NfaWRzGAYgAygJIrUDChhNb2RlbENvbXBhdGliaWxpdHlSZWFzb24SPAoEY29kZRgBIAEoDjIuLmRvdGNvcmUubW9kZWwudjEuTW9kZWxDb21wYXRpYmlsaXR5UmVhc29uQ29kZRI8ChNjb25maWd1cmVkX3BsYXRmb3JtGAIgASgLMh8uZG90Y29yZS5jYXRhbG9nLnYxLk9jaVBsYXRmb3JtEjoKEW9ic2VydmVkX3BsYXRmb3JtGAMgASgLMh8uZG90Y29yZS5jYXRhbG9nLnYxLk9jaVBsYXRmb3JtEjkKE3N1cHBvcnRlZF9zZWxlY3RvcnMYBCADKAsyHC5kb3Rjb3JlLmNhdGFsb2cudjEuU2VsZWN0b3ISKwojbWluaW11bV9udmlkaWFfZHJpdmVyX21ham9yX3ZlcnNpb24YBSABKA0SLAokb2JzZXJ2ZWRfbnZpZGlhX2RyaXZlcl9tYWpvcl92ZXJzaW9uGAYgASgNEiAKGGFjY2VsZXJhdG9yX2RldmljZV9jb3VudBgHIAEoDRIpCiFyZXF1aXJlZF9hY2NlbGVyYXRvcl9kZXZpY2VfY291bnQYCCABKA0imAEKHFVwZGF0ZU1vZGVsRGVwbG95bWVudFJlcXVlc3QSGwoTbW9kZWxfZGVwbG95bWVudF9pZBgBIAEoCRJbCh51cHN0cmVhbV9kZXNpcmVkX2NvbmZpZ3VyYXRpb24YAiABKAsyMy5kb3Rjb3JlLm1vZGVsLnYxLlVwc3RyZWFtTW9kZWxEZXNpcmVkQ29uZmlndXJhdGlvbiJWCh1VcGRhdGVNb2RlbERlcGxveW1lbnRSZXNwb25zZRI1CgpkZXBsb3ltZW50GAEgASgLMiEuZG90Y29yZS5tb2RlbC52MS5Nb2RlbERlcGxveW1lbnQiOwocUmVtb3ZlTW9kZWxEZXBsb3ltZW50UmVxdWVzdBIbChNtb2RlbF9kZXBsb3ltZW50X2lkGAEgASgJIlYKHVJlbW92ZU1vZGVsRGVwbG95bWVudFJlc3BvbnNlEjUKCmRlcGxveW1lbnQYASABKAsyIS5kb3Rjb3JlLm1vZGVsLnYxLk1vZGVsRGVwbG95bWVudCIwChFQYXVzZU1vZGVsUmVxdWVzdBIbChNtb2RlbF9kZXBsb3ltZW50X2lkGAEgASgJIksKElBhdXNlTW9kZWxSZXNwb25zZRI1CgpkZXBsb3ltZW50GAEgASgLMiEuZG90Y29yZS5tb2RlbC52MS5Nb2RlbERlcGxveW1lbnQiMQoSUmVzdW1lTW9kZWxSZXF1ZXN0EhsKE21vZGVsX2RlcGxveW1lbnRfaWQYASABKAkiTAoTUmVzdW1lTW9kZWxSZXNwb25zZRI1CgpkZXBsb3ltZW50GAEgASgLMiEuZG90Y29yZS5tb2RlbC52MS5Nb2RlbERlcGxveW1lbnQisQEKGVJlYWREZXBsb3ltZW50TG9nc1JlcXVlc3QSGwoTbW9kZWxfZGVwbG95bWVudF9pZBgBIAEoCRIUCgxzb3VyY2VfbmFtZXMYAiADKAkSEQoJcGFnZV9zaXplGAMgASgNEjkKEGJlZm9yZV90aW1lc3RhbXAYBCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wSACIAQFCEwoRX2JlZm9yZV90aW1lc3RhbXAijgMKGlJlYWREZXBsb3ltZW50TG9nc1Jlc3BvbnNlEjYKB3NvdXJjZXMYASADKAsyJS5kb3Rjb3JlLm1vZGVsLnYxLkRlcGxveW1lbnRMb2dTb3VyY2USNQoHZW50cmllcxgCIAMoCzIkLmRvdGNvcmUubW9kZWwudjEuRGVwbG95bWVudExvZ0VudHJ5Ej4KFW5leHRfYmVmb3JlX3RpbWVzdGFtcBgDIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBIAIgBARJCCg1oaXN0b3J5X3N0YXRlGAQgASgOMisuZG90Y29yZS5tb2RlbC52MS5EZXBsb3ltZW50TG9nSGlzdG9yeVN0YXRlEhoKEnJlc3BvbnNlX3RydW5jYXRlZBgFIAEoCBJHChBsYXRlc3RfcG9zaXRpb25zGAYgAygLMi0uZG90Y29yZS5tb2RlbC52MS5EZXBsb3ltZW50TG9nUmVzdW1lUG9zaXRpb25CGAoWX25leHRfYmVmb3JlX3RpbWVzdGFtcCKZAQobRm9sbG93RGVwbG95bWVudExvZ3NSZXF1ZXN0EhsKE21vZGVsX2RlcGxveW1lbnRfaWQYASABKAkSFAoMc291cmNlX25hbWVzGAIgAygJEkcKEHJlc3VtZV9wb3NpdGlvbnMYAyADKAsyLS5kb3Rjb3JlLm1vZGVsLnYxLkRlcGxveW1lbnRMb2dSZXN1bWVQb3NpdGlvbiKiAQocRm9sbG93RGVwbG95bWVudExvZ3NSZXNwb25zZRJCCgxzb3VyY2VfZXZlbnQYASABKAsyKi5kb3Rjb3JlLm1vZGVsLnYxLkRlcGxveW1lbnRMb2dTb3VyY2VFdmVudEgAEjUKBWVudHJ5GAIgASgLMiQuZG90Y29yZS5tb2RlbC52MS5EZXBsb3ltZW50TG9nRW50cnlIAEIHCgVldmVudCJ7ChNEZXBsb3ltZW50TG9nU291cmNlEhMKC3NvdXJjZV9uYW1lGAEgASgJEhQKDHNvdXJjZV9sYWJlbBgCIAEoCRI5CgVzdGF0ZRgDIAEoDjIqLmRvdGNvcmUubW9kZWwudjEuRGVwbG95bWVudExvZ1NvdXJjZVN0YXRlIskBChJEZXBsb3ltZW50TG9nRW50cnkSEwoLc291cmNlX25hbWUYASABKAkSFAoMc291cmNlX2xhYmVsGAIgASgJEjUKBnN0cmVhbRgDIAEoDjIlLmRvdGNvcmUubW9kZWwudjEuRGVwbG95bWVudExvZ1N0cmVhbRItCgl0aW1lc3RhbXAYBCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEg8KB2NvbnRlbnQYBSABKAwSEQoJdHJ1bmNhdGVkGAYgASgIImgKG0RlcGxveW1lbnRMb2dSZXN1bWVQb3NpdGlvbhITCgtzb3VyY2VfbmFtZRgBIAEoCRI0ChBsYXRlc3RfdGltZXN0YW1wGAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCKAAQoYRGVwbG95bWVudExvZ1NvdXJjZUV2ZW50EhMKC3NvdXJjZV9uYW1lGAEgASgJEhQKDHNvdXJjZV9sYWJlbBgCIAEoCRI5CgVzdGF0ZRgDIAEoDjIqLmRvdGNvcmUubW9kZWwudjEuRGVwbG95bWVudExvZ1NvdXJjZVN0YXRlIrEBCiRVcGRhdGVNb2RlbERlcGxveW1lbnRNZXRhZGF0YVJlcXVlc3QSGwoTbW9kZWxfZGVwbG95bWVudF9pZBgBIAEoCRI7CghtZXRhZGF0YRgCIAEoCzIpLmRvdGNvcmUubW9kZWwudjEuTW9kZWxEZXBsb3ltZW50TWV0YWRhdGESLwoLdXBkYXRlX21hc2sYAyABKAsyGi5nb29nbGUucHJvdG9idWYuRmllbGRNYXNrIkUKF01vZGVsRGVwbG95bWVudE1ldGFkYXRhEhkKDGRpc3BsYXlfbmFtZRgBIAEoCUgAiAEBQg8KDV9kaXNwbGF5X25hbWUiXgolVXBkYXRlTW9kZWxEZXBsb3ltZW50TWV0YWRhdGFSZXNwb25zZRI1CgpkZXBsb3ltZW50GAEgASgLMiEuZG90Y29yZS5tb2RlbC52MS5Nb2RlbERlcGxveW1lbnQq4AEKHExvY2FsTW9kZWxEZXBsb3ltZW50QWR2aXNvcnkSLworTE9DQUxfTU9ERUxfREVQTE9ZTUVOVF9BRFZJU09SWV9VTlNQRUNJRklFRBAAEkEKPUxPQ0FMX01PREVMX0RFUExPWU1FTlRfQURWSVNPUllfSE9TVF9LVl9PRkZMT0FEX1NIQVJFU19NRU1PUlkQARJMCkhMT0NBTF9NT0RFTF9ERVBMT1lNRU5UX0FEVklTT1JZX1VOSUZJRURfTUVNT1JZX1BPT0xfQkVMT1dfREVWSUNFX01JTklNVU0QAirqAQoWTW9kZWxSZWNvbW1lbmRhdGlvbkZpdBIoCiRNT0RFTF9SRUNPTU1FTkRBVElPTl9GSVRfVU5TUEVDSUZJRUQQABIlCiFNT0RFTF9SRUNPTU1FTkRBVElPTl9GSVRfRklUU19OT1cQARIvCitNT0RFTF9SRUNPTU1FTkRBVElPTl9GSVRfTkVFRFNfTUVNT1JZX0ZSRUVEEAISJAogTU9ERUxfUkVDT01NRU5EQVRJT05fRklUX1VOS05PV04QAxIoCiRNT0RFTF9SRUNPTU1FTkRBVElPTl9GSVRfVU5SRUFDSEFCTEUQBCq+AQoYTW9kZWxDb21wYXRpYmlsaXR5U3RhdHVzEioKJk1PREVMX0NPTVBBVElCSUxJVFlfU1RBVFVTX1VOU1BFQ0lGSUVEEAASIwofTU9ERUxfQ09NUEFUSUJJTElUWV9TVEFUVVNfUlVOUxABEikKJU1PREVMX0NPTVBBVElCSUxJVFlfU1RBVFVTX0NBTk5PVF9SVU4QAhImCiJNT0RFTF9DT01QQVRJQklMSVRZX1NUQVRVU19VTktOT1dOEAMq8AUKHE1vZGVsQ29tcGF0aWJpbGl0eVJlYXNvbkNvZGUSLworTU9ERUxfQ09NUEFUSUJJTElUWV9SRUFTT05fQ09ERV9VTlNQRUNJRklFRBAAEjgKNE1PREVMX0NPTVBBVElCSUxJVFlfUkVBU09OX0NPREVfVEFSR0VUX05PVF9SRVBPUlRJTkcQARI5CjVNT0RFTF9DT01QQVRJQklMSVRZX1JFQVNPTl9DT0RFX05WSURJQV9EUklWRVJfVU5LTk9XThACEj4KOk1PREVMX0NPTVBBVElCSUxJVFlfUkVBU09OX0NPREVfQUNDRUxFUkFUT1JfTUVNT1JZX1VOS05PV04QAxI7CjdNT0RFTF9DT01QQVRJQklMSVRZX1JFQVNPTl9DT0RFX1RBUkdFVF9QTEFURk9STV9JTlZBTElEEAQSNQoxTU9ERUxfQ09NUEFUSUJJTElUWV9SRUFTT05fQ09ERV9QTEFURk9STV9NSVNNQVRDSBAFEjIKLk1PREVMX0NPTVBBVElCSUxJVFlfUkVBU09OX0NPREVfTk9fQUNDRUxFUkFUT1IQBhI/CjtNT0RFTF9DT01QQVRJQklMSVRZX1JFQVNPTl9DT0RFX05PX01BVENISU5HX0VOR0lORV9BUlRJRkFDVBAHEj8KO01PREVMX0NPTVBBVElCSUxJVFlfUkVBU09OX0NPREVfTk9fU1VQUE9SVEVEX0hBUkRXQVJFX0NMQVNTEAsSPwo7TU9ERUxfQ09NUEFUSUJJTElUWV9SRUFTT05fQ09ERV9OVklESUFfRFJJVkVSX0JFTE9XX01JTklNVU0QCBJFCkFNT0RFTF9DT01QQVRJQklMSVRZX1JFQVNPTl9DT0RFX0FDQ0VMRVJBVE9SX0RFVklDRV9DT1VOVF9NSVNNQVRDSBAJEjgKNE1PREVMX0NPTVBBVElCSUxJVFlfUkVBU09OX0NPREVfUFJPRklMRV9VTlJFU09MVkFCTEUQCiqZAgofU2l6aW5nU2NlbmFyaW9VbnN1cHBvcnRlZFJlYXNvbhIyCi5TSVpJTkdfU0NFTkFSSU9fVU5TVVBQT1JURURfUkVBU09OX1VOU1BFQ0lGSUVEEAASPAo4U0laSU5HX1NDRU5BUklPX1VOU1VQUE9SVEVEX1JFQVNPTl9DT05URVhUX0FCT1ZFX01BWElNVU0QARI9CjlTSVpJTkdfU0NFTkFSSU9fVU5TVVBQT1JURURfUkVBU09OX1BBUkFNRVRFUl9PVVRfT0ZfUkFOR0UQAxJFCkFTSVpJTkdfU0NFTkFSSU9fVU5TVVBQT1JURURfUkVBU09OX0RFUklWRURfUEFSQU1FVEVSX09VVF9PRl9SQU5HRRAEKq0BChpTdGFydGluZ1RvdGFsQ29udGV4dFNvdXJjZRItCilTVEFSVElOR19UT1RBTF9DT05URVhUX1NPVVJDRV9VTlNQRUNJRklFRBAAEi0KKVNUQVJUSU5HX1RPVEFMX0NPTlRFWFRfU09VUkNFX0xBUkdFU1RfRklUEAESMQotU1RBUlRJTkdfVE9UQUxfQ09OVEVYVF9TT1VSQ0VfUFJPRklMRV9ERUZBVUxUEAIqvgEKGURlcGxveW1lbnRMb2dIaXN0b3J5U3RhdGUSLAooREVQTE9ZTUVOVF9MT0dfSElTVE9SWV9TVEFURV9VTlNQRUNJRklFRBAAEjgKNERFUExPWU1FTlRfTE9HX0hJU1RPUllfU1RBVEVfT0xERVJfT1VUUFVUX01BWV9SRU1BSU4QARI5CjVERVBMT1lNRU5UX0xPR19ISVNUT1JZX1NUQVRFX05PX09MREVSX09VVFBVVF9PQlNFUlZFRBACKsYBChhEZXBsb3ltZW50TG9nU291cmNlU3RhdGUSKwonREVQTE9ZTUVOVF9MT0dfU09VUkNFX1NUQVRFX1VOU1BFQ0lGSUVEEAASKQolREVQTE9ZTUVOVF9MT0dfU09VUkNFX1NUQVRFX0FWQUlMQUJMRRABEisKJ0RFUExPWU1FTlRfTE9HX1NPVVJDRV9TVEFURV9VTkFWQUlMQUJMRRACEiUKIURFUExPWU1FTlRfTE9HX1NPVVJDRV9TVEFURV9FTkRFRBADKoABChNEZXBsb3ltZW50TG9nU3RyZWFtEiUKIURFUExPWU1FTlRfTE9HX1NUUkVBTV9VTlNQRUNJRklFRBAAEiAKHERFUExPWU1FTlRfTE9HX1NUUkVBTV9TVERPVVQQARIgChxERVBMT1lNRU5UX0xPR19TVFJFQU1fU1RERVJSEAIy4AwKFk1vZGVsRGVwbG95bWVudFNlcnZpY2USdQoUTGlzdE1vZGVsRGVwbG95bWVudHMSLS5kb3Rjb3JlLm1vZGVsLnYxLkxpc3RNb2RlbERlcGxveW1lbnRzUmVxdWVzdBouLmRvdGNvcmUubW9kZWwudjEuTGlzdE1vZGVsRGVwbG95bWVudHNSZXNwb25zZRJ4ChVDcmVhdGVNb2RlbERlcGxveW1lbnQSLi5kb3Rjb3JlLm1vZGVsLnYxLkNyZWF0ZU1vZGVsRGVwbG95bWVudFJlcXVlc3QaLy5kb3Rjb3JlLm1vZGVsLnYxLkNyZWF0ZU1vZGVsRGVwbG95bWVudFJlc3BvbnNlEooBChtQcmV2aWV3TG9jYWxNb2RlbERlcGxveW1lbnQSNC5kb3Rjb3JlLm1vZGVsLnYxLlByZXZpZXdMb2NhbE1vZGVsRGVwbG95bWVudFJlcXVlc3QaNS5kb3Rjb3JlLm1vZGVsLnYxLlByZXZpZXdMb2NhbE1vZGVsRGVwbG95bWVudFJlc3BvbnNlEooBChtFc3RpbWF0ZUxvY2FsTW9kZWxSZXNvdXJjZXMSNC5kb3Rjb3JlLm1vZGVsLnYxLkVzdGltYXRlTG9jYWxNb2RlbFJlc291cmNlc1JlcXVlc3QaNS5kb3Rjb3JlLm1vZGVsLnYxLkVzdGltYXRlTG9jYWxNb2RlbFJlc291cmNlc1Jlc3BvbnNlEpABCh1MaXN0TW9kZWxQcm9maWxlQ29tcGF0aWJpbGl0eRI2LmRvdGNvcmUubW9kZWwudjEuTGlzdE1vZGVsUHJvZmlsZUNvbXBhdGliaWxpdHlSZXF1ZXN0GjcuZG90Y29yZS5tb2RlbC52MS5MaXN0TW9kZWxQcm9maWxlQ29tcGF0aWJpbGl0eVJlc3BvbnNlEoEBChhMaXN0TW9kZWxSZWNvbW1lbmRhdGlvbnMSMS5kb3Rjb3JlLm1vZGVsLnYxLkxpc3RNb2RlbFJlY29tbWVuZGF0aW9uc1JlcXVlc3QaMi5kb3Rjb3JlLm1vZGVsLnYxLkxpc3RNb2RlbFJlY29tbWVuZGF0aW9uc1Jlc3BvbnNlEngKFVVwZGF0ZU1vZGVsRGVwbG95bWVudBIuLmRvdGNvcmUubW9kZWwudjEuVXBkYXRlTW9kZWxEZXBsb3ltZW50UmVxdWVzdBovLmRvdGNvcmUubW9kZWwudjEuVXBkYXRlTW9kZWxEZXBsb3ltZW50UmVzcG9uc2USeAoVUmVtb3ZlTW9kZWxEZXBsb3ltZW50Ei4uZG90Y29yZS5tb2RlbC52MS5SZW1vdmVNb2RlbERlcGxveW1lbnRSZXF1ZXN0Gi8uZG90Y29yZS5tb2RlbC52MS5SZW1vdmVNb2RlbERlcGxveW1lbnRSZXNwb25zZRJXCgpQYXVzZU1vZGVsEiMuZG90Y29yZS5tb2RlbC52MS5QYXVzZU1vZGVsUmVxdWVzdBokLmRvdGNvcmUubW9kZWwudjEuUGF1c2VNb2RlbFJlc3BvbnNlEloKC1Jlc3VtZU1vZGVsEiQuZG90Y29yZS5tb2RlbC52MS5SZXN1bWVNb2RlbFJlcXVlc3QaJS5kb3Rjb3JlLm1vZGVsLnYxLlJlc3VtZU1vZGVsUmVzcG9uc2USbwoSUmVhZERlcGxveW1lbnRMb2dzEisuZG90Y29yZS5tb2RlbC52MS5SZWFkRGVwbG95bWVudExvZ3NSZXF1ZXN0GiwuZG90Y29yZS5tb2RlbC52MS5SZWFkRGVwbG95bWVudExvZ3NSZXNwb25zZRJ3ChRGb2xsb3dEZXBsb3ltZW50TG9ncxItLmRvdGNvcmUubW9kZWwudjEuRm9sbG93RGVwbG95bWVudExvZ3NSZXF1ZXN0Gi4uZG90Y29yZS5tb2RlbC52MS5Gb2xsb3dEZXBsb3ltZW50TG9nc1Jlc3BvbnNlMAESkAEKHVVwZGF0ZU1vZGVsRGVwbG95bWVudE1ldGFkYXRhEjYuZG90Y29yZS5tb2RlbC52MS5VcGRhdGVNb2RlbERlcGxveW1lbnRNZXRhZGF0YVJlcXVlc3QaNy5kb3Rjb3JlLm1vZGVsLnYxLlVwZGF0ZU1vZGVsRGVwbG95bWVudE1ldGFkYXRhUmVzcG9uc2ViBnByb3RvMw", [file_dotcore_capacity_v1_placement_preview, file_dotcore_catalog_v1_engine_release, file_dotcore_catalog_v1_model, file_dotcore_catalog_v1_oci, file_dotcore_catalog_v1_recommendation, file_dotcore_catalog_v1_selector, file_dotcore_deployment_v1_memory_fit, file_dotcore_deployment_v1_placement, file_dotcore_model_v1_deployment, file_google_protobuf_field_mask, file_google_protobuf_timestamp]);
+ fileDesc("Ch5kb3Rjb3JlL21vZGVsL3YxL3NlcnZpY2UucHJvdG8SEGRvdGNvcmUubW9kZWwudjEiHQobTGlzdE1vZGVsRGVwbG95bWVudHNSZXF1ZXN0IlYKHExpc3RNb2RlbERlcGxveW1lbnRzUmVzcG9uc2USNgoLZGVwbG95bWVudHMYASADKAsyIS5kb3Rjb3JlLm1vZGVsLnYxLk1vZGVsRGVwbG95bWVudCK1AgocQ3JlYXRlTW9kZWxEZXBsb3ltZW50UmVxdWVzdBJECgVsb2NhbBgBIAEoCzIzLmRvdGNvcmUubW9kZWwudjEuQ3JlYXRlTG9jYWxNb2RlbERlcGxveW1lbnRSZXF1ZXN0SAASSgoIdXBzdHJlYW0YAiABKAsyNi5kb3Rjb3JlLm1vZGVsLnYxLkNyZWF0ZVVwc3RyZWFtTW9kZWxEZXBsb3ltZW50UmVxdWVzdEgAEjsKCGV4cG9zdXJlGAMgASgLMikuZG90Y29yZS5tb2RlbC52MS5Nb2RlbERlcGxveW1lbnRFeHBvc3VyZRIiChVtb2RlbF9kZXBsb3ltZW50X25hbWUYBCABKAlIAYgBAUIICgZzb3VyY2VCGAoWX21vZGVsX2RlcGxveW1lbnRfbmFtZSLPAQohQ3JlYXRlTG9jYWxNb2RlbERlcGxveW1lbnRSZXF1ZXN0EhAKCG1vZGVsX2lkGAEgASgJEhIKCnByb2ZpbGVfaWQYAiABKAkSOAoJcGxhY2VtZW50GAMgASgLMiUuZG90Y29yZS5kZXBsb3ltZW50LnYxLkV4YWN0UGxhY2VtZW50EkoKDWNvbmZpZ3VyYXRpb24YBCABKAsyMy5kb3Rjb3JlLm1vZGVsLnYxLkxvY2FsTW9kZWxEZXBsb3ltZW50Q29uZmlndXJhdGlvbiLtAQohTG9jYWxNb2RlbERlcGxveW1lbnRDb25maWd1cmF0aW9uEiEKGW1vZGVsX2FydGlmYWN0X2JpbmRpbmdfaWQYASABKAkSRAoXZW5naW5lX3BhcmFtZXRlcl92YWx1ZXMYAiABKAsyIy5kb3Rjb3JlLmNhdGFsb2cudjEuUGFyYW1ldGVyVmFsdWVzEh4KFmN1c3RvbV9hcmd1bWVudF90b2tlbnMYAyADKAkSPwoQb3B0aW9uX292ZXJyaWRlcxgEIAMoCzIlLmRvdGNvcmUuY2F0YWxvZy52MS5Nb2RlbE9wdGlvblZhbHVlcyKeAQokQ3JlYXRlVXBzdHJlYW1Nb2RlbERlcGxveW1lbnRSZXF1ZXN0EiIKGnVwc3RyZWFtX21vZGVsX3RlbXBsYXRlX2lkGAEgASgJElIKFWRlc2lyZWRfY29uZmlndXJhdGlvbhgCIAEoCzIzLmRvdGNvcmUubW9kZWwudjEuVXBzdHJlYW1Nb2RlbERlc2lyZWRDb25maWd1cmF0aW9uIpgBChdNb2RlbERlcGxveW1lbnRFeHBvc3VyZRI3CgZkaXJlY3QYASABKAsyJS5kb3Rjb3JlLm1vZGVsLnYxLkRpcmVjdFJvdXRlRXhwb3N1cmVIABI8CgZoaWRkZW4YAiABKAsyKi5kb3Rjb3JlLm1vZGVsLnYxLkhpZGRlbkRlcGxveW1lbnRFeHBvc3VyZUgAQgYKBGtpbmQiKQoTRGlyZWN0Um91dGVFeHBvc3VyZRISCgpyb3V0ZV9uYW1lGAEgASgJIhoKGEhpZGRlbkRlcGxveW1lbnRFeHBvc3VyZSJWCh1DcmVhdGVNb2RlbERlcGxveW1lbnRSZXNwb25zZRI1CgpkZXBsb3ltZW50GAEgASgLMiEuZG90Y29yZS5tb2RlbC52MS5Nb2RlbERlcGxveW1lbnQiaAoiUHJldmlld0xvY2FsTW9kZWxEZXBsb3ltZW50UmVxdWVzdBJCCgVsb2NhbBgBIAEoCzIzLmRvdGNvcmUubW9kZWwudjEuQ3JlYXRlTG9jYWxNb2RlbERlcGxveW1lbnRSZXF1ZXN0IrwBCiNQcmV2aWV3TG9jYWxNb2RlbERlcGxveW1lbnRSZXNwb25zZRJRChFyZXNvbHZlZF9zbmFwc2hvdBgBIAEoCzI2LmRvdGNvcmUubW9kZWwudjEuUmVzb2x2ZWRMb2NhbE1vZGVsRGVwbG95bWVudFNuYXBzaG90EkIKCmFkdmlzb3JpZXMYAiADKA4yLi5kb3Rjb3JlLm1vZGVsLnYxLkxvY2FsTW9kZWxEZXBsb3ltZW50QWR2aXNvcnki0AEKIkVzdGltYXRlTG9jYWxNb2RlbFJlc291cmNlc1JlcXVlc3QSEAoIbW9kZWxfaWQYASABKAkSEgoKcHJvZmlsZV9pZBgCIAEoCRJKCg1jb25maWd1cmF0aW9uGAMgASgLMjMuZG90Y29yZS5tb2RlbC52MS5Mb2NhbE1vZGVsRGVwbG95bWVudENvbmZpZ3VyYXRpb24SOAoJcGxhY2VtZW50GAQgASgLMiUuZG90Y29yZS5kZXBsb3ltZW50LnYxLkV4YWN0UGxhY2VtZW50IroBCiNFc3RpbWF0ZUxvY2FsTW9kZWxSZXNvdXJjZXNSZXNwb25zZRJRChhoYXJkd2FyZV9jbGFzc19lc3RpbWF0ZXMYASADKAsyLy5kb3Rjb3JlLm1vZGVsLnYxLkhhcmR3YXJlQ2xhc3NSZXNvdXJjZUVzdGltYXRlEkAKEXBsYWNlbWVudF9wcmV2aWV3GAIgASgLMiUuZG90Y29yZS5jYXBhY2l0eS52MS5QbGFjZW1lbnRQcmV2aWV3IvkBCh1IYXJkd2FyZUNsYXNzUmVzb3VyY2VFc3RpbWF0ZRIZChFoYXJkd2FyZV9jbGFzc19pZBgBIAEoCRI7Cgllc3RpbWF0ZXMYAiADKAsyKC5kb3Rjb3JlLm1vZGVsLnYxLlNjYWxhclJlc291cmNlRXN0aW1hdGUSTAofZGVyaXZlZF9lbmdpbmVfcGFyYW1ldGVyX3ZhbHVlcxgDIAEoCzIjLmRvdGNvcmUuY2F0YWxvZy52MS5QYXJhbWV0ZXJWYWx1ZXMSHQoQbGF1bmNoX3JlamVjdGlvbhgEIAEoCUgAiAEBQhMKEV9sYXVuY2hfcmVqZWN0aW9uIkcKFlNjYWxhclJlc291cmNlRXN0aW1hdGUSEAoIcmVzb3VyY2UYASABKAkSDQoFdmFsdWUYAiABKAQSDAoEdW5pdBgDIAEoCSK1AQokTGlzdE1vZGVsUHJvZmlsZUNvbXBhdGliaWxpdHlSZXF1ZXN0EhUKCG1vZGVsX2lkGAEgASgJSACIAQESNQoGdGFyZ2V0GAIgASgLMiUuZG90Y29yZS5kZXBsb3ltZW50LnYxLkV4YWN0UGxhY2VtZW50EjIKCHNjZW5hcmlvGAMgASgLMiAuZG90Y29yZS5tb2RlbC52MS5TaXppbmdTY2VuYXJpb0ILCglfbW9kZWxfaWQidgoOU2l6aW5nU2NlbmFyaW8SHAoUdG90YWxfY29udGV4dF90b2tlbnMYASABKA0SJwoaY29udGV4dF90b2tlbnNfcGVyX3JlcXVlc3QYAiABKA1IAIgBAUIdChtfY29udGV4dF90b2tlbnNfcGVyX3JlcXVlc3Qi1QEKJUxpc3RNb2RlbFByb2ZpbGVDb21wYXRpYmlsaXR5UmVzcG9uc2USNgoHdGFyZ2V0cxgBIAMoCzIlLmRvdGNvcmUubW9kZWwudjEuVGFyZ2V0Q29tcGF0aWJpbGl0eRI8CgdlbnRyaWVzGAIgAygLMisuZG90Y29yZS5tb2RlbC52MS5Nb2RlbFByb2ZpbGVDb21wYXRpYmlsaXR5EjYKCHByb2ZpbGVzGAMgAygLMiQuZG90Y29yZS5tb2RlbC52MS5Nb2RlbFByb2ZpbGVTaXppbmciUQofTGlzdE1vZGVsUmVjb21tZW5kYXRpb25zUmVxdWVzdBIuCgl1c2VfY2FzZXMYASADKA4yGy5kb3Rjb3JlLmNhdGFsb2cudjEuVXNlQ2FzZSJkCiBMaXN0TW9kZWxSZWNvbW1lbmRhdGlvbnNSZXNwb25zZRJACgl1c2VfY2FzZXMYASADKAsyLS5kb3Rjb3JlLm1vZGVsLnYxLlVzZUNhc2VNb2RlbFJlY29tbWVuZGF0aW9ucyKFAQobVXNlQ2FzZU1vZGVsUmVjb21tZW5kYXRpb25zEi0KCHVzZV9jYXNlGAEgASgOMhsuZG90Y29yZS5jYXRhbG9nLnYxLlVzZUNhc2USNwoEcm93cxgCIAMoCzIpLmRvdGNvcmUubW9kZWwudjEuUmVjb21tZW5kZWRNb2RlbFByb2ZpbGUifAoXUmVjb21tZW5kZWRNb2RlbFByb2ZpbGUSEAoIbW9kZWxfaWQYASABKAkSEgoKcHJvZmlsZV9pZBgCIAEoCRI7Cgd0YXJnZXRzGAMgAygLMiouZG90Y29yZS5tb2RlbC52MS5SZWNvbW1lbmRlZFByb2ZpbGVUYXJnZXQi6AEKGFJlY29tbWVuZGVkUHJvZmlsZVRhcmdldBI1CgZ0YXJnZXQYASABKAsyJS5kb3Rjb3JlLmRlcGxveW1lbnQudjEuRXhhY3RQbGFjZW1lbnQSNQoDZml0GAIgASgOMiguZG90Y29yZS5tb2RlbC52MS5Nb2RlbFJlY29tbWVuZGF0aW9uRml0EhAKCGRlcGxveWVkGAMgASgIEioKHXN0YXJ0aW5nX3RvdGFsX2NvbnRleHRfdG9rZW5zGAQgASgESACIAQFCIAoeX3N0YXJ0aW5nX3RvdGFsX2NvbnRleHRfdG9rZW5zIvUBChNUYXJnZXRDb21wYXRpYmlsaXR5EjUKBnRhcmdldBgBIAEoCzIlLmRvdGNvcmUuZGVwbG95bWVudC52MS5FeGFjdFBsYWNlbWVudBI6CgZzdGF0dXMYAiABKA4yKi5kb3Rjb3JlLm1vZGVsLnYxLk1vZGVsQ29tcGF0aWJpbGl0eVN0YXR1cxI6CgZyZWFzb24YAyABKAsyKi5kb3Rjb3JlLm1vZGVsLnYxLk1vZGVsQ29tcGF0aWJpbGl0eVJlYXNvbhIvCgtvYnNlcnZlZF9hdBgEIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAi/gUKGU1vZGVsUHJvZmlsZUNvbXBhdGliaWxpdHkSEAoIbW9kZWxfaWQYASABKAkSEgoKcHJvZmlsZV9pZBgCIAEoCRI1CgZ0YXJnZXQYAyABKAsyJS5kb3Rjb3JlLmRlcGxveW1lbnQudjEuRXhhY3RQbGFjZW1lbnQSOgoGc3RhdHVzGAQgASgOMiouZG90Y29yZS5tb2RlbC52MS5Nb2RlbENvbXBhdGliaWxpdHlTdGF0dXMSOgoGcmVhc29uGAUgASgLMiouZG90Y29yZS5tb2RlbC52MS5Nb2RlbENvbXBhdGliaWxpdHlSZWFzb24SQgoKYWR2aXNvcmllcxgGIAMoDjIuLmRvdGNvcmUubW9kZWwudjEuTG9jYWxNb2RlbERlcGxveW1lbnRBZHZpc29yeRIZChFoYXJkd2FyZV9jbGFzc19pZBgHIAEoCRI0CgptZW1vcnlfZml0GAggASgOMiAuZG90Y29yZS5kZXBsb3ltZW50LnYxLk1lbW9yeUZpdBI5Cg9mcmVlX21lbW9yeV9maXQYCyABKA4yIC5kb3Rjb3JlLmRlcGxveW1lbnQudjEuTWVtb3J5Rml0Ek0KEnVuc3VwcG9ydGVkX3JlYXNvbhgJIAEoDjIxLmRvdGNvcmUubW9kZWwudjEuU2l6aW5nU2NlbmFyaW9VbnN1cHBvcnRlZFJlYXNvbhIpChxsYXJnZXN0X3RvdGFsX2NvbnRleHRfdG9rZW5zGAogASgNSACIAQESKgodc3RhcnRpbmdfdG90YWxfY29udGV4dF90b2tlbnMYDCABKA1IAYgBARJTCh1zdGFydGluZ190b3RhbF9jb250ZXh0X3NvdXJjZRgNIAEoDjIsLmRvdGNvcmUubW9kZWwudjEuU3RhcnRpbmdUb3RhbENvbnRleHRTb3VyY2VCHwodX2xhcmdlc3RfdG90YWxfY29udGV4dF90b2tlbnNCIAoeX3N0YXJ0aW5nX3RvdGFsX2NvbnRleHRfdG9rZW5zIsoCChJNb2RlbFByb2ZpbGVTaXppbmcSEAoIbW9kZWxfaWQYASABKAkSEgoKcHJvZmlsZV9pZBgCIAEoCRJEChdlbmdpbmVfcGFyYW1ldGVyX3ZhbHVlcxgDIAEoCzIjLmRvdGNvcmUuY2F0YWxvZy52MS5QYXJhbWV0ZXJWYWx1ZXMSUQoYaGFyZHdhcmVfY2xhc3NfZXN0aW1hdGVzGAQgAygLMi8uZG90Y29yZS5tb2RlbC52MS5IYXJkd2FyZUNsYXNzUmVzb3VyY2VFc3RpbWF0ZRJNChJ1bnN1cHBvcnRlZF9yZWFzb24YBSABKA4yMS5kb3Rjb3JlLm1vZGVsLnYxLlNpemluZ1NjZW5hcmlvVW5zdXBwb3J0ZWRSZWFzb24SJgoedW5zdXBwb3J0ZWRfaGFyZHdhcmVfY2xhc3NfaWRzGAYgAygJIrUDChhNb2RlbENvbXBhdGliaWxpdHlSZWFzb24SPAoEY29kZRgBIAEoDjIuLmRvdGNvcmUubW9kZWwudjEuTW9kZWxDb21wYXRpYmlsaXR5UmVhc29uQ29kZRI8ChNjb25maWd1cmVkX3BsYXRmb3JtGAIgASgLMh8uZG90Y29yZS5jYXRhbG9nLnYxLk9jaVBsYXRmb3JtEjoKEW9ic2VydmVkX3BsYXRmb3JtGAMgASgLMh8uZG90Y29yZS5jYXRhbG9nLnYxLk9jaVBsYXRmb3JtEjkKE3N1cHBvcnRlZF9zZWxlY3RvcnMYBCADKAsyHC5kb3Rjb3JlLmNhdGFsb2cudjEuU2VsZWN0b3ISKwojbWluaW11bV9udmlkaWFfZHJpdmVyX21ham9yX3ZlcnNpb24YBSABKA0SLAokb2JzZXJ2ZWRfbnZpZGlhX2RyaXZlcl9tYWpvcl92ZXJzaW9uGAYgASgNEiAKGGFjY2VsZXJhdG9yX2RldmljZV9jb3VudBgHIAEoDRIpCiFyZXF1aXJlZF9hY2NlbGVyYXRvcl9kZXZpY2VfY291bnQYCCABKA0imAEKHFVwZGF0ZU1vZGVsRGVwbG95bWVudFJlcXVlc3QSGwoTbW9kZWxfZGVwbG95bWVudF9pZBgBIAEoCRJbCh51cHN0cmVhbV9kZXNpcmVkX2NvbmZpZ3VyYXRpb24YAiABKAsyMy5kb3Rjb3JlLm1vZGVsLnYxLlVwc3RyZWFtTW9kZWxEZXNpcmVkQ29uZmlndXJhdGlvbiJWCh1VcGRhdGVNb2RlbERlcGxveW1lbnRSZXNwb25zZRI1CgpkZXBsb3ltZW50GAEgASgLMiEuZG90Y29yZS5tb2RlbC52MS5Nb2RlbERlcGxveW1lbnQiOwocUmVtb3ZlTW9kZWxEZXBsb3ltZW50UmVxdWVzdBIbChNtb2RlbF9kZXBsb3ltZW50X2lkGAEgASgJIlYKHVJlbW92ZU1vZGVsRGVwbG95bWVudFJlc3BvbnNlEjUKCmRlcGxveW1lbnQYASABKAsyIS5kb3Rjb3JlLm1vZGVsLnYxLk1vZGVsRGVwbG95bWVudCIwChFQYXVzZU1vZGVsUmVxdWVzdBIbChNtb2RlbF9kZXBsb3ltZW50X2lkGAEgASgJIksKElBhdXNlTW9kZWxSZXNwb25zZRI1CgpkZXBsb3ltZW50GAEgASgLMiEuZG90Y29yZS5tb2RlbC52MS5Nb2RlbERlcGxveW1lbnQiMQoSUmVzdW1lTW9kZWxSZXF1ZXN0EhsKE21vZGVsX2RlcGxveW1lbnRfaWQYASABKAkiTAoTUmVzdW1lTW9kZWxSZXNwb25zZRI1CgpkZXBsb3ltZW50GAEgASgLMiEuZG90Y29yZS5tb2RlbC52MS5Nb2RlbERlcGxveW1lbnQisQEKGVJlYWREZXBsb3ltZW50TG9nc1JlcXVlc3QSGwoTbW9kZWxfZGVwbG95bWVudF9pZBgBIAEoCRIUCgxzb3VyY2VfbmFtZXMYAiADKAkSEQoJcGFnZV9zaXplGAMgASgNEjkKEGJlZm9yZV90aW1lc3RhbXAYBCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wSACIAQFCEwoRX2JlZm9yZV90aW1lc3RhbXAijgMKGlJlYWREZXBsb3ltZW50TG9nc1Jlc3BvbnNlEjYKB3NvdXJjZXMYASADKAsyJS5kb3Rjb3JlLm1vZGVsLnYxLkRlcGxveW1lbnRMb2dTb3VyY2USNQoHZW50cmllcxgCIAMoCzIkLmRvdGNvcmUubW9kZWwudjEuRGVwbG95bWVudExvZ0VudHJ5Ej4KFW5leHRfYmVmb3JlX3RpbWVzdGFtcBgDIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBIAIgBARJCCg1oaXN0b3J5X3N0YXRlGAQgASgOMisuZG90Y29yZS5tb2RlbC52MS5EZXBsb3ltZW50TG9nSGlzdG9yeVN0YXRlEhoKEnJlc3BvbnNlX3RydW5jYXRlZBgFIAEoCBJHChBsYXRlc3RfcG9zaXRpb25zGAYgAygLMi0uZG90Y29yZS5tb2RlbC52MS5EZXBsb3ltZW50TG9nUmVzdW1lUG9zaXRpb25CGAoWX25leHRfYmVmb3JlX3RpbWVzdGFtcCKZAQobRm9sbG93RGVwbG95bWVudExvZ3NSZXF1ZXN0EhsKE21vZGVsX2RlcGxveW1lbnRfaWQYASABKAkSFAoMc291cmNlX25hbWVzGAIgAygJEkcKEHJlc3VtZV9wb3NpdGlvbnMYAyADKAsyLS5kb3Rjb3JlLm1vZGVsLnYxLkRlcGxveW1lbnRMb2dSZXN1bWVQb3NpdGlvbiKiAQocRm9sbG93RGVwbG95bWVudExvZ3NSZXNwb25zZRJCCgxzb3VyY2VfZXZlbnQYASABKAsyKi5kb3Rjb3JlLm1vZGVsLnYxLkRlcGxveW1lbnRMb2dTb3VyY2VFdmVudEgAEjUKBWVudHJ5GAIgASgLMiQuZG90Y29yZS5tb2RlbC52MS5EZXBsb3ltZW50TG9nRW50cnlIAEIHCgVldmVudCJ7ChNEZXBsb3ltZW50TG9nU291cmNlEhMKC3NvdXJjZV9uYW1lGAEgASgJEhQKDHNvdXJjZV9sYWJlbBgCIAEoCRI5CgVzdGF0ZRgDIAEoDjIqLmRvdGNvcmUubW9kZWwudjEuRGVwbG95bWVudExvZ1NvdXJjZVN0YXRlIskBChJEZXBsb3ltZW50TG9nRW50cnkSEwoLc291cmNlX25hbWUYASABKAkSFAoMc291cmNlX2xhYmVsGAIgASgJEjUKBnN0cmVhbRgDIAEoDjIlLmRvdGNvcmUubW9kZWwudjEuRGVwbG95bWVudExvZ1N0cmVhbRItCgl0aW1lc3RhbXAYBCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEg8KB2NvbnRlbnQYBSABKAwSEQoJdHJ1bmNhdGVkGAYgASgIImgKG0RlcGxveW1lbnRMb2dSZXN1bWVQb3NpdGlvbhITCgtzb3VyY2VfbmFtZRgBIAEoCRI0ChBsYXRlc3RfdGltZXN0YW1wGAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCKAAQoYRGVwbG95bWVudExvZ1NvdXJjZUV2ZW50EhMKC3NvdXJjZV9uYW1lGAEgASgJEhQKDHNvdXJjZV9sYWJlbBgCIAEoCRI5CgVzdGF0ZRgDIAEoDjIqLmRvdGNvcmUubW9kZWwudjEuRGVwbG95bWVudExvZ1NvdXJjZVN0YXRlIrEBCiRVcGRhdGVNb2RlbERlcGxveW1lbnRNZXRhZGF0YVJlcXVlc3QSGwoTbW9kZWxfZGVwbG95bWVudF9pZBgBIAEoCRI7CghtZXRhZGF0YRgCIAEoCzIpLmRvdGNvcmUubW9kZWwudjEuTW9kZWxEZXBsb3ltZW50TWV0YWRhdGESLwoLdXBkYXRlX21hc2sYAyABKAsyGi5nb29nbGUucHJvdG9idWYuRmllbGRNYXNrIkUKF01vZGVsRGVwbG95bWVudE1ldGFkYXRhEhkKDGRpc3BsYXlfbmFtZRgBIAEoCUgAiAEBQg8KDV9kaXNwbGF5X25hbWUiXgolVXBkYXRlTW9kZWxEZXBsb3ltZW50TWV0YWRhdGFSZXNwb25zZRI1CgpkZXBsb3ltZW50GAEgASgLMiEuZG90Y29yZS5tb2RlbC52MS5Nb2RlbERlcGxveW1lbnQq4AEKHExvY2FsTW9kZWxEZXBsb3ltZW50QWR2aXNvcnkSLworTE9DQUxfTU9ERUxfREVQTE9ZTUVOVF9BRFZJU09SWV9VTlNQRUNJRklFRBAAEkEKPUxPQ0FMX01PREVMX0RFUExPWU1FTlRfQURWSVNPUllfSE9TVF9LVl9PRkZMT0FEX1NIQVJFU19NRU1PUlkQARJMCkhMT0NBTF9NT0RFTF9ERVBMT1lNRU5UX0FEVklTT1JZX1VOSUZJRURfTUVNT1JZX1BPT0xfQkVMT1dfREVWSUNFX01JTklNVU0QAirqAQoWTW9kZWxSZWNvbW1lbmRhdGlvbkZpdBIoCiRNT0RFTF9SRUNPTU1FTkRBVElPTl9GSVRfVU5TUEVDSUZJRUQQABIlCiFNT0RFTF9SRUNPTU1FTkRBVElPTl9GSVRfRklUU19OT1cQARIvCitNT0RFTF9SRUNPTU1FTkRBVElPTl9GSVRfTkVFRFNfTUVNT1JZX0ZSRUVEEAISJAogTU9ERUxfUkVDT01NRU5EQVRJT05fRklUX1VOS05PV04QAxIoCiRNT0RFTF9SRUNPTU1FTkRBVElPTl9GSVRfVU5SRUFDSEFCTEUQBCq+AQoYTW9kZWxDb21wYXRpYmlsaXR5U3RhdHVzEioKJk1PREVMX0NPTVBBVElCSUxJVFlfU1RBVFVTX1VOU1BFQ0lGSUVEEAASIwofTU9ERUxfQ09NUEFUSUJJTElUWV9TVEFUVVNfUlVOUxABEikKJU1PREVMX0NPTVBBVElCSUxJVFlfU1RBVFVTX0NBTk5PVF9SVU4QAhImCiJNT0RFTF9DT01QQVRJQklMSVRZX1NUQVRVU19VTktOT1dOEAMq8AUKHE1vZGVsQ29tcGF0aWJpbGl0eVJlYXNvbkNvZGUSLworTU9ERUxfQ09NUEFUSUJJTElUWV9SRUFTT05fQ09ERV9VTlNQRUNJRklFRBAAEjgKNE1PREVMX0NPTVBBVElCSUxJVFlfUkVBU09OX0NPREVfVEFSR0VUX05PVF9SRVBPUlRJTkcQARI5CjVNT0RFTF9DT01QQVRJQklMSVRZX1JFQVNPTl9DT0RFX05WSURJQV9EUklWRVJfVU5LTk9XThACEj4KOk1PREVMX0NPTVBBVElCSUxJVFlfUkVBU09OX0NPREVfQUNDRUxFUkFUT1JfTUVNT1JZX1VOS05PV04QAxI7CjdNT0RFTF9DT01QQVRJQklMSVRZX1JFQVNPTl9DT0RFX1RBUkdFVF9QTEFURk9STV9JTlZBTElEEAQSNQoxTU9ERUxfQ09NUEFUSUJJTElUWV9SRUFTT05fQ09ERV9QTEFURk9STV9NSVNNQVRDSBAFEjIKLk1PREVMX0NPTVBBVElCSUxJVFlfUkVBU09OX0NPREVfTk9fQUNDRUxFUkFUT1IQBhI/CjtNT0RFTF9DT01QQVRJQklMSVRZX1JFQVNPTl9DT0RFX05PX01BVENISU5HX0VOR0lORV9BUlRJRkFDVBAHEj8KO01PREVMX0NPTVBBVElCSUxJVFlfUkVBU09OX0NPREVfTk9fU1VQUE9SVEVEX0hBUkRXQVJFX0NMQVNTEAsSPwo7TU9ERUxfQ09NUEFUSUJJTElUWV9SRUFTT05fQ09ERV9OVklESUFfRFJJVkVSX0JFTE9XX01JTklNVU0QCBJFCkFNT0RFTF9DT01QQVRJQklMSVRZX1JFQVNPTl9DT0RFX0FDQ0VMRVJBVE9SX0RFVklDRV9DT1VOVF9NSVNNQVRDSBAJEjgKNE1PREVMX0NPTVBBVElCSUxJVFlfUkVBU09OX0NPREVfUFJPRklMRV9VTlJFU09MVkFCTEUQCiqZAgofU2l6aW5nU2NlbmFyaW9VbnN1cHBvcnRlZFJlYXNvbhIyCi5TSVpJTkdfU0NFTkFSSU9fVU5TVVBQT1JURURfUkVBU09OX1VOU1BFQ0lGSUVEEAASPAo4U0laSU5HX1NDRU5BUklPX1VOU1VQUE9SVEVEX1JFQVNPTl9DT05URVhUX0FCT1ZFX01BWElNVU0QARI9CjlTSVpJTkdfU0NFTkFSSU9fVU5TVVBQT1JURURfUkVBU09OX1BBUkFNRVRFUl9PVVRfT0ZfUkFOR0UQAxJFCkFTSVpJTkdfU0NFTkFSSU9fVU5TVVBQT1JURURfUkVBU09OX0RFUklWRURfUEFSQU1FVEVSX09VVF9PRl9SQU5HRRAEKq0BChpTdGFydGluZ1RvdGFsQ29udGV4dFNvdXJjZRItCilTVEFSVElOR19UT1RBTF9DT05URVhUX1NPVVJDRV9VTlNQRUNJRklFRBAAEi0KKVNUQVJUSU5HX1RPVEFMX0NPTlRFWFRfU09VUkNFX0xBUkdFU1RfRklUEAESMQotU1RBUlRJTkdfVE9UQUxfQ09OVEVYVF9TT1VSQ0VfUFJPRklMRV9ERUZBVUxUEAIqvgEKGURlcGxveW1lbnRMb2dIaXN0b3J5U3RhdGUSLAooREVQTE9ZTUVOVF9MT0dfSElTVE9SWV9TVEFURV9VTlNQRUNJRklFRBAAEjgKNERFUExPWU1FTlRfTE9HX0hJU1RPUllfU1RBVEVfT0xERVJfT1VUUFVUX01BWV9SRU1BSU4QARI5CjVERVBMT1lNRU5UX0xPR19ISVNUT1JZX1NUQVRFX05PX09MREVSX09VVFBVVF9PQlNFUlZFRBACKsYBChhEZXBsb3ltZW50TG9nU291cmNlU3RhdGUSKwonREVQTE9ZTUVOVF9MT0dfU09VUkNFX1NUQVRFX1VOU1BFQ0lGSUVEEAASKQolREVQTE9ZTUVOVF9MT0dfU09VUkNFX1NUQVRFX0FWQUlMQUJMRRABEisKJ0RFUExPWU1FTlRfTE9HX1NPVVJDRV9TVEFURV9VTkFWQUlMQUJMRRACEiUKIURFUExPWU1FTlRfTE9HX1NPVVJDRV9TVEFURV9FTkRFRBADKoABChNEZXBsb3ltZW50TG9nU3RyZWFtEiUKIURFUExPWU1FTlRfTE9HX1NUUkVBTV9VTlNQRUNJRklFRBAAEiAKHERFUExPWU1FTlRfTE9HX1NUUkVBTV9TVERPVVQQARIgChxERVBMT1lNRU5UX0xPR19TVFJFQU1fU1RERVJSEAIy4AwKFk1vZGVsRGVwbG95bWVudFNlcnZpY2USdQoUTGlzdE1vZGVsRGVwbG95bWVudHMSLS5kb3Rjb3JlLm1vZGVsLnYxLkxpc3RNb2RlbERlcGxveW1lbnRzUmVxdWVzdBouLmRvdGNvcmUubW9kZWwudjEuTGlzdE1vZGVsRGVwbG95bWVudHNSZXNwb25zZRJ4ChVDcmVhdGVNb2RlbERlcGxveW1lbnQSLi5kb3Rjb3JlLm1vZGVsLnYxLkNyZWF0ZU1vZGVsRGVwbG95bWVudFJlcXVlc3QaLy5kb3Rjb3JlLm1vZGVsLnYxLkNyZWF0ZU1vZGVsRGVwbG95bWVudFJlc3BvbnNlEooBChtQcmV2aWV3TG9jYWxNb2RlbERlcGxveW1lbnQSNC5kb3Rjb3JlLm1vZGVsLnYxLlByZXZpZXdMb2NhbE1vZGVsRGVwbG95bWVudFJlcXVlc3QaNS5kb3Rjb3JlLm1vZGVsLnYxLlByZXZpZXdMb2NhbE1vZGVsRGVwbG95bWVudFJlc3BvbnNlEooBChtFc3RpbWF0ZUxvY2FsTW9kZWxSZXNvdXJjZXMSNC5kb3Rjb3JlLm1vZGVsLnYxLkVzdGltYXRlTG9jYWxNb2RlbFJlc291cmNlc1JlcXVlc3QaNS5kb3Rjb3JlLm1vZGVsLnYxLkVzdGltYXRlTG9jYWxNb2RlbFJlc291cmNlc1Jlc3BvbnNlEpABCh1MaXN0TW9kZWxQcm9maWxlQ29tcGF0aWJpbGl0eRI2LmRvdGNvcmUubW9kZWwudjEuTGlzdE1vZGVsUHJvZmlsZUNvbXBhdGliaWxpdHlSZXF1ZXN0GjcuZG90Y29yZS5tb2RlbC52MS5MaXN0TW9kZWxQcm9maWxlQ29tcGF0aWJpbGl0eVJlc3BvbnNlEoEBChhMaXN0TW9kZWxSZWNvbW1lbmRhdGlvbnMSMS5kb3Rjb3JlLm1vZGVsLnYxLkxpc3RNb2RlbFJlY29tbWVuZGF0aW9uc1JlcXVlc3QaMi5kb3Rjb3JlLm1vZGVsLnYxLkxpc3RNb2RlbFJlY29tbWVuZGF0aW9uc1Jlc3BvbnNlEngKFVVwZGF0ZU1vZGVsRGVwbG95bWVudBIuLmRvdGNvcmUubW9kZWwudjEuVXBkYXRlTW9kZWxEZXBsb3ltZW50UmVxdWVzdBovLmRvdGNvcmUubW9kZWwudjEuVXBkYXRlTW9kZWxEZXBsb3ltZW50UmVzcG9uc2USeAoVUmVtb3ZlTW9kZWxEZXBsb3ltZW50Ei4uZG90Y29yZS5tb2RlbC52MS5SZW1vdmVNb2RlbERlcGxveW1lbnRSZXF1ZXN0Gi8uZG90Y29yZS5tb2RlbC52MS5SZW1vdmVNb2RlbERlcGxveW1lbnRSZXNwb25zZRJXCgpQYXVzZU1vZGVsEiMuZG90Y29yZS5tb2RlbC52MS5QYXVzZU1vZGVsUmVxdWVzdBokLmRvdGNvcmUubW9kZWwudjEuUGF1c2VNb2RlbFJlc3BvbnNlEloKC1Jlc3VtZU1vZGVsEiQuZG90Y29yZS5tb2RlbC52MS5SZXN1bWVNb2RlbFJlcXVlc3QaJS5kb3Rjb3JlLm1vZGVsLnYxLlJlc3VtZU1vZGVsUmVzcG9uc2USbwoSUmVhZERlcGxveW1lbnRMb2dzEisuZG90Y29yZS5tb2RlbC52MS5SZWFkRGVwbG95bWVudExvZ3NSZXF1ZXN0GiwuZG90Y29yZS5tb2RlbC52MS5SZWFkRGVwbG95bWVudExvZ3NSZXNwb25zZRJ3ChRGb2xsb3dEZXBsb3ltZW50TG9ncxItLmRvdGNvcmUubW9kZWwudjEuRm9sbG93RGVwbG95bWVudExvZ3NSZXF1ZXN0Gi4uZG90Y29yZS5tb2RlbC52MS5Gb2xsb3dEZXBsb3ltZW50TG9nc1Jlc3BvbnNlMAESkAEKHVVwZGF0ZU1vZGVsRGVwbG95bWVudE1ldGFkYXRhEjYuZG90Y29yZS5tb2RlbC52MS5VcGRhdGVNb2RlbERlcGxveW1lbnRNZXRhZGF0YVJlcXVlc3QaNy5kb3Rjb3JlLm1vZGVsLnYxLlVwZGF0ZU1vZGVsRGVwbG95bWVudE1ldGFkYXRhUmVzcG9uc2ViBnByb3RvMw", [file_dotcore_capacity_v1_placement_preview, file_dotcore_catalog_v1_model, file_dotcore_catalog_v1_oci, file_dotcore_catalog_v1_parameter, file_dotcore_catalog_v1_recommendation, file_dotcore_catalog_v1_selector, file_dotcore_deployment_v1_memory_fit, file_dotcore_deployment_v1_placement, file_dotcore_model_v1_deployment, file_google_protobuf_field_mask, file_google_protobuf_timestamp]);
/**
* Requests the controller-owned model deployments.
@@ -176,9 +176,9 @@ export type LocalModelDeploymentConfiguration = Message<"dotcore.model.v1.LocalM
/**
* Exact typed engine parameter values when supplied, an empty set deriving from sizing; the profile's defaults when absent, even with a supplied binding.
*
- * @generated from field: dotcore.catalog.v1.EngineParameterValues engine_parameter_values = 2;
+ * @generated from field: dotcore.catalog.v1.ParameterValues engine_parameter_values = 2;
*/
- engineParameterValues?: EngineParameterValues;
+ engineParameterValues?: ParameterValues;
/**
* Exact ordered custom engine argv tokens appended without interpretation.
@@ -469,9 +469,9 @@ export type HardwareClassResourceEstimate = Message<"dotcore.model.v1.HardwareCl
/**
* Engine parameter values derived from catalog sizing because the configuration omitted them.
*
- * @generated from field: dotcore.catalog.v1.EngineParameterValues derived_engine_parameter_values = 3;
+ * @generated from field: dotcore.catalog.v1.ParameterValues derived_engine_parameter_values = 3;
*/
- derivedEngineParameterValues?: EngineParameterValues;
+ derivedEngineParameterValues?: ParameterValues;
/**
* Operator-readable reason a launch would reject these values; absent when it wouldn't.
@@ -942,9 +942,9 @@ export type ModelProfileSizing = Message<"dotcore.model.v1.ModelProfileSizing">
/**
* Profile and engine parameter defaults with the scenario's values and the filled context per request, without sizing-derived values; absent when unsupported.
*
- * @generated from field: dotcore.catalog.v1.EngineParameterValues engine_parameter_values = 3;
+ * @generated from field: dotcore.catalog.v1.ParameterValues engine_parameter_values = 3;
*/
- engineParameterValues?: EngineParameterValues;
+ engineParameterValues?: ParameterValues;
/**
* Estimates per supported hardware class in declaration order, omitting unsupported classes and classes whose sizing fails.
diff --git a/proto/dotcore/catalog/v1/application.proto b/proto/dotcore/catalog/v1/application.proto
index 06387a96..5ed0c51f 100644
--- a/proto/dotcore/catalog/v1/application.proto
+++ b/proto/dotcore/catalog/v1/application.proto
@@ -36,17 +36,9 @@ message ApplicationGraph {
// The one service port that can be projected as the primary endpoint.
ApplicationPrimaryEndpoint primary_endpoint = 2;
- // Typed parameters operators may set at deployment creation, in display order.
- repeated ApplicationParameter parameters = 3;
-}
-
-// One typed application parameter and how the console shows it.
-message ApplicationParameter {
- // Typed definition; application parameters always declare a default.
- ParameterDefinition definition = 1;
-
- // Whether the console shows this parameter only in its advanced view.
- bool advanced = 2;
+ // Typed parameters operators may set at deployment creation, in display order; each declares a
+ // default and is basic or advanced.
+ repeated ParameterDefinition parameters = 3;
}
// One named service in an immutable application graph.
diff --git a/proto/dotcore/catalog/v1/engine_release.proto b/proto/dotcore/catalog/v1/engine_release.proto
index 175eaf38..6e787775 100644
--- a/proto/dotcore/catalog/v1/engine_release.proto
+++ b/proto/dotcore/catalog/v1/engine_release.proto
@@ -4,6 +4,7 @@ package dotcore.catalog.v1;
import "dotcore/catalog/v1/nix.proto";
import "dotcore/catalog/v1/oci.proto";
+import "dotcore/catalog/v1/parameter.proto";
import "dotcore/catalog/v1/selector.proto";
import "dotcore/catalog/v1/storage.proto";
import "dotcore/engine/v1/engine.proto";
@@ -62,67 +63,6 @@ message EngineParameterSchema {
// Stable schema identity retained with resolved deployments.
string schema_id = 1;
- // Supported typed engine parameters.
- repeated EngineParameterDefinition parameters = 2;
-}
-
-// Defines one typed engine parameter and its validation constraints.
-message EngineParameterDefinition {
- // Stable engine parameter identity.
- string parameter_id = 1;
-
- // Human-readable parameter label.
- string display_name = 2;
-
- oneof parameter_type {
- // Unsigned-integer constraints for this parameter.
- UnsignedIntegerEngineParameterSchema unsigned_integer = 3;
-
- // Boolean type for this parameter.
- BooleanEngineParameterSchema boolean = 4;
- }
-
- // Whether the console exposes this parameter by default.
- bool exposed = 5;
-
- // Optional practical guidance shown with this parameter.
- string description = 6;
-}
-
-// Defines a boolean engine parameter.
-message BooleanEngineParameterSchema {}
-
-// Defines the inclusive range for an unsigned-integer engine parameter.
-message UnsignedIntegerEngineParameterSchema {
- // Inclusive minimum accepted value.
- uint64 minimum = 1;
-
- // Inclusive maximum accepted value.
- uint64 maximum = 2;
-
- // Human-readable unit label for this value.
- string unit = 3;
-
- // Value used when neither the deployment nor its profile supplies one, within the range.
- optional uint64 default_value = 4;
-}
-
-// Supplies a complete set of exact typed engine parameter values.
-message EngineParameterValues {
- // Exact values keyed by stable parameter identity.
- repeated EngineParameterValue values = 1;
-}
-
-// Supplies one exact typed engine parameter value.
-message EngineParameterValue {
- // Stable engine parameter identity.
- string parameter_id = 1;
-
- oneof value {
- // Exact unsigned-integer value.
- uint64 unsigned_integer = 2;
-
- // Exact boolean value, preserving false presence.
- bool boolean = 3;
- }
+ // Supported typed engine parameters, in display order.
+ repeated ParameterDefinition parameters = 2;
}
diff --git a/proto/dotcore/catalog/v1/model.proto b/proto/dotcore/catalog/v1/model.proto
index a54371b8..a2e57c3f 100644
--- a/proto/dotcore/catalog/v1/model.proto
+++ b/proto/dotcore/catalog/v1/model.proto
@@ -6,6 +6,7 @@ import "dotcore/catalog/v1/engine_release.proto";
import "dotcore/catalog/v1/execution.proto";
import "dotcore/catalog/v1/hardware_class.proto";
import "dotcore/catalog/v1/model_capabilities.proto";
+import "dotcore/catalog/v1/parameter.proto";
import "dotcore/engine/v1/engine.proto";
// An immutable artifact-manager catalog declaration. The inference controller
@@ -188,7 +189,7 @@ message ModelProfile {
string default_model_artifact_binding_id = 3;
// Typed engine parameter defaults for this profile, overlaid on the engine defaults.
- EngineParameterValues engine_parameter_defaults = 4;
+ ParameterValues engine_parameter_defaults = 4;
// Optional human-readable name for this profile.
string display_name = 5;
diff --git a/proto/dotcore/catalog/v1/parameter.proto b/proto/dotcore/catalog/v1/parameter.proto
index e5adf393..70a466be 100644
--- a/proto/dotcore/catalog/v1/parameter.proto
+++ b/proto/dotcore/catalog/v1/parameter.proto
@@ -2,8 +2,9 @@ syntax = "proto3";
package dotcore.catalog.v1;
-// Defines one owner-neutral typed parameter: its identity, label, guidance, and value constraints.
-// Owners decide whether a default is required and how the parameter is shown.
+// Defines one owner-neutral typed parameter: its identity, label, guidance, visibility and value
+// constraints. Owners decide which types and visibilities they accept and whether a default is
+// required.
message ParameterDefinition {
// Stable parameter identity, unique within its declaring owner.
string parameter_id = 1;
@@ -14,6 +15,9 @@ message ParameterDefinition {
// Optional practical guidance shown with this parameter.
string description = 3;
+ // Where the console shows this parameter.
+ ParameterVisibility visibility = 7;
+
oneof parameter_type {
// Boolean type for this parameter.
BooleanParameterSchema boolean = 4;
@@ -56,6 +60,12 @@ message StringEnumParameterSchema {
optional string default_value = 2;
}
+// Supplies a set of exact typed parameter values.
+message ParameterValues {
+ // Exact values keyed by stable parameter identity.
+ repeated ParameterValue values = 1;
+}
+
// Supplies one exact typed parameter value.
message ParameterValue {
// Stable parameter identity.
@@ -72,3 +82,18 @@ message ParameterValue {
string string = 4;
}
}
+
+// Where the console shows a parameter.
+enum ParameterVisibility {
+ // Invalid; every parameter declares its visibility.
+ PARAMETER_VISIBILITY_UNSPECIFIED = 0;
+
+ // Shown in the main view.
+ PARAMETER_VISIBILITY_BASIC = 1;
+
+ // Shown only in the advanced view.
+ PARAMETER_VISIBILITY_ADVANCED = 2;
+
+ // Never shown; the value comes from its default, a profile or sizing.
+ PARAMETER_VISIBILITY_HIDDEN = 3;
+}
diff --git a/proto/dotcore/model/v1/deployment.proto b/proto/dotcore/model/v1/deployment.proto
index 270fc85a..c209c97f 100644
--- a/proto/dotcore/model/v1/deployment.proto
+++ b/proto/dotcore/model/v1/deployment.proto
@@ -8,6 +8,7 @@ import "dotcore/catalog/v1/hardware_class.proto";
import "dotcore/catalog/v1/model.proto";
import "dotcore/catalog/v1/model_capabilities.proto";
import "dotcore/catalog/v1/oci.proto";
+import "dotcore/catalog/v1/parameter.proto";
import "dotcore/catalog/v1/selector.proto";
import "dotcore/catalog/v1/storage.proto";
import "dotcore/catalog/v1/upstream_model_template.proto";
@@ -153,7 +154,7 @@ message ResolvedLocalModelDeploymentSnapshot {
repeated dotcore.catalog.v1.InferenceRequestKind request_kinds = 22;
// Complete exact resolved typed engine parameter values.
- dotcore.catalog.v1.EngineParameterValues engine_parameter_values = 13;
+ dotcore.catalog.v1.ParameterValues engine_parameter_values = 13;
// Engine parameter schema identity used for resolution.
string engine_parameter_schema_id = 14;
diff --git a/proto/dotcore/model/v1/service.proto b/proto/dotcore/model/v1/service.proto
index a9fe1466..394ff0a9 100644
--- a/proto/dotcore/model/v1/service.proto
+++ b/proto/dotcore/model/v1/service.proto
@@ -3,9 +3,9 @@ syntax = "proto3";
package dotcore.model.v1;
import "dotcore/capacity/v1/placement_preview.proto";
-import "dotcore/catalog/v1/engine_release.proto";
import "dotcore/catalog/v1/model.proto";
import "dotcore/catalog/v1/oci.proto";
+import "dotcore/catalog/v1/parameter.proto";
import "dotcore/catalog/v1/recommendation.proto";
import "dotcore/catalog/v1/selector.proto";
import "dotcore/deployment/v1/memory_fit.proto";
@@ -91,7 +91,7 @@ message LocalModelDeploymentConfiguration {
string model_artifact_binding_id = 1;
// Exact typed engine parameter values when supplied, an empty set deriving from sizing; the profile's defaults when absent, even with a supplied binding.
- dotcore.catalog.v1.EngineParameterValues engine_parameter_values = 2;
+ dotcore.catalog.v1.ParameterValues engine_parameter_values = 2;
// Exact ordered custom engine argv tokens appended without interpretation.
repeated string custom_argument_tokens = 3;
@@ -200,7 +200,7 @@ message HardwareClassResourceEstimate {
repeated ScalarResourceEstimate estimates = 2;
// Engine parameter values derived from catalog sizing because the configuration omitted them.
- dotcore.catalog.v1.EngineParameterValues derived_engine_parameter_values = 3;
+ dotcore.catalog.v1.ParameterValues derived_engine_parameter_values = 3;
// Operator-readable reason a launch would reject these values; absent when it wouldn't.
optional string launch_rejection = 4;
@@ -387,7 +387,7 @@ message ModelProfileSizing {
string profile_id = 2;
// Profile and engine parameter defaults with the scenario's values and the filled context per request, without sizing-derived values; absent when unsupported.
- dotcore.catalog.v1.EngineParameterValues engine_parameter_values = 3;
+ dotcore.catalog.v1.ParameterValues engine_parameter_values = 3;
// Estimates per supported hardware class in declaration order, omitting unsupported classes and classes whose sizing fails.
repeated HardwareClassResourceEstimate hardware_class_estimates = 4;
diff --git a/proto/dotcore/telemetry/v1/workload_resource.proto b/proto/dotcore/telemetry/v1/workload_resource.proto
index 43d30454..0754c061 100644
--- a/proto/dotcore/telemetry/v1/workload_resource.proto
+++ b/proto/dotcore/telemetry/v1/workload_resource.proto
@@ -2,8 +2,8 @@ syntax = "proto3";
package dotcore.telemetry.v1;
-import "dotcore/catalog/v1/engine_release.proto";
import "dotcore/catalog/v1/model.proto";
+import "dotcore/catalog/v1/parameter.proto";
import "dotcore/observation/v1/observation.proto";
import "google/protobuf/duration.proto";
import "google/protobuf/timestamp.proto";
@@ -87,7 +87,7 @@ message WorkloadResourceInputs {
string engine_artifact_reference = 8;
// Complete resolved engine parameter values.
- dotcore.catalog.v1.EngineParameterValues engine_parameter_values = 9;
+ dotcore.catalog.v1.ParameterValues engine_parameter_values = 9;
// Complete resolved option values.
repeated dotcore.catalog.v1.ModelOptionValues option_values = 10;
From 1e005092c0b91080a4ae77d4d921e7f79738b957 Mon Sep 17 00:00:00 2001
From: Andre-Philippe Paquet
Date: Thu, 12 Nov 2026 13:38:10 -0500
Subject: [PATCH 02/40] unreviewed: refactor(backend): move engines onto the
shared parameter definition
Upgrading: site files keep deployment snapshots with the old parameter fields and fail to load
after this change. Tear down model and ComfyUI deployments before upgrading, and upgrade every
node of a cluster together.
---
backend/catalog/src/lib.rs | 53 ++++----
backend/catalog/src/shared_parameters.rs | 87 ++++++++-----
backend/catalog/src/sizing/mod.rs | 74 +++++------
backend/catalog/src/test_support.rs | 21 ++--
backend/catalog/src/typed_parameters.rs | 32 +++--
backend/catalog/src/validation/application.rs | 69 ++++++----
.../catalog/src/validation/engine_release.rs | 115 +++++++++++------
backend/catalog/src/validation/graph.rs | 22 ++--
backend/catalog/src/validation/mod.rs | 14 +--
backend/catalog/src/validation/model.rs | 119 ++++++++++++------
backend/cli/src/workload/mod.rs | 39 +++---
.../src/application/controller/parameters.rs | 7 +-
.../application/controller/test_support.rs | 50 ++++----
backend/core/src/application/repository.rs | 28 ++---
backend/core/src/development/mod.rs | 4 +-
backend/core/src/model/engine/declaration.rs | 16 +--
backend/core/src/model/engine/definition.rs | 22 ++--
.../src/model/engine/llama_cpp/declaration.rs | 47 ++++---
.../core/src/model/engine/llama_cpp/mod.rs | 8 +-
.../core/src/model/engine/llama_cpp/plan.rs | 14 +--
backend/core/src/model/engine/mod.rs | 8 +-
.../src/model/engine/ninfer/declaration.rs | 28 ++---
backend/core/src/model/engine/ninfer/mod.rs | 6 +-
backend/core/src/model/engine/ninfer/plan.rs | 6 +-
.../src/model/engine/strata/declaration.rs | 39 +++---
backend/core/src/model/engine/strata/mod.rs | 12 +-
backend/core/src/model/engine/strata/plan.rs | 6 +-
.../core/src/model/engine/vllm/declaration.rs | 34 ++---
backend/core/src/model/engine/vllm/plan.rs | 12 +-
.../core/src/model/recommendation/tests.rs | 8 +-
backend/core/src/model/repository.rs | 21 ++--
backend/core/src/model/resolution.rs | 101 ++++++++-------
backend/core/src/model/resolved.rs | 6 +-
backend/core/src/model/sizing_scenario/mod.rs | 18 +--
.../core/src/model/sizing_scenario/tests.rs | 30 ++---
backend/core/src/route_manager/projection.rs | 4 +-
backend/core/src/telemetry/fit/mod.rs | 10 +-
backend/core/src/telemetry/fit/tests.rs | 8 +-
backend/core/src/telemetry/mod.rs | 10 +-
backend/core/src/test_support/mod.rs | 8 +-
backend/core/src/test_support/ninfer.rs | 51 ++++----
backend/core/src/test_support/nix.rs | 5 +-
backend/core/src/test_support/strata.rs | 43 +++----
.../core/tests/backend/backend_integration.rs | 16 +--
.../core/tests/backend/catalog_contracts.rs | 64 +++++-----
.../tests/gated/apple_hardware_integration.rs | 4 +-
backend/core/tests/gated/decision_requests.rs | 4 +-
.../core/tests/gated/nvidia_qualification.rs | 8 +-
.../multi_node/remote_model_lifecycle.rs | 7 +-
backend/core/tests/support/mod.rs | 17 ++-
backend/domain/src/test_support/model.rs | 31 ++---
.../development-catalog/engines/fixture.yaml | 7 +-
.../recommendation-catalog/engines/llama.yaml | 8 +-
.../catalog/engines/fixture.yaml | 7 +-
.../catalog/engines/fixture.yaml | 7 +-
.../engines/llama-cpp-linux-2026-10-19.yaml | 7 +-
backend/proto/tests/model_contract.rs | 92 ++++++++------
backend/proto/tests/site_contract.rs | 29 ++---
58 files changed, 887 insertions(+), 736 deletions(-)
diff --git a/backend/catalog/src/lib.rs b/backend/catalog/src/lib.rs
index 3ea5813f..82799ec2 100644
--- a/backend/catalog/src/lib.rs
+++ b/backend/catalog/src/lib.rs
@@ -16,9 +16,9 @@ compile_error!("Dotcore supports only Linux and macOS targets");
use std::{collections::BTreeMap, error::Error as StdError, fmt, path::Path};
use dotcore_proto::dotcore::catalog::v1::{
- Application, EngineArtifact, EngineParameterValues, EngineRelease, HardwareClass,
- HardwareClassRecommendations, HardwareRequirements, Model, ModelArtifact, ModelArtifactBinding,
- ModelEngineConfiguration, ModelOptionValues, ModelProfile, UpstreamModelTemplate,
+ Application, EngineArtifact, EngineRelease, HardwareClass, HardwareClassRecommendations,
+ HardwareRequirements, Model, ModelArtifact, ModelArtifactBinding, ModelEngineConfiguration,
+ ModelOptionValues, ModelProfile, ParameterValues, UpstreamModelTemplate,
};
#[cfg(test)]
use dotcore_proto::dotcore::model::v1::ResolvedLocalModelDeploymentSnapshot;
@@ -219,7 +219,7 @@ impl Catalog {
&self,
model_id: &CatalogModelId,
profile_id: &str,
- ) -> Option<(String, EngineParameterValues)> {
+ ) -> Option<(String, ParameterValues)> {
let (model, profile) = self.selected_model_profile(model_id, profile_id)?;
let engine_defaults = self
.engine_defaults
@@ -357,7 +357,7 @@ impl Catalog {
configuration_id: &str,
hardware_class_id: &str,
binding_id: &str,
- engine_parameter_values: &EngineParameterValues,
+ engine_parameter_values: &ParameterValues,
option_values: &[ModelOptionValues],
) -> Result, SizingError> {
let key = SizingKey {
@@ -383,7 +383,7 @@ impl Catalog {
binding_id: &str,
resolved_model_artifact_id: Option<&str>,
resolved_auxiliary_artifact_ids: &[String],
- engine_parameter_values: &EngineParameterValues,
+ engine_parameter_values: &ParameterValues,
option_values: &[ModelOptionValues],
) -> Result, SizingError> {
let key = SizingKey {
@@ -960,9 +960,8 @@ mod tests {
use dotcore_domain::test_support::oci::ServiceArtifactOci as _;
use dotcore_proto::dotcore::{
catalog::v1::{
- ApplicationDependencyCondition, ApplicationServiceDependency, EngineParameterValue,
- EngineParameterValues, EngineRelease, UpstreamWireProtocol, UseCase,
- engine_parameter_value::Value as ParameterValue,
+ ApplicationDependencyCondition, ApplicationServiceDependency, EngineRelease,
+ ParameterValue, ParameterValues, UpstreamWireProtocol, UseCase, parameter_value::Value,
},
engine::v1::EngineKind,
model::v1::ResolvedLocalModelDeploymentSnapshot,
@@ -1089,18 +1088,19 @@ parameterSchema:
parameters:
- parameterId: "total-context"
displayName: "Total context"
- exposed: true
+ visibility: "PARAMETER_VISIBILITY_BASIC"
unsignedInteger: {{ minimum: "1024", maximum: "131072", unit: "tokens", defaultValue: "32768" }}
- parameterId: "context-per-request"
displayName: "Context per request"
- exposed: true
+ visibility: "PARAMETER_VISIBILITY_BASIC"
unsignedInteger: {{ minimum: "1024", maximum: "131072", unit: "tokens" }}
- parameterId: "parallel-requests"
displayName: "Parallel requests"
- exposed: true
+ visibility: "PARAMETER_VISIBILITY_ADVANCED"
unsignedInteger: {{ minimum: "1", maximum: "64", unit: "requests", defaultValue: "4" }}
- parameterId: "cache-mib"
displayName: "Cache"
+ visibility: "PARAMETER_VISIBILITY_HIDDEN"
unsignedInteger: {{ minimum: "0", maximum: "{cache_maximum}", unit: "MiB" }}
artifacts:
- artifactId: "linux-amd64"
@@ -1375,10 +1375,10 @@ artifacts:
"Catalog sizing evaluation: resolved local selection does not match sizing binding"
);
- let expanded = EngineParameterValues {
- values: vec![EngineParameterValue {
+ let expanded = ParameterValues {
+ values: vec![ParameterValue {
parameter_id: "total-context".to_owned(),
- value: Some(ParameterValue::UnsignedInteger(50_177)),
+ value: Some(Value::UnsignedInteger(50_177)),
}],
};
let expanded_local = resolved_fixture_snapshot("sized-weights", "nvidia", expanded);
@@ -1786,7 +1786,7 @@ artifacts:
"local",
"nvidia",
"second",
- &EngineParameterValues::default(),
+ &ParameterValues::default(),
&[],
)
.expect("derivation evaluates"),
@@ -2575,9 +2575,7 @@ useCases:
.expect("parameter defaults")
.values[0]
.value = Some(
- dotcore_proto::dotcore::catalog::v1::engine_parameter_value::Value::UnsignedInteger(
- 1_000_000,
- ),
+ dotcore_proto::dotcore::catalog::v1::parameter_value::Value::UnsignedInteger(1_000_000),
);
model.profiles.push(duplicate);
@@ -2910,18 +2908,19 @@ parameterSchema:
parameters:
- parameterId: "total-context"
displayName: "Total context"
- exposed: true
+ visibility: "PARAMETER_VISIBILITY_BASIC"
unsignedInteger: {{ minimum: "1024", maximum: "131072", unit: "tokens", defaultValue: "32768" }}
- parameterId: "context-per-request"
displayName: "Context per request"
- exposed: true
+ visibility: "PARAMETER_VISIBILITY_BASIC"
unsignedInteger: {{ minimum: "1024", maximum: "131072", unit: "tokens" }}
- parameterId: "parallel-requests"
displayName: "Parallel requests"
- exposed: true
+ visibility: "PARAMETER_VISIBILITY_ADVANCED"
unsignedInteger: {{ minimum: "1", maximum: "64", unit: "requests", defaultValue: "4" }}
- parameterId: "cache-mib"
displayName: "Cache"
+ visibility: "PARAMETER_VISIBILITY_HIDDEN"
unsignedInteger: {{ minimum: "0", maximum: "{cache_maximum}", unit: "MiB" }}
artifacts:
- artifactId: "{id}-artifact"
@@ -3011,13 +3010,13 @@ profiles:
)
}
- fn unsigned_values(values: &[(&str, u64)]) -> EngineParameterValues {
- EngineParameterValues {
+ fn unsigned_values(values: &[(&str, u64)]) -> ParameterValues {
+ ParameterValues {
values: values
.iter()
- .map(|(parameter_id, value)| EngineParameterValue {
+ .map(|(parameter_id, value)| ParameterValue {
parameter_id: (*parameter_id).to_owned(),
- value: Some(ParameterValue::UnsignedInteger(*value)),
+ value: Some(Value::UnsignedInteger(*value)),
})
.collect(),
}
@@ -3035,7 +3034,7 @@ profiles:
fn resolved_fixture_snapshot(
model_artifact_id: &str,
hardware_class_id: &str,
- engine_parameter_values: EngineParameterValues,
+ engine_parameter_values: ParameterValues,
) -> ResolvedLocalModelDeploymentSnapshot {
ResolvedLocalModelDeploymentSnapshot {
model_id: "fixture-model".to_owned(),
diff --git a/backend/catalog/src/shared_parameters.rs b/backend/catalog/src/shared_parameters.rs
index f7db621d..c0fcc8d2 100644
--- a/backend/catalog/src/shared_parameters.rs
+++ b/backend/catalog/src/shared_parameters.rs
@@ -1,15 +1,15 @@
//! The engine parameters every engine release shares: total context, context per request and
//! parallel requests.
//!
-//! The catalog owns their ids and their fixed shape, so controllers, adapters and the console
-//! name them the same way on every engine, while each release sets its own maxima, default values
+//! The catalog owns their ids and their fixed shape and visibility, so controllers, adapters and
+//! the console name and place them the same way on every engine, while each release sets its own maxima, default values
//! and descriptions. It also fills the context per request a deployment leaves out, from the
//! maximum context and the total context.
use dotcore_proto::dotcore::catalog::v1::{
- EngineParameterDefinition, EngineParameterSchema, EngineParameterValue, EngineParameterValues,
- Model, ModelEngineConfiguration, engine_parameter_definition::ParameterType,
- engine_parameter_value::Value,
+ EngineParameterSchema, Model, ModelEngineConfiguration, ParameterDefinition, ParameterValue,
+ ParameterValues, ParameterVisibility, parameter_definition::ParameterType,
+ parameter_value::Value,
};
/// Id of the total context: the KV pool an engine holds at start, shared by active requests.
@@ -49,9 +49,9 @@ pub fn maximum_context_tokens(model: &Model, configuration: &ModelEngineConfigur
/// values already supply one, or they hold no total context to bound it.
pub fn filled_context_per_request(
schema: &EngineParameterSchema,
- values: &EngineParameterValues,
+ values: &ParameterValues,
maximum_context_tokens: u32,
-) -> Option {
+) -> Option {
let declared = schema
.parameters
.iter()
@@ -73,7 +73,7 @@ pub fn filled_context_per_request(
_ => None,
}
})?;
- Some(EngineParameterValue {
+ Some(ParameterValue {
parameter_id: CONTEXT_PER_REQUEST_PARAMETER_ID.to_owned(),
value: Some(Value::UnsignedInteger(
total_context.min(u64::from(maximum_context_tokens)),
@@ -125,6 +125,7 @@ const SHARED_PARAMETERS: [SharedParameter; 3] = [
display_name: "Total context",
unit: "tokens",
minimum: MINIMUM_CONTEXT_TOKENS,
+ visibility: ParameterVisibility::Basic,
engine_default: true,
profile_default: false,
},
@@ -133,6 +134,7 @@ const SHARED_PARAMETERS: [SharedParameter; 3] = [
display_name: "Context per request",
unit: "tokens",
minimum: MINIMUM_CONTEXT_TOKENS,
+ visibility: ParameterVisibility::Basic,
engine_default: false,
profile_default: false,
},
@@ -141,17 +143,20 @@ const SHARED_PARAMETERS: [SharedParameter; 3] = [
display_name: "Parallel requests",
unit: "requests",
minimum: 1,
+ visibility: ParameterVisibility::Advanced,
engine_default: true,
profile_default: true,
},
];
-/// One shared parameter's fixed shape. Every shared parameter is an exposed unsigned integer.
+/// One shared parameter's fixed shape. Every shared parameter is an unsigned integer the console
+/// shows: the contexts in its main view, the parallel requests in its advanced view.
struct SharedParameter {
parameter_id: &'static str,
display_name: &'static str,
unit: &'static str,
minimum: u64,
+ visibility: ParameterVisibility,
/// Whether every release must declare an engine default; otherwise none may.
engine_default: bool,
@@ -162,15 +167,15 @@ struct SharedParameter {
impl SharedParameter {
/// Returns how a declared definition departs from the fixed shape, or none when it matches.
- fn shape_problem(&self, definition: &EngineParameterDefinition) -> Option {
+ fn shape_problem(&self, definition: &ParameterDefinition) -> Option {
let Some(ParameterType::UnsignedInteger(range)) = definition.parameter_type.as_ref() else {
return Some("must be an unsigned integer".to_owned());
};
if definition.display_name != self.display_name {
return Some(format!("must be named {:?}", self.display_name));
}
- if !definition.exposed {
- return Some("must be exposed".to_owned());
+ if definition.visibility() != self.visibility {
+ return Some(format!("must be {}", visibility_name(self.visibility)));
}
if range.unit != self.unit {
return Some(format!("must be in {:?}", self.unit));
@@ -186,9 +191,19 @@ impl SharedParameter {
}
}
+/// Returns the visibility's lowercase name, for messages.
+fn visibility_name(visibility: ParameterVisibility) -> &'static str {
+ match visibility {
+ ParameterVisibility::Unspecified => "unspecified",
+ ParameterVisibility::Basic => "basic",
+ ParameterVisibility::Advanced => "advanced",
+ ParameterVisibility::Hidden => "hidden",
+ }
+}
+
#[cfg(test)]
mod tests {
- use dotcore_proto::dotcore::catalog::v1::UnsignedIntegerEngineParameterSchema;
+ use dotcore_proto::dotcore::catalog::v1::UnsignedIntegerParameterSchema;
use super::*;
@@ -214,7 +229,7 @@ mod tests {
#[test]
fn a_shared_parameter_in_the_wrong_shape_is_rejected() {
- let cases: [(&str, ShapeChange, &str); 7] = [
+ let cases: [(&str, ShapeChange, &str); 9] = [
(
TOTAL_CONTEXT_PARAMETER_ID,
|definition| {
@@ -229,8 +244,18 @@ mod tests {
),
(
PARALLEL_REQUESTS_PARAMETER_ID,
- |definition| definition.exposed = false,
- "must be exposed",
+ |definition| definition.set_visibility(ParameterVisibility::Hidden),
+ "must be advanced",
+ ),
+ (
+ TOTAL_CONTEXT_PARAMETER_ID,
+ |definition| definition.set_visibility(ParameterVisibility::Advanced),
+ "must be basic",
+ ),
+ (
+ CONTEXT_PER_REQUEST_PARAMETER_ID,
+ |definition| definition.set_visibility(ParameterVisibility::Hidden),
+ "must be basic",
),
(
PARALLEL_REQUESTS_PARAMETER_ID,
@@ -340,7 +365,7 @@ mod tests {
None
);
assert_eq!(
- filled_context_per_request(&schema, &EngineParameterValues::default(), 262_144),
+ filled_context_per_request(&schema, &ParameterValues::default(), 262_144),
None
);
}
@@ -371,7 +396,7 @@ mod tests {
}
/// Edits one shared parameter definition away from its fixed shape.
- type ShapeChange = fn(&mut EngineParameterDefinition);
+ type ShapeChange = fn(&mut ParameterDefinition);
fn messages(schema: &EngineParameterSchema) -> Vec {
validate_shared_parameters(schema)
@@ -415,43 +440,45 @@ mod tests {
unit: &str,
minimum: u64,
default_value: Option,
- ) -> EngineParameterDefinition {
- EngineParameterDefinition {
+ ) -> ParameterDefinition {
+ ParameterDefinition {
parameter_id: parameter_id.to_owned(),
display_name: display_name.to_owned(),
parameter_type: Some(ParameterType::UnsignedInteger(
- UnsignedIntegerEngineParameterSchema {
+ UnsignedIntegerParameterSchema {
minimum,
maximum: 16_777_216,
unit: unit.to_owned(),
default_value,
},
)),
- exposed: true,
+ visibility: SHARED_PARAMETERS
+ .iter()
+ .find(|shared| shared.parameter_id == parameter_id)
+ .map_or(ParameterVisibility::Advanced, |shared| shared.visibility)
+ as i32,
description: String::new(),
}
}
- fn range(
- definition: &mut EngineParameterDefinition,
- ) -> &mut UnsignedIntegerEngineParameterSchema {
+ fn range(definition: &mut ParameterDefinition) -> &mut UnsignedIntegerParameterSchema {
match definition.parameter_type.as_mut() {
Some(ParameterType::UnsignedInteger(range)) => range,
_ => panic!("unsigned shared parameter"),
}
}
- fn total(value: u64) -> EngineParameterValues {
- EngineParameterValues {
- values: vec![EngineParameterValue {
+ fn total(value: u64) -> ParameterValues {
+ ParameterValues {
+ values: vec![ParameterValue {
parameter_id: TOTAL_CONTEXT_PARAMETER_ID.to_owned(),
value: Some(Value::UnsignedInteger(value)),
}],
}
}
- fn context_per_request(value: u64) -> EngineParameterValue {
- EngineParameterValue {
+ fn context_per_request(value: u64) -> ParameterValue {
+ ParameterValue {
parameter_id: CONTEXT_PER_REQUEST_PARAMETER_ID.to_owned(),
value: Some(Value::UnsignedInteger(value)),
}
diff --git a/backend/catalog/src/sizing/mod.rs b/backend/catalog/src/sizing/mod.rs
index fbf74561..84e687c5 100644
--- a/backend/catalog/src/sizing/mod.rs
+++ b/backend/catalog/src/sizing/mod.rs
@@ -7,12 +7,11 @@
use std::collections::{BTreeMap, BTreeSet};
use dotcore_proto::dotcore::catalog::v1::{
- EngineParameterSchema, EngineParameterValues, ModelEngineConfiguration,
- ModelOptionParameterValue, ModelOptionValues,
- engine_parameter_definition::ParameterType as EngineParameterType,
- engine_parameter_value::Value as EngineParameterValue,
- model_option_parameter_definition::ParameterType as OptionParameterType,
+ EngineParameterSchema, ModelEngineConfiguration, ModelOptionParameterValue, ModelOptionValues,
+ ParameterValues, model_option_parameter_definition::ParameterType as OptionParameterType,
model_option_parameter_value::Value as OptionParameterValue,
+ parameter_definition::ParameterType as EngineParameterType,
+ parameter_value::Value as EngineParameterValueKind,
};
use serde::Deserialize;
@@ -79,7 +78,7 @@ impl CompiledSizing {
/// parameter id order. Supplied values always take precedence over a derivation.
pub(super) fn derive(
&self,
- engine_values: &EngineParameterValues,
+ engine_values: &ParameterValues,
option_values: &[ModelOptionValues],
) -> Result, SizingError> {
let values = self.symbol_values(engine_values, option_values)?;
@@ -114,7 +113,7 @@ impl CompiledSizing {
/// Evaluates available results in deterministic resource-name order.
pub(super) fn evaluate(
&self,
- engine_values: &EngineParameterValues,
+ engine_values: &ParameterValues,
option_values: &[ModelOptionValues],
) -> Result, SizingError> {
let values = self.symbol_values(engine_values, option_values)?;
@@ -230,7 +229,7 @@ impl CompiledSizing {
fn symbol_values(
&self,
- engine_values: &EngineParameterValues,
+ engine_values: &ParameterValues,
option_values: &[ModelOptionValues],
) -> Result, SizingError> {
let mut values = self
@@ -534,7 +533,7 @@ impl SizingFitModel {
/// Evaluates one point's constant and coefficients, or none when the estimate is unavailable.
pub fn point(
&self,
- engine_values: &EngineParameterValues,
+ engine_values: &ParameterValues,
option_values: &[ModelOptionValues],
extra_values: &BTreeMap,
) -> Result, SizingError> {
@@ -574,7 +573,7 @@ impl SizingFitModel {
pub fn predict(
&self,
parameters: &BTreeMap,
- engine_values: &EngineParameterValues,
+ engine_values: &ParameterValues,
option_values: &[ModelOptionValues],
extra_values: &BTreeMap,
) -> Result {
@@ -588,7 +587,7 @@ impl SizingFitModel {
/// appear in an allowance, so it needs only the point's values.
pub fn allowance(
&self,
- engine_values: &EngineParameterValues,
+ engine_values: &ParameterValues,
option_values: &[ModelOptionValues],
extra_values: &BTreeMap,
) -> Result {
@@ -603,7 +602,7 @@ impl SizingFitModel {
fn values(
&self,
- engine_values: &EngineParameterValues,
+ engine_values: &ParameterValues,
option_values: &[ModelOptionValues],
extra_values: &BTreeMap,
parameters: Option<&BTreeMap>,
@@ -842,16 +841,17 @@ fn option_symbols(
fn insert_engine_values(
symbols: &mut BTreeMap,
- values: &EngineParameterValues,
+ values: &ParameterValues,
) -> Result<(), SizingError> {
for parameter in &values.values {
match parameter.value.as_ref() {
- Some(EngineParameterValue::UnsignedInteger(value)) => insert_value(
+ Some(EngineParameterValueKind::UnsignedInteger(value)) => insert_value(
symbols,
parameter.parameter_id.clone(),
expression::Value::Unsigned(*value),
)?,
- Some(EngineParameterValue::Boolean(_)) => {}
+ // Sizing references integers only.
+ Some(EngineParameterValueKind::Boolean(_) | EngineParameterValueKind::String(_)) => {}
None => {
return Err(SizingError::evaluation(format!(
"Engine parameter {:?} has no value",
@@ -918,15 +918,15 @@ mod tests {
use std::collections::{BTreeMap, BTreeSet};
use dotcore_proto::dotcore::catalog::v1::{
- BooleanEngineParameterSchema, BooleanModelOptionParameterSchema, EngineParameterDefinition,
- EngineParameterSchema, EngineParameterValue, EngineParameterValues,
+ BooleanModelOptionParameterSchema, BooleanParameterSchema, EngineParameterSchema,
ModelConfigurationOption, ModelEngineConfiguration, ModelOptionParameterDefinition,
- ModelOptionParameterValue, ModelOptionValues, StringEnumModelOptionParameterSchema,
- UnsignedIntegerEngineParameterSchema, UnsignedIntegerModelOptionParameterSchema,
- engine_parameter_definition::ParameterType as EngineParameterType,
- engine_parameter_value::Value as EngineParameterValueKind,
+ ModelOptionParameterValue, ModelOptionValues, ParameterDefinition, ParameterValue,
+ ParameterValues, ParameterVisibility, StringEnumModelOptionParameterSchema,
+ UnsignedIntegerModelOptionParameterSchema, UnsignedIntegerParameterSchema,
model_option_parameter_definition::ParameterType as OptionParameterType,
model_option_parameter_value::Value as OptionParameterValueKind,
+ parameter_definition::ParameterType as EngineParameterType,
+ parameter_value::Value as EngineParameterValueKind,
};
use super::{EstimateSource, ScalarResourceEstimate, SizingSource, compile};
@@ -934,18 +934,18 @@ mod tests {
fn schema() -> EngineParameterSchema {
EngineParameterSchema {
schema_id: "test-schema".to_owned(),
- parameters: vec![EngineParameterDefinition {
+ parameters: vec![ParameterDefinition {
parameter_id: "total-context".to_owned(),
display_name: "Total context".to_owned(),
parameter_type: Some(EngineParameterType::UnsignedInteger(
- UnsignedIntegerEngineParameterSchema {
+ UnsignedIntegerParameterSchema {
minimum: 1,
maximum: 131_072,
unit: "tokens".to_owned(),
default_value: None,
},
)),
- exposed: true,
+ visibility: ParameterVisibility::Basic as i32,
description: "Maximum context.".to_owned(),
}],
}
@@ -1014,18 +1014,18 @@ mod tests {
}
}
- fn engine_values(total_context: u64) -> EngineParameterValues {
- EngineParameterValues {
- values: vec![EngineParameterValue {
+ fn engine_values(total_context: u64) -> ParameterValues {
+ ParameterValues {
+ values: vec![ParameterValue {
parameter_id: "total-context".to_owned(),
value: Some(EngineParameterValueKind::UnsignedInteger(total_context)),
}],
}
}
- fn engine_values_with_boolean(total_context: u64, enabled: bool) -> EngineParameterValues {
+ fn engine_values_with_boolean(total_context: u64, enabled: bool) -> ParameterValues {
let mut values = engine_values(total_context);
- values.values.push(EngineParameterValue {
+ values.values.push(ParameterValue {
parameter_id: "feature-enabled".to_owned(),
value: Some(EngineParameterValueKind::Boolean(enabled)),
});
@@ -1574,27 +1574,27 @@ mod tests {
fn derived_schema() -> EngineParameterSchema {
let mut schema = schema();
- schema.parameters.push(EngineParameterDefinition {
+ schema.parameters.push(ParameterDefinition {
parameter_id: "cache-bytes".to_owned(),
display_name: "Cache bytes".to_owned(),
parameter_type: Some(EngineParameterType::UnsignedInteger(
- UnsignedIntegerEngineParameterSchema {
+ UnsignedIntegerParameterSchema {
minimum: 1,
maximum: u64::MAX,
unit: "bytes".to_owned(),
default_value: None,
},
)),
- exposed: false,
+ visibility: ParameterVisibility::Hidden as i32,
description: "Cache size.".to_owned(),
});
- schema.parameters.push(EngineParameterDefinition {
+ schema.parameters.push(ParameterDefinition {
parameter_id: "feature-enabled".to_owned(),
display_name: "Feature".to_owned(),
parameter_type: Some(EngineParameterType::Boolean(
- BooleanEngineParameterSchema {},
+ BooleanParameterSchema::default(),
)),
- exposed: false,
+ visibility: ParameterVisibility::Hidden as i32,
description: "Feature toggle.".to_owned(),
});
schema
@@ -1609,8 +1609,8 @@ mod tests {
declaration
}
- fn cache_bytes_value(value: u64) -> EngineParameterValue {
- EngineParameterValue {
+ fn cache_bytes_value(value: u64) -> ParameterValue {
+ ParameterValue {
parameter_id: "cache-bytes".to_owned(),
value: Some(EngineParameterValueKind::UnsignedInteger(value)),
}
diff --git a/backend/catalog/src/test_support.rs b/backend/catalog/src/test_support.rs
index 6fd2a2d2..48d8706d 100644
--- a/backend/catalog/src/test_support.rs
+++ b/backend/catalog/src/test_support.rs
@@ -8,10 +8,10 @@ use std::{collections::BTreeMap, fs, path::PathBuf};
use dotcore_domain::test_support::{canonical_temp_dir, unique_suffix};
use dotcore_proto::dotcore::catalog::v1::{
- Application, EngineParameterValue, EngineRelease, HardwareClass, HardwareSupport, Model,
- ModelEngineConfiguration, ModelOptionParameterValue, ModelOptionValues, ModelProfile, Selector,
- UpstreamModelTemplate, UpstreamWireProtocol, engine_parameter_value,
- model_option_parameter_definition, model_option_parameter_value,
+ Application, EngineRelease, HardwareClass, HardwareSupport, Model, ModelEngineConfiguration,
+ ModelOptionParameterValue, ModelOptionValues, ModelProfile, ParameterValue, Selector,
+ UpstreamModelTemplate, UpstreamWireProtocol, model_option_parameter_definition,
+ model_option_parameter_value, parameter_value,
};
use crate::{
@@ -290,7 +290,7 @@ pub fn write_sizing_engine(root: &std::path::Path) {
parameters:
- parameterId: "total-context"
displayName: "Total context"
- exposed: true
+ visibility: "PARAMETER_VISIBILITY_BASIC"
unsignedInteger:
minimum: "1024"
maximum: "16777216"
@@ -298,14 +298,14 @@ pub fn write_sizing_engine(root: &std::path::Path) {
defaultValue: "32768"
- parameterId: "context-per-request"
displayName: "Context per request"
- exposed: true
+ visibility: "PARAMETER_VISIBILITY_BASIC"
unsignedInteger:
minimum: "1024"
maximum: "16777216"
unit: "tokens"
- parameterId: "parallel-requests"
displayName: "Parallel requests"
- exposed: true
+ visibility: "PARAMETER_VISIBILITY_ADVANCED"
unsignedInteger:
minimum: "1"
maximum: "64"
@@ -313,6 +313,7 @@ pub fn write_sizing_engine(root: &std::path::Path) {
defaultValue: "4"
- parameterId: "prompt-cache-memory-mib"
displayName: "Prompt cache memory"
+ visibility: "PARAMETER_VISIBILITY_HIDDEN"
unsignedInteger:
minimum: "0"
maximum: "65536"
@@ -578,9 +579,9 @@ fn choice_combinations(
combinations
}
-fn unsigned_value(parameter_id: &str, value: u64) -> EngineParameterValue {
- EngineParameterValue {
+fn unsigned_value(parameter_id: &str, value: u64) -> ParameterValue {
+ ParameterValue {
parameter_id: parameter_id.to_owned(),
- value: Some(engine_parameter_value::Value::UnsignedInteger(value)),
+ value: Some(parameter_value::Value::UnsignedInteger(value)),
}
}
diff --git a/backend/catalog/src/typed_parameters.rs b/backend/catalog/src/typed_parameters.rs
index 42e1fe2a..70b725fd 100644
--- a/backend/catalog/src/typed_parameters.rs
+++ b/backend/catalog/src/typed_parameters.rs
@@ -1,20 +1,21 @@
-//! Owner-neutral typed parameters: definition validation, value checks against a definition,
-//! and the canonical text form a value takes when projected into configuration.
+//! Owner-neutral typed parameters: definition validation, defaults, value checks against a
+//! definition, and the canonical text form a value takes when projected into configuration.
//!
-//! Application graphs use these definitions today; the model lane keeps its own engine and
-//! option parameter schemas until it adopts this one. Owners layer their own rules on top,
-//! such as whether a default is required.
+//! Application graphs and engine releases declare their parameters with these definitions. Owners
+//! layer their own rules on top, such as which types and visibilities they accept and whether a
+//! default is required.
use std::collections::BTreeSet;
use anyhow::anyhow;
use dotcore_domain::valid_id;
use dotcore_proto::dotcore::catalog::v1::{
- ParameterDefinition, ParameterValue, parameter_definition::ParameterType,
+ ParameterDefinition, ParameterValue, ParameterVisibility, parameter_definition::ParameterType,
parameter_value::Value,
};
-/// Validates one definition's identity, label, and type constraints, including any default.
+/// Validates one definition's identity, label, visibility and type constraints, including any
+/// default.
pub(crate) fn validate_parameter_definition(
definition: &ParameterDefinition,
) -> Vec {
@@ -28,6 +29,16 @@ pub(crate) fn validate_parameter_definition(
if definition.display_name.trim().is_empty() {
errors.push(anyhow!("parameter {id:?} requires a display name"));
}
+ if !matches!(
+ ParameterVisibility::try_from(definition.visibility),
+ Ok(ParameterVisibility::Basic
+ | ParameterVisibility::Advanced
+ | ParameterVisibility::Hidden)
+ ) {
+ errors.push(anyhow!(
+ "parameter {id:?} requires a basic, advanced or hidden visibility"
+ ));
+ }
match definition.parameter_type.as_ref() {
Some(ParameterType::Boolean(_)) => {}
Some(ParameterType::UnsignedInteger(range)) if range.minimum > range.maximum => {
@@ -208,6 +219,12 @@ mod tests {
let mut untyped = enum_definition(&["auto"], None);
untyped.parameter_type = None;
assert_errors(&untyped, &["parameter \"mode\" requires a type"]);
+ let mut unplaced = enum_definition(&["auto"], Some("auto"));
+ unplaced.visibility = ParameterVisibility::Unspecified as i32;
+ assert_errors(
+ &unplaced,
+ &["parameter \"mode\" requires a basic, advanced or hidden visibility"],
+ );
assert!(
validate_parameter_definition(&enum_definition(&["auto"], Some("auto"))).is_empty()
);
@@ -233,6 +250,7 @@ mod tests {
parameter_id: "mode".into(),
display_name: "Mode".into(),
description: String::new(),
+ visibility: ParameterVisibility::Basic as i32,
parameter_type: Some(parameter_type),
}
}
diff --git a/backend/catalog/src/validation/application.rs b/backend/catalog/src/validation/application.rs
index 4a73f6cf..ed845117 100644
--- a/backend/catalog/src/validation/application.rs
+++ b/backend/catalog/src/validation/application.rs
@@ -59,10 +59,10 @@ mod tests {
Application, ApplicationConfigurationBinding, ApplicationDependencyCondition,
ApplicationDeploymentInput, ApplicationEndpointProtocol, ApplicationGraph,
ApplicationHttpReadiness, ApplicationInferenceRouteInput,
- ApplicationInferenceRouteProjection, ApplicationParameter, ApplicationPrimaryEndpoint,
- ApplicationSecretBinding, ApplicationService, ApplicationServiceDependency,
- ApplicationServicePort, ApplicationServiceReadiness, BooleanParameterSchema, NixArtifact,
- NixDockerImage, NixSystemStorePath, OciPlatform, ParameterDefinition, StorageBinding,
+ ApplicationInferenceRouteProjection, ApplicationPrimaryEndpoint, ApplicationSecretBinding,
+ ApplicationService, ApplicationServiceDependency, ApplicationServicePort,
+ ApplicationServiceReadiness, BooleanParameterSchema, NixArtifact, NixDockerImage,
+ NixSystemStorePath, OciPlatform, ParameterDefinition, ParameterVisibility, StorageBinding,
StorageKind, StorageScope, StringEnumParameterSchema, UnsignedIntegerParameterSchema,
application_configuration_binding,
application_service_artifact::Kind as ServiceArtifactKind, nix_artifact::Provides,
@@ -636,6 +636,28 @@ mod tests {
),
"application parameter \"debug\" is not used by any configuration binding",
),
+ (
+ parameterized_application(
+ vec![parameter_with_visibility(
+ "mode",
+ string_enum(&["auto"], Some("auto")),
+ ParameterVisibility::Hidden,
+ )],
+ &["mode"],
+ ),
+ "application parameter \"mode\" must be basic or advanced, not hidden",
+ ),
+ (
+ parameterized_application(
+ vec![parameter_with_visibility(
+ "mode",
+ string_enum(&["auto"], Some("auto")),
+ ParameterVisibility::Unspecified,
+ )],
+ &["mode"],
+ ),
+ "parameter \"mode\" requires a basic, advanced or hidden visibility",
+ ),
];
for (application, expected) in cases {
@@ -682,13 +704,12 @@ mod tests {
fn application_yaml_declares_parameters_and_parameter_bindings() {
let graph = dotcore_proto::yaml::from_str::(
r#"parameters:
- - advanced: true
- definition:
- parameterId: "mode"
- displayName: "mode"
- stringEnum:
- values: ["auto", "highvram"]
- defaultValue: "auto"
+ - parameterId: "mode"
+ displayName: "mode"
+ visibility: "PARAMETER_VISIBILITY_ADVANCED"
+ stringEnum:
+ values: ["auto", "highvram"]
+ defaultValue: "auto"
services:
- configurationBindings:
- bindingName: "mode"
@@ -703,7 +724,7 @@ services:
[parameter_with_visibility(
"mode",
string_enum(&["auto", "highvram"], Some("auto")),
- true
+ ParameterVisibility::Advanced
)]
);
assert_eq!(
@@ -747,7 +768,7 @@ services:
}
fn parameterized_application(
- parameters: Vec,
+ parameters: Vec,
bound_ids: &[&str],
) -> Application {
let mut application = application();
@@ -768,23 +789,21 @@ services:
application
}
- fn parameter(id: &str, parameter_type: ParameterType) -> ApplicationParameter {
- parameter_with_visibility(id, parameter_type, false)
+ fn parameter(id: &str, parameter_type: ParameterType) -> ParameterDefinition {
+ parameter_with_visibility(id, parameter_type, ParameterVisibility::Basic)
}
fn parameter_with_visibility(
id: &str,
parameter_type: ParameterType,
- advanced: bool,
- ) -> ApplicationParameter {
- ApplicationParameter {
- definition: Some(ParameterDefinition {
- parameter_id: id.to_owned(),
- display_name: id.to_owned(),
- description: String::new(),
- parameter_type: Some(parameter_type),
- }),
- advanced,
+ visibility: ParameterVisibility,
+ ) -> ParameterDefinition {
+ ParameterDefinition {
+ parameter_id: id.to_owned(),
+ display_name: id.to_owned(),
+ description: String::new(),
+ visibility: visibility as i32,
+ parameter_type: Some(parameter_type),
}
}
diff --git a/backend/catalog/src/validation/engine_release.rs b/backend/catalog/src/validation/engine_release.rs
index 1f64cb81..061692b1 100644
--- a/backend/catalog/src/validation/engine_release.rs
+++ b/backend/catalog/src/validation/engine_release.rs
@@ -9,14 +9,17 @@ use std::collections::BTreeSet;
use dotcore_domain::{Accelerator, valid_id};
use dotcore_proto::dotcore::catalog::v1::{
- EngineArtifact, EngineParameterDefinition, EngineParameterSchema, EngineRelease, Selector,
- engine_artifact::Kind as EngineArtifactKind, engine_parameter_definition::ParameterType,
+ EngineArtifact, EngineParameterSchema, EngineRelease, ParameterDefinition, Selector,
+ engine_artifact::Kind as EngineArtifactKind, parameter_definition::ParameterType,
};
use super::common::{valid_selector_memory_architecture, validate_oci_reference};
use super::nix::{AcceptedProvides, nix_systems, validate_nix_artifact};
use super::storage::{StorageOwner, validate_storage_bindings};
-use crate::{OciArtifactPolicy, shared_parameters::validate_shared_parameters};
+use crate::{
+ OciArtifactPolicy, shared_parameters::validate_shared_parameters,
+ typed_parameters::validate_parameter_definition as validate_shared_definition,
+};
pub fn validate_engine_release_with_policy(
engine: &EngineRelease,
@@ -229,37 +232,26 @@ fn validate_parameter_schema(
errors.extend(validate_shared_parameters(schema));
}
+/// Validates one engine parameter definition: the shared definition rules, a unique id, and the
+/// engine owner rule that parameters are booleans or unsigned integers with a unit.
fn validate_parameter_definition(
- definition: &EngineParameterDefinition,
+ definition: &ParameterDefinition,
ids: &mut BTreeSet,
errors: &mut Vec,
) {
- if !ids.insert(definition.parameter_id.clone())
- || !valid_id(&definition.parameter_id)
- || definition.display_name.trim().is_empty()
- {
- errors.push(anyhow::anyhow!(
- "engine parameter requires a unique valid ID and display name"
- ));
+ let id = &definition.parameter_id;
+ if !ids.insert(id.clone()) {
+ errors.push(anyhow::anyhow!("duplicate engine parameter {id:?}"));
}
- if definition
- .parameter_type
- .as_ref()
- .is_none_or(|kind| match kind {
- ParameterType::UnsignedInteger(schema) => {
- schema.minimum > schema.maximum
- || schema.unit.trim().is_empty()
- || schema.default_value.is_some_and(|default| {
- !(schema.minimum..=schema.maximum).contains(&default)
- })
- }
- ParameterType::Boolean(_) => false,
- })
- {
- errors.push(anyhow::anyhow!(
- "engine parameter {:?} requires a valid typed range holding its default",
- definition.parameter_id
- ));
+ errors.extend(validate_shared_definition(definition));
+ match definition.parameter_type.as_ref() {
+ Some(ParameterType::UnsignedInteger(schema)) if schema.unit.trim().is_empty() => {
+ errors.push(anyhow::anyhow!("engine parameter {id:?} requires a unit"));
+ }
+ Some(ParameterType::StringEnum(_)) => errors.push(anyhow::anyhow!(
+ "engine parameter {id:?} must be a boolean or an unsigned integer"
+ )),
+ Some(ParameterType::Boolean(_) | ParameterType::UnsignedInteger(_)) | None => {}
}
}
@@ -267,8 +259,8 @@ fn validate_parameter_definition(
mod tests {
use dotcore_proto::dotcore::{
catalog::v1::{
- NixArtifact, NixDockerImage, StorageBinding, StorageKind, StorageScope,
- nix_artifact::Provides,
+ NixArtifact, NixDockerImage, ParameterVisibility, StorageBinding, StorageKind,
+ StorageScope, nix_artifact::Provides,
},
observation::v1::AcceleratorMemoryArchitecture,
};
@@ -403,18 +395,18 @@ mod tests {
#[test]
fn parameter_schemas_reject_an_out_of_range_default_and_a_missing_shared_parameter() {
let unsigned = |parameter_id: &str, display_name: &str, unit: &str, default_value| {
- EngineParameterDefinition {
+ ParameterDefinition {
parameter_id: parameter_id.to_owned(),
display_name: display_name.to_owned(),
parameter_type: Some(ParameterType::UnsignedInteger(
- dotcore_proto::dotcore::catalog::v1::UnsignedIntegerEngineParameterSchema {
+ dotcore_proto::dotcore::catalog::v1::UnsignedIntegerParameterSchema {
minimum: 1024,
maximum: 65_536,
unit: unit.to_owned(),
default_value,
},
)),
- exposed: true,
+ visibility: ParameterVisibility::Basic as i32,
description: String::new(),
}
};
@@ -432,12 +424,65 @@ mod tests {
assert_eq!(
errors.iter().map(ToString::to_string).collect::>(),
[
- "engine parameter \"total-context\" requires a valid typed range holding its default",
+ "parameter \"total-context\" default is invalid: 131072 is outside 1024..=65536",
"engine parameter schema \"fixture-shared\" must declare the shared parameter \"parallel-requests\"",
]
);
}
+ #[test]
+ fn engine_parameters_are_booleans_or_unsigned_integers_with_a_unit() {
+ let definition = |parameter_type| ParameterDefinition {
+ parameter_id: "feature".to_owned(),
+ display_name: "Feature".to_owned(),
+ description: String::new(),
+ visibility: ParameterVisibility::Advanced as i32,
+ parameter_type: Some(parameter_type),
+ };
+ let errors = |definition: &ParameterDefinition| {
+ let mut errors = Vec::new();
+ validate_parameter_definition(definition, &mut BTreeSet::new(), &mut errors);
+ errors.iter().map(ToString::to_string).collect::>()
+ };
+ let unsigned = |unit: &str| {
+ ParameterType::UnsignedInteger(
+ dotcore_proto::dotcore::catalog::v1::UnsignedIntegerParameterSchema {
+ minimum: 0,
+ maximum: 8,
+ unit: unit.to_owned(),
+ default_value: Some(4),
+ },
+ )
+ };
+ let boolean = ParameterType::Boolean(
+ dotcore_proto::dotcore::catalog::v1::BooleanParameterSchema {
+ default_value: Some(true),
+ },
+ );
+
+ assert_eq!(errors(&definition(boolean.clone())), Vec::::new());
+ assert_eq!(errors(&definition(unsigned("MiB"))), Vec::::new());
+ assert_eq!(
+ errors(&definition(unsigned(" "))),
+ ["engine parameter \"feature\" requires a unit"]
+ );
+ assert_eq!(
+ errors(&definition(ParameterType::StringEnum(
+ dotcore_proto::dotcore::catalog::v1::StringEnumParameterSchema {
+ values: vec!["on".to_owned()],
+ default_value: Some("on".to_owned()),
+ },
+ ))),
+ ["engine parameter \"feature\" must be a boolean or an unsigned integer"]
+ );
+ let mut unplaced = definition(boolean);
+ unplaced.visibility = ParameterVisibility::Unspecified as i32;
+ assert_eq!(
+ errors(&unplaced),
+ ["parameter \"feature\" requires a basic, advanced or hidden visibility"]
+ );
+ }
+
#[test]
fn engine_storage_requires_engine_scopes_cache_kind_and_container_artifacts() {
let mut image = nix("self");
diff --git a/backend/catalog/src/validation/graph.rs b/backend/catalog/src/validation/graph.rs
index 15633580..388f92c4 100644
--- a/backend/catalog/src/validation/graph.rs
+++ b/backend/catalog/src/validation/graph.rs
@@ -5,7 +5,8 @@ use std::collections::{BTreeMap, BTreeSet};
use dotcore_domain::valid_id;
use dotcore_proto::dotcore::catalog::v1::{
ApplicationDependencyCondition, ApplicationEndpointProtocol, ApplicationGraph,
- ApplicationService, application_configuration_binding, parameter_definition::ParameterType,
+ ApplicationService, ParameterVisibility, application_configuration_binding,
+ parameter_definition::ParameterType,
};
use super::application::valid_configuration_value;
@@ -179,17 +180,12 @@ fn validate_primary_endpoint(
}
}
-/// Checks parameter definitions and that bindings and parameters reference each other exactly:
-/// every binding names a declared parameter and every parameter supplies at least one binding.
+/// Checks parameter definitions, each with a default and shown as basic or advanced, and that
+/// bindings and parameters reference each other exactly: every binding names a declared parameter
+/// and every parameter supplies at least one binding.
fn validate_parameters(graph: &ApplicationGraph, errors: &mut Vec) {
let mut parameter_ids = BTreeSet::new();
- for parameter in &graph.parameters {
- let Some(definition) = parameter.definition.as_ref() else {
- errors.push(anyhow::anyhow!(
- "application parameter requires a definition"
- ));
- continue;
- };
+ for definition in &graph.parameters {
let id = &definition.parameter_id;
if !parameter_ids.insert(id.as_str()) {
errors.push(anyhow::anyhow!("duplicate application parameter {id:?}"));
@@ -202,6 +198,12 @@ fn validate_parameters(graph: &ApplicationGraph, errors: &mut Vec
"application parameter {id:?} requires a default"
));
}
+ // A value the operator never sees belongs in the graph as a literal.
+ if definition.visibility() == ParameterVisibility::Hidden {
+ errors.push(anyhow::anyhow!(
+ "application parameter {id:?} must be basic or advanced, not hidden"
+ ));
+ }
// Enum values are projected verbatim into environment variables.
if let Some(ParameterType::StringEnum(schema)) = definition.parameter_type.as_ref()
&& !schema
diff --git a/backend/catalog/src/validation/mod.rs b/backend/catalog/src/validation/mod.rs
index 3631a63d..76d53eb6 100644
--- a/backend/catalog/src/validation/mod.rs
+++ b/backend/catalog/src/validation/mod.rs
@@ -10,8 +10,8 @@
use std::collections::{BTreeMap, BTreeSet};
use dotcore_proto::dotcore::catalog::v1::{
- Application, EngineParameterDefinition, EngineParameterValues, EngineRelease, HardwareClass,
- HardwareClassRecommendations, Model, ModelEngineConfiguration, UpstreamModelTemplate,
+ Application, EngineRelease, HardwareClass, HardwareClassRecommendations, Model,
+ ModelEngineConfiguration, ParameterDefinition, ParameterValues, UpstreamModelTemplate,
};
use super::{
@@ -72,8 +72,8 @@ pub trait EngineDeclarationValidator: Send + Sync {
_model: &Model,
_configuration: &ModelEngineConfiguration,
_engine: &EngineRelease,
- ) -> EngineParameterValues {
- EngineParameterValues::default()
+ ) -> ParameterValues {
+ ParameterValues::default()
}
}
@@ -218,7 +218,7 @@ pub(super) fn validate_catalog(
}
/// Engine parameter defaults of each configuration, keyed by model and configuration id.
-pub(super) type ConfigurationEngineDefaults = BTreeMap<(String, String), EngineParameterValues>;
+pub(super) type ConfigurationEngineDefaults = BTreeMap<(String, String), ParameterValues>;
pub(super) struct ValidatedCatalog {
pub(super) models: BTreeMap,
@@ -479,9 +479,9 @@ fn validate_sizing_coverage(
}
fn parameter_definition<'a>(
- parameters: &'a [EngineParameterDefinition],
+ parameters: &'a [ParameterDefinition],
parameter_id: &str,
-) -> Option<&'a EngineParameterDefinition> {
+) -> Option<&'a ParameterDefinition> {
parameters
.iter()
.find(|parameter| parameter.parameter_id == parameter_id)
diff --git a/backend/catalog/src/validation/model.rs b/backend/catalog/src/validation/model.rs
index b6e3cd8a..04882982 100644
--- a/backend/catalog/src/validation/model.rs
+++ b/backend/catalog/src/validation/model.rs
@@ -11,15 +11,15 @@ use std::collections::{BTreeMap, BTreeSet};
use dotcore_domain::valid_id;
use dotcore_proto::dotcore::catalog::v1::{
- EngineArtifact, EngineParameterValue, EngineParameterValues, EngineRelease, HardwareClass,
- HuggingFaceArtifactSource, InferenceRequestKind, IntelligenceScore, License, Model,
- ModelArtifact, ModelConfigurationOption, ModelEngineConfiguration, ModelInputCapabilities,
- ModelInputModality, ModelOptionParameterDefinition, SpeculativeDecodingMethod,
- engine_parameter_definition::ParameterType as EngineParameterType,
- engine_parameter_value::Value as EngineParameterValueKind,
+ EngineArtifact, EngineRelease, HardwareClass, HuggingFaceArtifactSource, InferenceRequestKind,
+ IntelligenceScore, License, Model, ModelArtifact, ModelConfigurationOption,
+ ModelEngineConfiguration, ModelInputCapabilities, ModelInputModality,
+ ModelOptionParameterDefinition, ParameterValue, ParameterValues, SpeculativeDecodingMethod,
hugging_face_artifact_source::Content as HuggingFaceContent,
model_option::Kind as ModelOptionKind,
model_option_parameter_definition::ParameterType as ModelOptionParameterType,
+ parameter_definition::ParameterType as EngineParameterType,
+ parameter_value::Value as EngineParameterValueKind,
};
use super::common::lower_hex;
@@ -31,6 +31,7 @@ use crate::{
},
selector::{SelectorRelation, selector_relation},
shared_parameters::profile_may_default,
+ typed_parameters::default_value,
validation::{EngineDeclarationValidator, configuration_engine_release},
};
@@ -891,7 +892,7 @@ fn validate_profile_option_defaults(
/// Validates a complete set of engine parameter values against the engine schema.
pub fn validate_parameter_values(
- values: &EngineParameterValues,
+ values: &ParameterValues,
engine: &EngineRelease,
profile_id: &str,
errors: &mut Vec,
@@ -910,7 +911,7 @@ pub fn validate_parameter_values(
/// Validates supplied engine parameter values, which may omit parameters that sizing derives.
/// Completeness is checked once derivations are known.
pub fn validate_supplied_parameter_values(
- values: &EngineParameterValues,
+ values: &ParameterValues,
engine: &EngineRelease,
profile_id: &str,
errors: &mut Vec,
@@ -962,9 +963,9 @@ pub fn validate_supplied_parameter_values(
/// Overlays defaults on supplied engine parameter values, per parameter: the supplied values
/// keep their order, followed by each default whose parameter they leave out.
pub fn overlay_parameter_defaults(
- values: &EngineParameterValues,
- defaults: &EngineParameterValues,
-) -> EngineParameterValues {
+ values: &ParameterValues,
+ defaults: &ParameterValues,
+) -> ParameterValues {
let mut overlaid = values.clone();
overlaid.values.extend(
defaults
@@ -982,30 +983,24 @@ pub fn overlay_parameter_defaults(
}
/// Engine defaults the release declares for its parameters, in schema order.
-pub(super) fn engine_default_values(engine: &EngineRelease) -> EngineParameterValues {
- EngineParameterValues {
+pub(super) fn engine_default_values(engine: &EngineRelease) -> ParameterValues {
+ ParameterValues {
values: engine
.parameter_schema
.iter()
.flat_map(|schema| &schema.parameters)
- .filter_map(|definition| match definition.parameter_type.as_ref() {
- Some(EngineParameterType::UnsignedInteger(range)) => {
- range.default_value.map(|default| EngineParameterValue {
- parameter_id: definition.parameter_id.clone(),
- value: Some(EngineParameterValueKind::UnsignedInteger(default)),
- })
- }
- _ => None,
+ .filter_map(|definition| {
+ default_value(definition).map(|default| ParameterValue {
+ parameter_id: definition.parameter_id.clone(),
+ value: Some(default),
+ })
})
.collect(),
}
}
/// Engine schema parameter ids without a supplied value, in schema order.
-pub fn missing_parameter_ids(
- values: &EngineParameterValues,
- engine: &EngineRelease,
-) -> Vec {
+pub fn missing_parameter_ids(values: &ParameterValues, engine: &EngineRelease) -> Vec {
engine
.parameter_schema
.as_ref()
@@ -1133,23 +1128,73 @@ mod tests {
use std::collections::{BTreeMap, BTreeSet};
use dotcore_proto::dotcore::catalog::v1::{
- BooleanModelOptionParameterSchema, EngineArtifact, EngineArtifactReference,
- EngineParameterValues, EngineRelease, HardwareClass, InferenceRequestKind,
- KvCachePrecisionModelOption, Model, ModelArtifactBinding, ModelConfigurationOption,
- ModelEngineConfiguration, ModelOption, ModelOptionParameterDefinition,
- ModelOptionParameterValue, ModelOptionValues, ModelProfile, OciPlatform, Selector,
- SpeculativeDecodingMethod, SpeculativeDecodingModelOption,
- StringEnumModelOptionParameterSchema, UnsignedIntegerModelOptionParameterSchema,
- model_option, model_option_parameter_definition::ParameterType,
- model_option_parameter_value::Value,
+ BooleanModelOptionParameterSchema, EngineArtifact, EngineArtifactReference, EngineRelease,
+ HardwareClass, InferenceRequestKind, KvCachePrecisionModelOption, Model,
+ ModelArtifactBinding, ModelConfigurationOption, ModelEngineConfiguration, ModelOption,
+ ModelOptionParameterDefinition, ModelOptionParameterValue, ModelOptionValues, ModelProfile,
+ OciPlatform, ParameterValues, Selector, SpeculativeDecodingMethod,
+ SpeculativeDecodingModelOption, StringEnumModelOptionParameterSchema,
+ UnsignedIntegerModelOptionParameterSchema, model_option,
+ model_option_parameter_definition::ParameterType, model_option_parameter_value::Value,
};
use super::{
- overlapping_artifacts, valid_request_kinds, validate_class_artifact_coverage,
- validate_configuration_options, validate_model_options, validate_profiles,
+ engine_default_values, overlapping_artifacts, valid_request_kinds,
+ validate_class_artifact_coverage, validate_configuration_options, validate_model_options,
+ validate_profiles,
};
use crate::test_support::vendor_hardware_class;
+ #[test]
+ fn engine_defaults_cover_every_parameter_declaring_one_booleans_included() {
+ use dotcore_proto::dotcore::catalog::v1::{
+ BooleanParameterSchema, EngineParameterSchema, ParameterDefinition, ParameterValue,
+ UnsignedIntegerParameterSchema, parameter_definition::ParameterType as Type,
+ parameter_value::Value as ParameterValueKind,
+ };
+
+ let definition = |parameter_id: &str, parameter_type| ParameterDefinition {
+ parameter_id: parameter_id.to_owned(),
+ parameter_type: Some(parameter_type),
+ ..Default::default()
+ };
+ let unsigned = |default_value| {
+ Type::UnsignedInteger(UnsignedIntegerParameterSchema {
+ minimum: 0,
+ maximum: 64,
+ unit: "requests".to_owned(),
+ default_value,
+ })
+ };
+ let boolean = |default_value| Type::Boolean(BooleanParameterSchema { default_value });
+ let engine = EngineRelease {
+ parameter_schema: Some(EngineParameterSchema {
+ schema_id: "fixture".to_owned(),
+ parameters: vec![
+ definition("parallel-requests", unsigned(Some(4))),
+ definition("flash-attention", boolean(Some(false))),
+ definition("cache-mib", unsigned(None)),
+ definition("mlock", boolean(None)),
+ ],
+ }),
+ ..Default::default()
+ };
+ let value = |parameter_id: &str, value| ParameterValue {
+ parameter_id: parameter_id.to_owned(),
+ value: Some(value),
+ };
+
+ assert_eq!(
+ engine_default_values(&engine),
+ ParameterValues {
+ values: vec![
+ value("parallel-requests", ParameterValueKind::UnsignedInteger(4)),
+ value("flash-attention", ParameterValueKind::Boolean(false)),
+ ],
+ }
+ );
+ }
+
#[test]
fn request_kinds_must_be_non_empty_known_and_unique() {
let chat = InferenceRequestKind::Chat as i32;
@@ -1601,7 +1646,7 @@ mod tests {
profile_id: "balanced".to_owned(),
configuration_id: "local".to_owned(),
default_model_artifact_binding_id: "weights".to_owned(),
- engine_parameter_defaults: Some(EngineParameterValues::default()),
+ engine_parameter_defaults: Some(ParameterValues::default()),
..Default::default()
}],
..Default::default()
diff --git a/backend/cli/src/workload/mod.rs b/backend/cli/src/workload/mod.rs
index b4a5f87d..1a3c3187 100644
--- a/backend/cli/src/workload/mod.rs
+++ b/backend/cli/src/workload/mod.rs
@@ -31,8 +31,8 @@ use dotcore_node_hardware::vram::{
};
use dotcore_proto::dotcore::{
catalog::v1::{
- EngineParameterValue, EngineParameterValues, ModelOptionParameterValue, ModelOptionValues,
- engine_parameter_value, model_option_parameter_value,
+ ModelOptionParameterValue, ModelOptionValues, ParameterValue, ParameterValues,
+ model_option_parameter_value, parameter_value,
},
telemetry::v1::WorkloadResourceRecord,
};
@@ -290,7 +290,7 @@ async fn launch(paths: &RootPathArgs, args: LaunchArgs) -> Result<()> {
merge_parameter_overrides(
configuration
.engine_parameter_values
- .get_or_insert_with(EngineParameterValues::default),
+ .get_or_insert_with(ParameterValues::default),
args.engine_parameter
.into_iter()
.map(parse_parameter)
@@ -831,10 +831,7 @@ fn accelerator_device_ids(
]
}
-fn merge_parameter_overrides(
- defaults: &mut EngineParameterValues,
- overrides: Vec,
-) {
+fn merge_parameter_overrides(defaults: &mut ParameterValues, overrides: Vec) {
for parameter in overrides {
match defaults
.values
@@ -926,7 +923,7 @@ fn merge_option_overrides(
}
}
-fn parse_parameter(value: String) -> Result {
+fn parse_parameter(value: String) -> Result {
let (id, typed) = value
.split_once('=')
.ok_or_else(|| anyhow::anyhow!("engine parameter must be ID=TYPE:VALUE"))?;
@@ -934,12 +931,12 @@ fn parse_parameter(value: String) -> Result {
.split_once(':')
.ok_or_else(|| anyhow::anyhow!("engine parameter must be ID=TYPE:VALUE"))?;
let value = match kind {
- "unsigned" => engine_parameter_value::Value::UnsignedInteger(
+ "unsigned" => parameter_value::Value::UnsignedInteger(
raw.parse().context("invalid unsigned parameter")?,
),
_ => bail!("unsupported engine parameter type: {kind}"),
};
- Ok(EngineParameterValue {
+ Ok(ParameterValue {
parameter_id: id.to_owned(),
value: Some(value),
})
@@ -972,8 +969,8 @@ mod tests {
};
use dotcore_proto::dotcore::{
catalog::v1::{
- EngineParameterValue, EngineParameterValues, ModelOptionParameterValue,
- ModelOptionValues, engine_parameter_value, model_option_parameter_value,
+ ModelOptionParameterValue, ModelOptionValues, ParameterValue, ParameterValues,
+ model_option_parameter_value, parameter_value,
},
telemetry::v1::WorkloadResourceRecord,
};
@@ -982,7 +979,7 @@ mod tests {
use super::{
CommonArgs, DevSubcommand, HandleArgs, Runtime, Transport, WorkloadAction,
accelerator_device_ids, accounting_payload, gpu_memory_payload, handle_root,
- merge_option_overrides, merge_parameter_overrides, parse_option_overrides,
+ merge_option_overrides, merge_parameter_overrides, parse_option_overrides, parse_parameter,
record_measurement, resolve_handle, vram_payload, write_log_content_to,
};
use crate::RootPathArgs;
@@ -1508,16 +1505,16 @@ mod tests {
#[test]
fn explicit_parameter_values_override_profile_defaults() {
- let mut values = EngineParameterValues {
+ let mut values = ParameterValues {
values: vec![unsigned_parameter("total-context", 30_000)],
};
merge_parameter_overrides(
&mut values,
- vec![
- unsigned_parameter("total-context", 8_192),
- unsigned_parameter("new-parameter", 7),
- ],
+ ["total-context=unsigned:8192", "new-parameter=unsigned:7"]
+ .into_iter()
+ .map(|flag| parse_parameter(flag.to_owned()).expect("engine parameter flag"))
+ .collect(),
);
assert_eq!(
@@ -1566,10 +1563,10 @@ mod tests {
}
}
- fn unsigned_parameter(id: &str, value: u64) -> EngineParameterValue {
- EngineParameterValue {
+ fn unsigned_parameter(id: &str, value: u64) -> ParameterValue {
+ ParameterValue {
parameter_id: id.to_owned(),
- value: Some(engine_parameter_value::Value::UnsignedInteger(value)),
+ value: Some(parameter_value::Value::UnsignedInteger(value)),
}
}
}
diff --git a/backend/core/src/application/controller/parameters.rs b/backend/core/src/application/controller/parameters.rs
index 21a5017e..ef9b7f7c 100644
--- a/backend/core/src/application/controller/parameters.rs
+++ b/backend/core/src/application/controller/parameters.rs
@@ -29,7 +29,6 @@ pub(super) fn resolve_parameter_values(
let definition = graph
.parameters
.iter()
- .filter_map(|parameter| parameter.definition.as_ref())
.find(|definition| definition.parameter_id == value.parameter_id)
.ok_or_else(|| reject(ParameterRejection::Unknown))?;
let inner = value
@@ -43,11 +42,7 @@ pub(super) fn resolve_parameter_values(
graph
.parameters
.iter()
- .map(|parameter| {
- let definition = parameter
- .definition
- .as_ref()
- .ok_or_else(|| configuration("Application parameter definition is missing"))?;
+ .map(|definition| {
let value = match supplied
.iter()
.find(|value| value.parameter_id == definition.parameter_id)
diff --git a/backend/core/src/application/controller/test_support.rs b/backend/core/src/application/controller/test_support.rs
index e2c76ba4..9d13dab7 100644
--- a/backend/core/src/application/controller/test_support.rs
+++ b/backend/core/src/application/controller/test_support.rs
@@ -2948,40 +2948,36 @@ pub(super) fn application_with_route_input() -> Application {
/// an environment variable of its web service.
pub(super) fn application_with_parameters() -> Application {
use dotcore_proto::dotcore::catalog::v1::{
- ApplicationParameter, ParameterDefinition, StringEnumParameterSchema,
+ ParameterDefinition, ParameterVisibility, StringEnumParameterSchema,
UnsignedIntegerParameterSchema, parameter_definition::ParameterType,
};
let mut application = application();
let graph = application.graph.as_mut().expect("graph");
graph.parameters = vec![
- ApplicationParameter {
- definition: Some(ParameterDefinition {
- parameter_id: "mode".to_owned(),
- display_name: "Mode".to_owned(),
- description: String::new(),
- parameter_type: Some(ParameterType::StringEnum(StringEnumParameterSchema {
- values: vec!["auto".to_owned(), "highvram".to_owned()],
- default_value: Some("auto".to_owned()),
- })),
- }),
- advanced: true,
+ ParameterDefinition {
+ parameter_id: "mode".to_owned(),
+ display_name: "Mode".to_owned(),
+ description: String::new(),
+ parameter_type: Some(ParameterType::StringEnum(StringEnumParameterSchema {
+ values: vec!["auto".to_owned(), "highvram".to_owned()],
+ default_value: Some("auto".to_owned()),
+ })),
+ visibility: ParameterVisibility::Advanced as i32,
},
- ApplicationParameter {
- definition: Some(ParameterDefinition {
- parameter_id: "workers".to_owned(),
- display_name: "Workers".to_owned(),
- description: String::new(),
- parameter_type: Some(ParameterType::UnsignedInteger(
- UnsignedIntegerParameterSchema {
- minimum: 1,
- maximum: 8,
- unit: String::new(),
- default_value: Some(2),
- },
- )),
- }),
- advanced: false,
+ ParameterDefinition {
+ parameter_id: "workers".to_owned(),
+ display_name: "Workers".to_owned(),
+ description: String::new(),
+ parameter_type: Some(ParameterType::UnsignedInteger(
+ UnsignedIntegerParameterSchema {
+ minimum: 1,
+ maximum: 8,
+ unit: String::new(),
+ default_value: Some(2),
+ },
+ )),
+ visibility: ParameterVisibility::Basic as i32,
},
];
graph.services[0].configuration_bindings = ["mode", "workers"]
diff --git a/backend/core/src/application/repository.rs b/backend/core/src/application/repository.rs
index 09818443..8acb5d9e 100644
--- a/backend/core/src/application/repository.rs
+++ b/backend/core/src/application/repository.rs
@@ -1052,10 +1052,10 @@ mod tests {
},
catalog::v1::{
Application, ApplicationConfigurationBinding, ApplicationEndpointProtocol,
- ApplicationGraph, ApplicationParameter, ApplicationPrimaryEndpoint, ApplicationService,
- ApplicationServicePort, ParameterDefinition, ParameterValue, StringEnumParameterSchema,
- application_configuration_binding, parameter_definition::ParameterType,
- parameter_value,
+ ApplicationGraph, ApplicationPrimaryEndpoint, ApplicationService,
+ ApplicationServicePort, ParameterDefinition, ParameterValue, ParameterVisibility,
+ StringEnumParameterSchema, application_configuration_binding,
+ parameter_definition::ParameterType, parameter_value,
},
deployment::v1::{ArtifactPreparationPolicy, NodeResolvedLaunchIdentity},
};
@@ -1234,17 +1234,15 @@ mod tests {
let mut deployment = valid_deployment();
let snapshot = deployment.snapshot.as_mut().expect("snapshot");
let graph = snapshot.graph.as_mut().expect("graph");
- graph.parameters = vec![ApplicationParameter {
- definition: Some(ParameterDefinition {
- parameter_id: "mode".to_owned(),
- display_name: "Mode".to_owned(),
- description: String::new(),
- parameter_type: Some(ParameterType::StringEnum(StringEnumParameterSchema {
- values: vec!["auto".to_owned(), "highvram".to_owned()],
- default_value: Some("auto".to_owned()),
- })),
- }),
- advanced: true,
+ graph.parameters = vec![ParameterDefinition {
+ parameter_id: "mode".to_owned(),
+ display_name: "Mode".to_owned(),
+ description: String::new(),
+ parameter_type: Some(ParameterType::StringEnum(StringEnumParameterSchema {
+ values: vec!["auto".to_owned(), "highvram".to_owned()],
+ default_value: Some("auto".to_owned()),
+ })),
+ visibility: ParameterVisibility::Advanced as i32,
}];
graph.services[0].configuration_bindings = vec![ApplicationConfigurationBinding {
binding_name: "mode".to_owned(),
diff --git a/backend/core/src/development/mod.rs b/backend/core/src/development/mod.rs
index 4a41c1d6..0d8c1659 100644
--- a/backend/core/src/development/mod.rs
+++ b/backend/core/src/development/mod.rs
@@ -36,7 +36,7 @@ use dotcore_domain::{
};
use dotcore_node_hardware::hardware::SystemHardwareSnapshotCollector;
use dotcore_proto::dotcore::{
- catalog::v1::{EngineParameterValues, engine_artifact::Kind as EngineArtifactKind},
+ catalog::v1::{ParameterValues, engine_artifact::Kind as EngineArtifactKind},
engine::v1::EngineKind,
model::v1::{
LocalModelDeploymentConfiguration, PortableModelLaunchPlan,
@@ -1386,7 +1386,7 @@ pub struct DevelopmentLaunchFacts {
pub model_artifact_id: String,
pub auxiliary_artifact_ids: Vec,
pub resolved_options: Vec,
- pub engine_parameter_values: EngineParameterValues,
+ pub engine_parameter_values: ParameterValues,
pub engine_release_id: String,
pub engine_artifact_id: String,
pub engine_version: String,
diff --git a/backend/core/src/model/engine/declaration.rs b/backend/core/src/model/engine/declaration.rs
index d3663081..10d53e60 100644
--- a/backend/core/src/model/engine/declaration.rs
+++ b/backend/core/src/model/engine/declaration.rs
@@ -6,8 +6,8 @@ use dotcore_catalog::{
};
use dotcore_domain::Accelerator;
use dotcore_proto::dotcore::catalog::v1::{
- EngineParameterValues, EngineRelease, HardwareClass, InferenceRequestKind, Model,
- ModelEngineConfiguration, model_option,
+ EngineRelease, HardwareClass, InferenceRequestKind, Model, ModelEngineConfiguration,
+ ParameterValues, model_option,
};
use super::definition::{definition, unsigned_parameter_maximum};
@@ -67,7 +67,7 @@ impl EngineDeclarationValidator for EngineFacade {
model: &Model,
configuration: &ModelEngineConfiguration,
engine: &EngineRelease,
- ) -> EngineParameterValues {
+ ) -> ParameterValues {
definition(engine.engine_kind)
.map(|definition| definition.parameter_defaults(model, configuration))
.unwrap_or_default()
@@ -185,9 +185,9 @@ mod tests {
};
use dotcore_proto::dotcore::{
catalog::v1::{
- EngineParameterDefinition, EngineParameterSchema, EngineRelease, InferenceRequestKind,
- Model, ModelEngineConfiguration, UnsignedIntegerEngineParameterSchema,
- engine_parameter_definition::ParameterType,
+ EngineParameterSchema, EngineRelease, InferenceRequestKind, Model,
+ ModelEngineConfiguration, ParameterDefinition, UnsignedIntegerParameterSchema,
+ parameter_definition::ParameterType,
},
engine::v1::{EngineKind, EngineModelConfiguration},
};
@@ -202,10 +202,10 @@ mod tests {
};
let release = EngineRelease {
parameter_schema: Some(EngineParameterSchema {
- parameters: vec![EngineParameterDefinition {
+ parameters: vec![ParameterDefinition {
parameter_id: CONTEXT_PER_REQUEST_PARAMETER_ID.to_owned(),
parameter_type: Some(ParameterType::UnsignedInteger(
- UnsignedIntegerEngineParameterSchema {
+ UnsignedIntegerParameterSchema {
minimum: 1024,
maximum: 131_072,
unit: "tokens".to_owned(),
diff --git a/backend/core/src/model/engine/definition.rs b/backend/core/src/model/engine/definition.rs
index 823f4028..17416e75 100644
--- a/backend/core/src/model/engine/definition.rs
+++ b/backend/core/src/model/engine/definition.rs
@@ -13,9 +13,9 @@ use dotcore_catalog::shared_parameters;
use dotcore_domain::WorkloadArtifactKind;
use dotcore_proto::dotcore::{
catalog::v1::{
- EngineParameterSchema, EngineParameterValue, EngineParameterValues, EngineRelease,
- HardwareClass, InferenceRequestKind, Model, ModelArtifactBinding, ModelEngineConfiguration,
- ModelOptionValues, engine_parameter_definition, engine_parameter_value,
+ EngineParameterSchema, EngineRelease, HardwareClass, InferenceRequestKind, Model,
+ ModelArtifactBinding, ModelEngineConfiguration, ModelOptionValues, ParameterValue,
+ ParameterValues, parameter_definition, parameter_value,
},
engine::v1::{EngineKind, ResolvedEngineModelState},
model::v1::{PortableModelLaunchPlan, ResolvedLocalModelDeploymentSnapshot},
@@ -74,8 +74,8 @@ pub(crate) trait EngineDefinition: Send + Sync {
&self,
_model: &Model,
_configuration: &ModelEngineConfiguration,
- ) -> EngineParameterValues {
- EngineParameterValues::default()
+ ) -> ParameterValues {
+ ParameterValues::default()
}
/// Context per request for values that leave it out, which counts as derived. By default it is
@@ -84,9 +84,9 @@ pub(crate) trait EngineDefinition: Send + Sync {
fn filled_context_per_request(
&self,
schema: &EngineParameterSchema,
- values: &EngineParameterValues,
+ values: &ParameterValues,
maximum_context_tokens: u32,
- ) -> Option {
+ ) -> Option {
shared_parameters::filled_context_per_request(schema, values, maximum_context_tokens)
}
@@ -97,7 +97,7 @@ pub(crate) trait EngineDefinition: Send + Sync {
&self,
_model: &Model,
_configuration: &ModelEngineConfiguration,
- _values: &EngineParameterValues,
+ _values: &ParameterValues,
) -> anyhow::Result<()> {
Ok(())
}
@@ -195,7 +195,7 @@ pub(crate) fn definition(kind: i32) -> Option<&'static dyn EngineDefinition> {
/// Returns the unsigned value of one engine parameter, or none when it is absent or not unsigned.
pub(crate) fn unsigned_parameter_value(
- values: &EngineParameterValues,
+ values: &ParameterValues,
parameter_id: &str,
) -> Option {
values
@@ -203,7 +203,7 @@ pub(crate) fn unsigned_parameter_value(
.iter()
.find(|value| value.parameter_id == parameter_id)
.and_then(|value| match value.value {
- Some(engine_parameter_value::Value::UnsignedInteger(value)) => Some(value),
+ Some(parameter_value::Value::UnsignedInteger(value)) => Some(value),
_ => None,
})
}
@@ -229,7 +229,7 @@ pub(crate) fn unsigned_parameter_range(
.iter()
.find(|definition| definition.parameter_id == parameter_id)
.and_then(|definition| match definition.parameter_type.as_ref() {
- Some(engine_parameter_definition::ParameterType::UnsignedInteger(range)) => {
+ Some(parameter_definition::ParameterType::UnsignedInteger(range)) => {
Some(range.minimum..=range.maximum)
}
_ => None,
diff --git a/backend/core/src/model/engine/llama_cpp/declaration.rs b/backend/core/src/model/engine/llama_cpp/declaration.rs
index 8cfae5a8..5c3e1a87 100644
--- a/backend/core/src/model/engine/llama_cpp/declaration.rs
+++ b/backend/core/src/model/engine/llama_cpp/declaration.rs
@@ -12,13 +12,12 @@ use dotcore_catalog::{
use dotcore_domain::Accelerator;
use dotcore_proto::dotcore::{
catalog::v1::{
- EngineParameterDefinition, EngineParameterValue, EngineParameterValues, EngineRelease,
- HardwareClass, Model, ModelConfigurationOption, ModelEngineConfiguration,
- ModelInputModality, SpeculativeDecodingMethod, engine_parameter_definition::ParameterType,
- engine_parameter_value::Value as EngineParameterValueKind,
- hugging_face_artifact_source::Content as HuggingFaceContent,
+ EngineRelease, HardwareClass, Model, ModelConfigurationOption, ModelEngineConfiguration,
+ ModelInputModality, ParameterDefinition, ParameterValue, ParameterValues,
+ SpeculativeDecodingMethod, hugging_face_artifact_source::Content as HuggingFaceContent,
model_option::Kind as ModelOptionKind,
model_option_parameter_definition::ParameterType as ModelOptionParameterType,
+ parameter_definition::ParameterType, parameter_value::Value as EngineParameterValueKind,
},
engine::{
llama_cpp::v1::{
@@ -130,12 +129,12 @@ pub(crate) fn validate_hardware_support(
pub(crate) fn parameter_defaults(
model: &Model,
configuration: &ModelEngineConfiguration,
-) -> EngineParameterValues {
+) -> ParameterValues {
if !uses_single_micro_batch(configuration) {
- return EngineParameterValues::default();
+ return ParameterValues::default();
}
- EngineParameterValues {
- values: vec![EngineParameterValue {
+ ParameterValues {
+ values: vec![ParameterValue {
parameter_id: TOTAL_CONTEXT_PARAMETER_ID.to_owned(),
value: Some(EngineParameterValueKind::UnsignedInteger(u64::from(
maximum_context_tokens(model, configuration),
@@ -176,7 +175,7 @@ pub(crate) fn validate_total_context_default(
pub(crate) fn validate_parameter_values(
model: &Model,
configuration: &ModelEngineConfiguration,
- values: &EngineParameterValues,
+ values: &ParameterValues,
) -> anyhow::Result<()> {
if !uses_single_micro_batch(configuration) {
return Ok(());
@@ -661,7 +660,7 @@ pub(crate) enum LlamaCppSamplingError {
/// Accepts a shared parameter bounded by llama.cpp's own limit. The bound on the total context is
/// high enough that a large pool exceeds memory before it exceeds the bound.
-fn is_bounded(definition: &EngineParameterDefinition, parameter_id: &str, maximum: u64) -> bool {
+fn is_bounded(definition: &ParameterDefinition, parameter_id: &str, maximum: u64) -> bool {
definition.parameter_id == parameter_id
&& matches!(
definition.parameter_type.as_ref(),
@@ -669,7 +668,7 @@ fn is_bounded(definition: &EngineParameterDefinition, parameter_id: &str, maximu
)
}
-fn is_prompt_cache_memory(definition: &EngineParameterDefinition) -> bool {
+fn is_prompt_cache_memory(definition: &ParameterDefinition) -> bool {
definition.parameter_id == PROMPT_CACHE_MEMORY_PARAMETER_ID
&& definition.display_name == "Prompt cache memory"
&& matches!(
@@ -686,12 +685,12 @@ mod tests {
use dotcore_catalog::{Catalog, OciArtifactPolicy, test_support::vendor_hardware_class};
use dotcore_proto::dotcore::{
catalog::v1::{
- EngineParameterDefinition, HuggingFaceArtifactSource, HuggingFaceFileArtifact,
- HuggingFaceSnapshotArtifact, HuggingFaceSnapshotFile, Model, ModelArtifact,
- ModelArtifactBinding, ModelAuxiliaryArtifact, ModelConfigurationOption,
- ModelEngineConfiguration, SpeculativeDecodingMethod, SpeculativeDecodingModelOption,
- VisionModelOption, engine_parameter_definition::ParameterType,
- hugging_face_artifact_source::Content as HuggingFaceContent, model_option,
+ HuggingFaceArtifactSource, HuggingFaceFileArtifact, HuggingFaceSnapshotArtifact,
+ HuggingFaceSnapshotFile, Model, ModelArtifact, ModelArtifactBinding,
+ ModelAuxiliaryArtifact, ModelConfigurationOption, ModelEngineConfiguration,
+ ParameterDefinition, SpeculativeDecodingMethod, SpeculativeDecodingModelOption,
+ VisionModelOption, hugging_face_artifact_source::Content as HuggingFaceContent,
+ model_option, parameter_definition::ParameterType,
},
engine::{
llama_cpp::v1::{
@@ -837,7 +836,7 @@ mod tests {
.find(|release| release.engine_kind == EngineKind::LlamaCpp as i32)
.expect("llama.cpp release");
assert!(validate_engine_release(&release).is_empty());
- let rejection = |edit: &dyn Fn(&mut Vec)| {
+ let rejection = |edit: &dyn Fn(&mut Vec)| {
let mut release = release.clone();
edit(
&mut release
@@ -851,7 +850,7 @@ mod tests {
.map(ToString::to_string)
.collect::>()
};
- let maximum = |parameters: &mut Vec, index: usize, value| {
+ let maximum = |parameters: &mut Vec, index: usize, value| {
if let Some(ParameterType::UnsignedInteger(range)) =
parameters[index].parameter_type.as_mut()
{
@@ -1252,9 +1251,7 @@ mod tests {
#[test]
fn a_single_micro_batch_maximum_context_must_be_a_valid_total_context() {
use dotcore_proto::dotcore::{
- catalog::v1::{
- EngineParameterSchema, EngineRelease, UnsignedIntegerEngineParameterSchema,
- },
+ catalog::v1::{EngineParameterSchema, EngineRelease, UnsignedIntegerParameterSchema},
engine::{
llama_cpp::v1::{LlamaCppFixedModelSettings, LlamaCppModelConfiguration},
v1::EngineModelConfiguration,
@@ -1267,10 +1264,10 @@ mod tests {
};
let engine = EngineRelease {
parameter_schema: Some(EngineParameterSchema {
- parameters: vec![EngineParameterDefinition {
+ parameters: vec![ParameterDefinition {
parameter_id: "total-context".to_owned(),
parameter_type: Some(ParameterType::UnsignedInteger(
- UnsignedIntegerEngineParameterSchema {
+ UnsignedIntegerParameterSchema {
minimum: 1024,
maximum: 8192,
..Default::default()
diff --git a/backend/core/src/model/engine/llama_cpp/mod.rs b/backend/core/src/model/engine/llama_cpp/mod.rs
index b8d1272f..ae414be6 100644
--- a/backend/core/src/model/engine/llama_cpp/mod.rs
+++ b/backend/core/src/model/engine/llama_cpp/mod.rs
@@ -17,8 +17,8 @@ use dotcore_artifact::PreparedModelArtifacts;
use dotcore_domain::WorkloadArtifactKind;
use dotcore_proto::dotcore::{
catalog::v1::{
- EngineParameterValues, EngineRelease, HardwareClass, InferenceRequestKind, Model,
- ModelArtifactBinding, ModelEngineConfiguration, ModelOptionValues,
+ EngineRelease, HardwareClass, InferenceRequestKind, Model, ModelArtifactBinding,
+ ModelEngineConfiguration, ModelOptionValues, ParameterValues,
},
engine::v1::{EngineKind, ResolvedEngineModelState},
model::v1::{PortableModelLaunchPlan, ResolvedLocalModelDeploymentSnapshot},
@@ -82,7 +82,7 @@ impl EngineDefinition for LlamaCpp {
&self,
model: &Model,
configuration: &ModelEngineConfiguration,
- ) -> EngineParameterValues {
+ ) -> ParameterValues {
declaration::parameter_defaults(model, configuration)
}
@@ -90,7 +90,7 @@ impl EngineDefinition for LlamaCpp {
&self,
model: &Model,
configuration: &ModelEngineConfiguration,
- values: &EngineParameterValues,
+ values: &ParameterValues,
) -> anyhow::Result<()> {
declaration::validate_parameter_values(model, configuration, values)
}
diff --git a/backend/core/src/model/engine/llama_cpp/plan.rs b/backend/core/src/model/engine/llama_cpp/plan.rs
index 213137f1..9bcbc7c6 100644
--- a/backend/core/src/model/engine/llama_cpp/plan.rs
+++ b/backend/core/src/model/engine/llama_cpp/plan.rs
@@ -14,7 +14,7 @@ use dotcore_catalog::{
};
use dotcore_proto::{
dotcore::{
- catalog::v1::{ModelOptionValues, engine_parameter_value::Value as EngineParameterValue},
+ catalog::v1::{ModelOptionValues, parameter_value::Value as EngineParameterValueKind},
engine::{
llama_cpp::v1::{
LlamaCppFixedModelSettings, LlamaCppResolvedModelState, LlamaCppSpeculativeMethod,
@@ -431,7 +431,7 @@ fn unsigned_parameter(
.find(|value| value.parameter_id == parameter_id)
})
.and_then(|value| match value.value.as_ref() {
- Some(EngineParameterValue::UnsignedInteger(value)) => Some(*value),
+ Some(EngineParameterValueKind::UnsignedInteger(value)) => Some(*value),
_ => None,
})
}
@@ -628,7 +628,7 @@ pub(crate) enum LlamaCppPlanError {
#[cfg(test)]
mod tests {
use dotcore_proto::dotcore::catalog::v1::{
- EngineParameterDefinition, EngineParameterValue as EngineParameterValueMessage,
+ ParameterDefinition, ParameterValue as EngineParameterValueMessage, ParameterVisibility,
};
use super::*;
@@ -910,7 +910,7 @@ mod tests {
.retain(|value| value.parameter_id != PROMPT_CACHE_MEMORY_PARAMETER_ID);
values.values.push(EngineParameterValueMessage {
parameter_id: PROMPT_CACHE_MEMORY_PARAMETER_ID.to_owned(),
- value: Some(EngineParameterValue::UnsignedInteger(mib)),
+ value: Some(EngineParameterValueKind::UnsignedInteger(mib)),
});
assert_eq!(
@@ -951,11 +951,11 @@ mod tests {
.as_mut()
.expect("engine parameter schema")
.parameters
- .push(EngineParameterDefinition {
+ .push(ParameterDefinition {
parameter_id: PROMPT_CACHE_MEMORY_PARAMETER_ID.to_owned(),
display_name: "Prompt cache memory".to_owned(),
parameter_type: None,
- exposed: true,
+ visibility: ParameterVisibility::Advanced as i32,
description: String::new(),
});
for token in tokens {
@@ -1753,7 +1753,7 @@ mod tests {
.retain(|value| value.parameter_id != parameter_id);
values.values.push(EngineParameterValueMessage {
parameter_id: parameter_id.to_owned(),
- value: Some(EngineParameterValue::UnsignedInteger(value)),
+ value: Some(EngineParameterValueKind::UnsignedInteger(value)),
});
}
diff --git a/backend/core/src/model/engine/mod.rs b/backend/core/src/model/engine/mod.rs
index c7cac54a..a13a6143 100644
--- a/backend/core/src/model/engine/mod.rs
+++ b/backend/core/src/model/engine/mod.rs
@@ -54,9 +54,9 @@ pub(crate) fn engine_artifact_kinds(kind: i32) -> &'static [dotcore_domain::Work
pub(crate) fn filled_context_per_request(
kind: i32,
schema: &dotcore_proto::dotcore::catalog::v1::EngineParameterSchema,
- values: &dotcore_proto::dotcore::catalog::v1::EngineParameterValues,
+ values: &dotcore_proto::dotcore::catalog::v1::ParameterValues,
maximum_context_tokens: u32,
-) -> Option {
+) -> Option {
match definition::definition(kind) {
Some(engine) => engine.filled_context_per_request(schema, values, maximum_context_tokens),
None => dotcore_catalog::shared_parameters::filled_context_per_request(
@@ -74,7 +74,7 @@ pub(crate) fn validate_parameter_values(
kind: i32,
model: &dotcore_proto::dotcore::catalog::v1::Model,
configuration: &dotcore_proto::dotcore::catalog::v1::ModelEngineConfiguration,
- values: &dotcore_proto::dotcore::catalog::v1::EngineParameterValues,
+ values: &dotcore_proto::dotcore::catalog::v1::ParameterValues,
) -> anyhow::Result<()> {
validate_context_within_total(values)?;
definition::definition(kind).map_or(Ok(()), |engine| {
@@ -85,7 +85,7 @@ pub(crate) fn validate_parameter_values(
/// Rejects a context per request above the total context every request shares, since no request
/// could use it.
fn validate_context_within_total(
- values: &dotcore_proto::dotcore::catalog::v1::EngineParameterValues,
+ values: &dotcore_proto::dotcore::catalog::v1::ParameterValues,
) -> anyhow::Result<()> {
use dotcore_catalog::shared_parameters::{
CONTEXT_PER_REQUEST_PARAMETER_ID, TOTAL_CONTEXT_PARAMETER_ID,
diff --git a/backend/core/src/model/engine/ninfer/declaration.rs b/backend/core/src/model/engine/ninfer/declaration.rs
index 797e4923..bb6e3f63 100644
--- a/backend/core/src/model/engine/ninfer/declaration.rs
+++ b/backend/core/src/model/engine/ninfer/declaration.rs
@@ -19,12 +19,12 @@ use dotcore_catalog::{
use dotcore_domain::Accelerator;
use dotcore_proto::dotcore::{
catalog::v1::{
- EngineParameterValues, EngineRelease, HardwareClass, Model, ModelConfigurationOption,
- ModelEngineConfiguration, ModelInputModality, ModelParallelShape,
- SpeculativeDecodingMethod, engine_parameter_definition::ParameterType,
+ EngineRelease, HardwareClass, Model, ModelConfigurationOption, ModelEngineConfiguration,
+ ModelInputModality, ModelParallelShape, ParameterValues, SpeculativeDecodingMethod,
hugging_face_artifact_source::Content as HuggingFaceContent,
model_option::Kind as ModelOptionKind,
model_option_parameter_definition::ParameterType as ModelOptionParameterType,
+ parameter_definition::ParameterType,
},
engine::{
ninfer::v1::{
@@ -52,7 +52,7 @@ const MODEL_FILE_EXTENSION: &str = ".ninfer";
/// Rejects a total context ninfer refuses at startup: above what the parallel requests use at
/// their full context per request, so part of it could never hold a request.
-pub(crate) fn validate_parameter_values(values: &EngineParameterValues) -> anyhow::Result<()> {
+pub(crate) fn validate_parameter_values(values: &ParameterValues) -> anyhow::Result<()> {
let (Some(total_context), Some(context_per_request), Some(parallel_requests)) = (
unsigned_parameter_value(values, TOTAL_CONTEXT_PARAMETER_ID),
unsigned_parameter_value(values, CONTEXT_PER_REQUEST_PARAMETER_ID),
@@ -460,9 +460,9 @@ mod tests {
use dotcore_domain::ExecutionPlatform;
use dotcore_proto::dotcore::{
catalog::v1::{
- BooleanEngineParameterSchema, EngineParameterDefinition, HuggingFaceArtifactSource,
- HuggingFaceFileArtifact, HuggingFaceSnapshotArtifact, SpeculativeDecodingModelOption,
- UnsignedIntegerEngineParameterSchema,
+ BooleanParameterSchema, HuggingFaceArtifactSource, HuggingFaceFileArtifact,
+ HuggingFaceSnapshotArtifact, ParameterDefinition, SpeculativeDecodingModelOption,
+ UnsignedIntegerParameterSchema,
},
model::v1::{
LocalModelDeploymentConfiguration, ResolvedLocalModelDeploymentSnapshot,
@@ -506,7 +506,7 @@ mod tests {
.as_mut()
.expect("schema")
.parameters[3]
- .parameter_type = Some(ParameterType::Boolean(BooleanEngineParameterSchema {}));
+ .parameter_type = Some(ParameterType::Boolean(BooleanParameterSchema::default()));
assert_eq!(
messages(validate_engine_release(&boolean)),
["ninfer engine release requires non-empty unsigned-integer parameter definitions"]
@@ -578,7 +578,7 @@ mod tests {
.as_mut()
.expect("schema")
.parameters
- .push(EngineParameterDefinition {
+ .push(ParameterDefinition {
parameter_id: "prefill-chunk".to_owned(),
parameter_type: Some(unsigned(128, 8192, "tokens")),
..Default::default()
@@ -996,10 +996,10 @@ version: "1"
parameterSchema:
schemaId: "fixture-ninfer-v2"
parameters:
- - { parameterId: "total-context", displayName: "Total context", exposed: true, description: "total-context", unsignedInteger: { minimum: "1024", maximum: "2097152", unit: "tokens", defaultValue: "32768" } }
- - { parameterId: "context-per-request", displayName: "Context per request", exposed: true, description: "context-per-request", unsignedInteger: { minimum: "1024", maximum: "262144", unit: "tokens" } }
- - { parameterId: "parallel-requests", displayName: "Parallel requests", exposed: true, description: "parallel-requests", unsignedInteger: { minimum: "1", maximum: "8", unit: "requests", defaultValue: "4" } }
- - { parameterId: "host-context-mib", displayName: "Host context memory", exposed: true, description: "host-context-mib", unsignedInteger: { minimum: "0", maximum: "1048576", unit: "MiB" } }
+ - { parameterId: "total-context", displayName: "Total context", visibility: "PARAMETER_VISIBILITY_BASIC", description: "total-context", unsignedInteger: { minimum: "1024", maximum: "2097152", unit: "tokens", defaultValue: "32768" } }
+ - { parameterId: "context-per-request", displayName: "Context per request", visibility: "PARAMETER_VISIBILITY_BASIC", description: "context-per-request", unsignedInteger: { minimum: "1024", maximum: "262144", unit: "tokens" } }
+ - { parameterId: "parallel-requests", displayName: "Parallel requests", visibility: "PARAMETER_VISIBILITY_ADVANCED", description: "parallel-requests", unsignedInteger: { minimum: "1", maximum: "8", unit: "requests", defaultValue: "4" } }
+ - { parameterId: "host-context-mib", displayName: "Host context memory", visibility: "PARAMETER_VISIBILITY_ADVANCED", description: "host-context-mib", unsignedInteger: { minimum: "0", maximum: "1048576", unit: "MiB" } }
artifacts:
- artifactId: "linux-amd64-cuda"
oci:
@@ -1192,7 +1192,7 @@ profiles:
}
fn unsigned(minimum: u64, maximum: u64, unit: &str) -> ParameterType {
- ParameterType::UnsignedInteger(UnsignedIntegerEngineParameterSchema {
+ ParameterType::UnsignedInteger(UnsignedIntegerParameterSchema {
minimum,
maximum,
unit: unit.to_owned(),
diff --git a/backend/core/src/model/engine/ninfer/mod.rs b/backend/core/src/model/engine/ninfer/mod.rs
index 0011bf23..4bdaa16d 100644
--- a/backend/core/src/model/engine/ninfer/mod.rs
+++ b/backend/core/src/model/engine/ninfer/mod.rs
@@ -16,8 +16,8 @@ use dotcore_artifact::PreparedModelArtifacts;
use dotcore_domain::WorkloadArtifactKind;
use dotcore_proto::dotcore::{
catalog::v1::{
- EngineParameterValues, EngineRelease, HardwareClass, InferenceRequestKind, Model,
- ModelArtifactBinding, ModelEngineConfiguration, ModelOptionValues,
+ EngineRelease, HardwareClass, InferenceRequestKind, Model, ModelArtifactBinding,
+ ModelEngineConfiguration, ModelOptionValues, ParameterValues,
},
engine::v1::{EngineKind, ResolvedEngineModelState},
model::v1::{PortableModelLaunchPlan, ResolvedLocalModelDeploymentSnapshot},
@@ -78,7 +78,7 @@ impl EngineDefinition for Ninfer {
&self,
_model: &Model,
_configuration: &ModelEngineConfiguration,
- values: &EngineParameterValues,
+ values: &ParameterValues,
) -> anyhow::Result<()> {
declaration::validate_parameter_values(values)
}
diff --git a/backend/core/src/model/engine/ninfer/plan.rs b/backend/core/src/model/engine/ninfer/plan.rs
index bd8f2064..9ddb9d36 100644
--- a/backend/core/src/model/engine/ninfer/plan.rs
+++ b/backend/core/src/model/engine/ninfer/plan.rs
@@ -16,7 +16,7 @@ use dotcore_catalog::{
},
};
use dotcore_proto::dotcore::{
- catalog::v1::{ModelOptionValues, engine_parameter_value::Value as EngineParameterValue},
+ catalog::v1::{ModelOptionValues, parameter_value::Value as EngineParameterValueKind},
engine::{
ninfer::v1::{
NinferFixedModelSettings, NinferKvCacheDataType, NinferResolvedModelState,
@@ -367,7 +367,7 @@ fn parameter(
.find(|value| value.parameter_id == parameter_id)
})
.and_then(|value| match value.value.as_ref() {
- Some(EngineParameterValue::UnsignedInteger(value)) => Some(*value),
+ Some(EngineParameterValueKind::UnsignedInteger(value)) => Some(*value),
_ => None,
})
.ok_or_else(|| NinferPlanError::ParameterNotConfigured(parameter_id.to_owned()))
@@ -711,7 +711,7 @@ mod tests {
.iter_mut()
.find(|value| value.parameter_id == parameter_id)
.expect("parameter")
- .value = Some(EngineParameterValue::UnsignedInteger(0));
+ .value = Some(EngineParameterValueKind::UnsignedInteger(0));
assert_eq!(
translate(&local).expect_err("zero parameter").to_string(),
diff --git a/backend/core/src/model/engine/strata/declaration.rs b/backend/core/src/model/engine/strata/declaration.rs
index c338e096..497de4c1 100644
--- a/backend/core/src/model/engine/strata/declaration.rs
+++ b/backend/core/src/model/engine/strata/declaration.rs
@@ -16,12 +16,11 @@ use dotcore_catalog::shared_parameters::{
use dotcore_domain::Accelerator;
use dotcore_proto::dotcore::{
catalog::v1::{
- EngineParameterSchema, EngineParameterValue, EngineParameterValues, EngineRelease,
- HardwareClass, HuggingFaceArtifactSource, Model, ModelEngineConfiguration,
- ModelInputModality, ModelParallelShape, StorageScope,
- engine_parameter_definition::ParameterType,
- engine_parameter_value::Value as EngineParameterValueKind,
+ EngineParameterSchema, EngineRelease, HardwareClass, HuggingFaceArtifactSource, Model,
+ ModelEngineConfiguration, ModelInputModality, ModelParallelShape, ParameterValue,
+ ParameterValues, StorageScope,
hugging_face_artifact_source::Content as HuggingFaceContent,
+ parameter_definition::ParameterType, parameter_value::Value as EngineParameterValueKind,
},
engine::{
strata::v1::{
@@ -54,9 +53,9 @@ const READY_PACK_FILES: [&str; 2] = ["native_experts.txt", "tokenizer/vocab.json
/// for each of them. Returns none when the shared rule would fill nothing.
pub(crate) fn filled_context_per_request(
schema: &EngineParameterSchema,
- values: &EngineParameterValues,
+ values: &ParameterValues,
maximum_context_tokens: u32,
-) -> Option {
+) -> Option {
// The shared rule with no maximum yields the total context, or none when it fills nothing.
let total_context = shared_parameters::filled_context_per_request(schema, values, u32::MAX)
.and_then(|filled| match filled.value {
@@ -66,7 +65,7 @@ pub(crate) fn filled_context_per_request(
let parallel_requests = unsigned_parameter_value(values, PARALLEL_REQUESTS_PARAMETER_ID)
.unwrap_or(1)
.max(1);
- Some(EngineParameterValue {
+ Some(ParameterValue {
parameter_id: CONTEXT_PER_REQUEST_PARAMETER_ID.to_owned(),
value: Some(EngineParameterValueKind::UnsignedInteger(
(total_context / parallel_requests).min(u64::from(maximum_context_tokens)),
@@ -80,7 +79,7 @@ pub(crate) fn filled_context_per_request(
pub(crate) fn validate_parameter_values(
model: &Model,
configuration: &ModelEngineConfiguration,
- values: &EngineParameterValues,
+ values: &ParameterValues,
) -> anyhow::Result<()> {
let (Some(total_context), Some(context_per_request), Some(parallel_requests)) = (
unsigned_parameter_value(values, TOTAL_CONTEXT_PARAMETER_ID),
@@ -454,9 +453,9 @@ mod tests {
use dotcore_domain::ExecutionPlatform;
use dotcore_proto::dotcore::{
catalog::v1::{
- BooleanEngineParameterSchema, EngineParameterDefinition, HuggingFaceArtifactSource,
- HuggingFaceFileArtifact, ModelConfigurationOption, ModelImageInputCapacity,
- UnsignedIntegerEngineParameterSchema,
+ BooleanParameterSchema, HuggingFaceArtifactSource, HuggingFaceFileArtifact,
+ ModelConfigurationOption, ModelImageInputCapacity, ParameterDefinition,
+ UnsignedIntegerParameterSchema,
},
model::v1::{
LocalModelDeploymentConfiguration, ResolvedLocalModelDeploymentSnapshot,
@@ -514,7 +513,7 @@ mod tests {
let mut boolean = release();
parameter(&mut boolean, EXPERT_VRAM_MIB_PARAMETER_ID).parameter_type =
- Some(ParameterType::Boolean(BooleanEngineParameterSchema {}));
+ Some(ParameterType::Boolean(BooleanParameterSchema::default()));
assert_eq!(
messages(validate_engine_release(&boolean)),
["Strata engine release requires non-empty unsigned-integer parameter definitions"]
@@ -609,7 +608,7 @@ mod tests {
.as_mut()
.expect("schema")
.parameters
- .push(EngineParameterDefinition {
+ .push(ParameterDefinition {
parameter_id: "kv-bits".to_owned(),
parameter_type: Some(unsigned(4, 16, "bits")),
..Default::default()
@@ -1015,10 +1014,10 @@ version: "1"
parameterSchema:
schemaId: "fixture-strata-v1"
parameters:
- - { parameterId: "total-context", displayName: "Total context", exposed: true, description: "total-context", unsignedInteger: { minimum: "1024", maximum: "2097152", unit: "tokens", defaultValue: "32768" } }
- - { parameterId: "context-per-request", displayName: "Context per request", exposed: true, description: "context-per-request", unsignedInteger: { minimum: "1024", maximum: "262144", unit: "tokens" } }
- - { parameterId: "parallel-requests", displayName: "Parallel requests", exposed: true, description: "parallel-requests", unsignedInteger: { minimum: "1", maximum: "8", unit: "requests", defaultValue: "1" } }
- - { parameterId: "expert-vram-mib", displayName: "Expert accelerator memory", exposed: true, description: "expert-vram-mib", unsignedInteger: { minimum: "1", maximum: "1048576", unit: "MiB" } }
+ - { parameterId: "total-context", displayName: "Total context", visibility: "PARAMETER_VISIBILITY_BASIC", description: "total-context", unsignedInteger: { minimum: "1024", maximum: "2097152", unit: "tokens", defaultValue: "32768" } }
+ - { parameterId: "context-per-request", displayName: "Context per request", visibility: "PARAMETER_VISIBILITY_BASIC", description: "context-per-request", unsignedInteger: { minimum: "1024", maximum: "262144", unit: "tokens" } }
+ - { parameterId: "parallel-requests", displayName: "Parallel requests", visibility: "PARAMETER_VISIBILITY_ADVANCED", description: "parallel-requests", unsignedInteger: { minimum: "1", maximum: "8", unit: "requests", defaultValue: "1" } }
+ - { parameterId: "expert-vram-mib", displayName: "Expert accelerator memory", visibility: "PARAMETER_VISIBILITY_ADVANCED", description: "expert-vram-mib", unsignedInteger: { minimum: "1", maximum: "1048576", unit: "MiB" } }
storageBindings:
- { bindingName: "data", mountPath: "/data", kind: "STORAGE_KIND_CACHE", scope: "STORAGE_SCOPE_DEPLOYMENT" }
artifacts:
@@ -1184,7 +1183,7 @@ profiles:
fn parameter<'a>(
release: &'a mut EngineRelease,
parameter_id: &str,
- ) -> &'a mut EngineParameterDefinition {
+ ) -> &'a mut ParameterDefinition {
release
.parameter_schema
.as_mut()
@@ -1196,7 +1195,7 @@ profiles:
}
fn unsigned(minimum: u64, maximum: u64, unit: &str) -> ParameterType {
- ParameterType::UnsignedInteger(UnsignedIntegerEngineParameterSchema {
+ ParameterType::UnsignedInteger(UnsignedIntegerParameterSchema {
minimum,
maximum,
unit: unit.to_owned(),
diff --git a/backend/core/src/model/engine/strata/mod.rs b/backend/core/src/model/engine/strata/mod.rs
index 73c05eb5..662812ed 100644
--- a/backend/core/src/model/engine/strata/mod.rs
+++ b/backend/core/src/model/engine/strata/mod.rs
@@ -20,9 +20,9 @@ use dotcore_artifact::PreparedModelArtifacts;
use dotcore_domain::WorkloadArtifactKind;
use dotcore_proto::dotcore::{
catalog::v1::{
- EngineParameterSchema, EngineParameterValue, EngineParameterValues, EngineRelease,
- HardwareClass, InferenceRequestKind, Model, ModelArtifactBinding, ModelEngineConfiguration,
- ModelOptionValues,
+ EngineParameterSchema, EngineRelease, HardwareClass, InferenceRequestKind, Model,
+ ModelArtifactBinding, ModelEngineConfiguration, ModelOptionValues, ParameterValue,
+ ParameterValues,
},
engine::{
strata::v1::{StrataFixedModelSettings, strata_fixed_model_settings::ModelFiles},
@@ -91,9 +91,9 @@ impl EngineDefinition for Strata {
fn filled_context_per_request(
&self,
schema: &EngineParameterSchema,
- values: &EngineParameterValues,
+ values: &ParameterValues,
maximum_context_tokens: u32,
- ) -> Option {
+ ) -> Option {
declaration::filled_context_per_request(schema, values, maximum_context_tokens)
}
@@ -101,7 +101,7 @@ impl EngineDefinition for Strata {
&self,
model: &Model,
configuration: &ModelEngineConfiguration,
- values: &EngineParameterValues,
+ values: &ParameterValues,
) -> anyhow::Result<()> {
declaration::validate_parameter_values(model, configuration, values)
}
diff --git a/backend/core/src/model/engine/strata/plan.rs b/backend/core/src/model/engine/strata/plan.rs
index 5b811693..450b4479 100644
--- a/backend/core/src/model/engine/strata/plan.rs
+++ b/backend/core/src/model/engine/strata/plan.rs
@@ -12,7 +12,7 @@ use dotcore_catalog::shared_parameters::{
CONTEXT_PER_REQUEST_PARAMETER_ID, PARALLEL_REQUESTS_PARAMETER_ID,
};
use dotcore_proto::dotcore::{
- catalog::v1::engine_parameter_value::Value as EngineParameterValue,
+ catalog::v1::parameter_value::Value as EngineParameterValueKind,
engine::{
strata::v1::{StrataResolvedModelState, strata_fixed_model_settings::ModelFiles},
v1::{EngineKind, resolved_engine_model_state::State},
@@ -213,7 +213,7 @@ fn positive_parameter(
.find(|value| value.parameter_id == parameter_id)
})
.and_then(|value| match value.value.as_ref() {
- Some(EngineParameterValue::UnsignedInteger(value)) => Some(*value),
+ Some(EngineParameterValueKind::UnsignedInteger(value)) => Some(*value),
_ => None,
})
.filter(|value| *value > 0)
@@ -591,7 +591,7 @@ mod tests {
.iter_mut()
.find(|value| value.parameter_id == parameter_id)
.expect("parameter")
- .value = Some(EngineParameterValue::UnsignedInteger(0));
+ .value = Some(EngineParameterValueKind::UnsignedInteger(0));
let mut missing = local("qwen3-6-35b-a3b");
missing
.engine_parameter_values
diff --git a/backend/core/src/model/engine/vllm/declaration.rs b/backend/core/src/model/engine/vllm/declaration.rs
index 5c881920..84263805 100644
--- a/backend/core/src/model/engine/vllm/declaration.rs
+++ b/backend/core/src/model/engine/vllm/declaration.rs
@@ -17,10 +17,10 @@ use dotcore_proto::dotcore::{
catalog::v1::{
EngineRelease, HardwareClass, Model, ModelConfigurationOption, ModelEngineConfiguration,
ModelInputModality, ModelParallelShape, SpeculativeDecodingMethod,
- engine_parameter_definition::ParameterType,
hugging_face_artifact_source::Content as HuggingFaceContent,
model_option::Kind as ModelOptionKind,
model_option_parameter_definition::ParameterType as ModelOptionParameterType,
+ parameter_definition::ParameterType,
},
engine::{
v1::engine_model_configuration::Configuration,
@@ -62,7 +62,7 @@ pub(crate) fn validate_engine_release(engine: &EngineRelease) -> Vec= range.minimum && !range.unit.is_empty()
}
Some(ParameterType::Boolean(_)) => true,
- None => false,
+ Some(ParameterType::StringEnum(_)) | None => false,
}
})
});
@@ -431,10 +431,10 @@ mod tests {
use dotcore_proto::dotcore::{
catalog::v1::{
- BooleanEngineParameterSchema, EngineParameterDefinition, EngineParameterSchema,
- EngineRelease, HuggingFaceArtifactSource, Model, ModelArtifactBinding,
- ModelAuxiliaryArtifact, ModelExecutionCapabilities, SpeculativeDecodingModelOption,
- UnsignedIntegerEngineParameterSchema,
+ BooleanParameterSchema, EngineParameterSchema, EngineRelease,
+ HuggingFaceArtifactSource, Model, ModelArtifactBinding, ModelAuxiliaryArtifact,
+ ModelExecutionCapabilities, ParameterDefinition, SpeculativeDecodingModelOption,
+ UnsignedIntegerParameterSchema,
},
engine::v1::{
EngineKind, EngineModelConfiguration, engine_model_configuration::Configuration,
@@ -461,10 +461,10 @@ mod tests {
};
fn release(kind: EngineKind) -> EngineRelease {
- let unsigned = |parameter_id: &str, maximum: u64, unit: &str| EngineParameterDefinition {
+ let unsigned = |parameter_id: &str, maximum: u64, unit: &str| ParameterDefinition {
parameter_id: parameter_id.to_owned(),
parameter_type: Some(ParameterType::UnsignedInteger(
- UnsignedIntegerEngineParameterSchema {
+ UnsignedIntegerParameterSchema {
minimum: 1,
maximum,
unit: unit.to_owned(),
@@ -497,10 +497,10 @@ mod tests {
.as_mut()
.expect("schema")
.parameters
- .push(EngineParameterDefinition {
+ .push(ParameterDefinition {
parameter_id: "feature-enabled".to_owned(),
display_name: "Feature enabled".to_owned(),
- parameter_type: Some(ParameterType::Boolean(BooleanEngineParameterSchema {})),
+ parameter_type: Some(ParameterType::Boolean(BooleanParameterSchema::default())),
..Default::default()
});
assert!(validate_engine_release(&with_boolean).is_empty());
@@ -1041,13 +1041,13 @@ version: "1"
parameterSchema:
schemaId: "fixture-vllm-v2"
parameters:
- - { parameterId: "total-context", displayName: "Total context", exposed: true, unsignedInteger: { minimum: "1024", maximum: "16777216", unit: "tokens", defaultValue: "32768" } }
- - { parameterId: "context-per-request", displayName: "Context per request", exposed: true, unsignedInteger: { minimum: "1024", maximum: "1048576", unit: "tokens" } }
- - { parameterId: "parallel-requests", displayName: "Parallel requests", exposed: true, unsignedInteger: { minimum: "1", maximum: "1024", unit: "requests", defaultValue: "4" } }
- - { parameterId: "maximum-batched-tokens", displayName: "Maximum batched tokens", unsignedInteger: { minimum: "1", maximum: "1048576", unit: "tokens" } }
- - { parameterId: "maximum-cudagraph-capture-size", displayName: "Maximum CUDA graph capture size", unsignedInteger: { minimum: "1", maximum: "1024", unit: "sequences" } }
- - { parameterId: "kv-cache-memory-bytes", displayName: "KV cache memory", unsignedInteger: { minimum: "1", maximum: "1099511627776", unit: "bytes" } }
- - { parameterId: "kv-offloading-size-gib", displayName: "KV offloading size", exposed: true, unsignedInteger: { minimum: "0", maximum: "1024", unit: "GiB" } }
+ - { parameterId: "total-context", displayName: "Total context", visibility: "PARAMETER_VISIBILITY_BASIC", unsignedInteger: { minimum: "1024", maximum: "16777216", unit: "tokens", defaultValue: "32768" } }
+ - { parameterId: "context-per-request", displayName: "Context per request", visibility: "PARAMETER_VISIBILITY_BASIC", unsignedInteger: { minimum: "1024", maximum: "1048576", unit: "tokens" } }
+ - { parameterId: "parallel-requests", displayName: "Parallel requests", visibility: "PARAMETER_VISIBILITY_ADVANCED", unsignedInteger: { minimum: "1", maximum: "1024", unit: "requests", defaultValue: "4" } }
+ - { parameterId: "maximum-batched-tokens", displayName: "Maximum batched tokens", visibility: "PARAMETER_VISIBILITY_HIDDEN", unsignedInteger: { minimum: "1", maximum: "1048576", unit: "tokens" } }
+ - { parameterId: "maximum-cudagraph-capture-size", displayName: "Maximum CUDA graph capture size", visibility: "PARAMETER_VISIBILITY_HIDDEN", unsignedInteger: { minimum: "1", maximum: "1024", unit: "sequences" } }
+ - { parameterId: "kv-cache-memory-bytes", displayName: "KV cache memory", visibility: "PARAMETER_VISIBILITY_HIDDEN", unsignedInteger: { minimum: "1", maximum: "1099511627776", unit: "bytes" } }
+ - { parameterId: "kv-offloading-size-gib", displayName: "KV offloading size", visibility: "PARAMETER_VISIBILITY_ADVANCED", unsignedInteger: { minimum: "0", maximum: "1024", unit: "GiB" } }
artifacts:
- artifactId: "linux-amd64-cuda"
oci:
diff --git a/backend/core/src/model/engine/vllm/plan.rs b/backend/core/src/model/engine/vllm/plan.rs
index ce737dc3..044c5d63 100644
--- a/backend/core/src/model/engine/vllm/plan.rs
+++ b/backend/core/src/model/engine/vllm/plan.rs
@@ -12,7 +12,7 @@ use dotcore_catalog::{
shared_parameters::{CONTEXT_PER_REQUEST_PARAMETER_ID, PARALLEL_REQUESTS_PARAMETER_ID},
};
use dotcore_proto::dotcore::{
- catalog::v1::engine_parameter_value::Value as EngineParameterValue,
+ catalog::v1::parameter_value::Value as EngineParameterValueKind,
engine::{
v1::{EngineKind, resolved_engine_model_state::State},
vllm::v1::{
@@ -443,7 +443,7 @@ fn unsigned_integer_parameter(
.find(|value| value.parameter_id == parameter_id)
})
.and_then(|value| match value.value.as_ref() {
- Some(EngineParameterValue::UnsignedInteger(value)) => Some(*value),
+ Some(EngineParameterValueKind::UnsignedInteger(value)) => Some(*value),
_ => None,
})
.ok_or_else(|| VllmPlanError::ParameterNotConfigured(parameter_id.to_owned()))
@@ -595,8 +595,8 @@ pub(crate) enum VllmPlanError {
mod tests {
use dotcore_proto::dotcore::{
catalog::v1::{
- EngineParameterValue, EngineParameterValues, ModelArtifact, ModelAuxiliaryArtifact,
- ModelOptionParameterValue, ModelOptionValues, engine_parameter_value::Value,
+ ModelArtifact, ModelAuxiliaryArtifact, ModelOptionParameterValue, ModelOptionValues,
+ ParameterValue, ParameterValues, parameter_value::Value,
},
engine::v1::{ResolvedEngineModelState, resolved_engine_model_state::State},
engine::vllm::v1::{
@@ -657,7 +657,7 @@ mod tests {
semantics_version: super::super::realization::SEMANTICS_VERSION,
state: Some(State::Vllm(state)),
}),
- engine_parameter_values: Some(EngineParameterValues {
+ engine_parameter_values: Some(ParameterValues {
values: [
("bytes", 7000_u64),
(PARALLEL_REQUESTS_PARAMETER_ID, 8),
@@ -667,7 +667,7 @@ mod tests {
(CONTEXT_PER_REQUEST_PARAMETER_ID, 32768),
]
.into_iter()
- .map(|(parameter_id, value)| EngineParameterValue {
+ .map(|(parameter_id, value)| ParameterValue {
parameter_id: parameter_id.to_owned(),
value: Some(Value::UnsignedInteger(value)),
})
diff --git a/backend/core/src/model/recommendation/tests.rs b/backend/core/src/model/recommendation/tests.rs
index 7366e95f..64fdd470 100644
--- a/backend/core/src/model/recommendation/tests.rs
+++ b/backend/core/src/model/recommendation/tests.rs
@@ -910,10 +910,10 @@ version: "1"
parameterSchema:
schemaId: "llama-v2"
parameters:
- - { parameterId: "total-context", displayName: "Total context", exposed: true, unsignedInteger: { minimum: "1024", maximum: "16777216", unit: "tokens", defaultValue: "32768" } }
- - { parameterId: "context-per-request", displayName: "Context per request", exposed: true, unsignedInteger: { minimum: "1024", maximum: "16777216", unit: "tokens" } }
- - { parameterId: "parallel-requests", displayName: "Parallel requests", exposed: true, unsignedInteger: { minimum: "1", maximum: "64", unit: "requests", defaultValue: "4" } }
- - { parameterId: "prompt-cache-memory-mib", displayName: "Prompt cache memory", unsignedInteger: { minimum: "0", maximum: "65536", unit: "MiB" } }
+ - { parameterId: "total-context", displayName: "Total context", visibility: "PARAMETER_VISIBILITY_BASIC", unsignedInteger: { minimum: "1024", maximum: "16777216", unit: "tokens", defaultValue: "32768" } }
+ - { parameterId: "context-per-request", displayName: "Context per request", visibility: "PARAMETER_VISIBILITY_BASIC", unsignedInteger: { minimum: "1024", maximum: "16777216", unit: "tokens" } }
+ - { parameterId: "parallel-requests", displayName: "Parallel requests", visibility: "PARAMETER_VISIBILITY_ADVANCED", unsignedInteger: { minimum: "1", maximum: "64", unit: "requests", defaultValue: "4" } }
+ - { parameterId: "prompt-cache-memory-mib", displayName: "Prompt cache memory", visibility: "PARAMETER_VISIBILITY_HIDDEN", unsignedInteger: { minimum: "0", maximum: "65536", unit: "MiB" } }
artifacts:
- artifactId: "linux-amd64"
oci:
diff --git a/backend/core/src/model/repository.rs b/backend/core/src/model/repository.rs
index ab7767fa..ff8ce628 100644
--- a/backend/core/src/model/repository.rs
+++ b/backend/core/src/model/repository.rs
@@ -1022,9 +1022,9 @@ mod tests {
ResolvedApplicationDeploymentSnapshot,
},
catalog::v1::{
- EngineParameterValue, EngineParameterValues, OciPlatform, UpstreamModelBoundary,
+ OciPlatform, ParameterValue, ParameterValues, UpstreamModelBoundary,
UpstreamModelDataEgressClass, UpstreamModelLocality,
- engine_parameter_value::Value as EngineParameterValueKind,
+ parameter_value::Value as EngineParameterValueKind,
},
deployment::v1::ExactPlacement,
inference::v1::{FallbackGroup, FallbackGroupSelection, InferenceRoute},
@@ -3120,7 +3120,7 @@ mod tests {
/// Engine parameter values for a llama.cpp deployment: the total context, the prompt cache and
/// the parallel requests that sizing never derives.
- fn engine_values(total_context: u64) -> EngineParameterValues {
+ fn engine_values(total_context: u64) -> ParameterValues {
unsigned_values(&[
("total-context", total_context),
("prompt-cache-memory-mib", 0),
@@ -3130,7 +3130,7 @@ mod tests {
/// The fixture profile's engine parameter defaults: its prompt cache, then the engine defaults
/// of the total context and the parallel requests.
- fn profile_default_values() -> EngineParameterValues {
+ fn profile_default_values() -> ParameterValues {
unsigned_values(&[
("prompt-cache-memory-mib", 0),
("total-context", 32_768),
@@ -3140,28 +3140,25 @@ mod tests {
/// The context per request resolution fills from the total context, within the fixture
/// model's maximum context.
- fn filled_context_per_request(total_context: u64) -> EngineParameterValues {
+ fn filled_context_per_request(total_context: u64) -> ParameterValues {
unsigned_values(&[("context-per-request", total_context.min(262_144))])
}
/// Values in the given order, followed by the filled context per request, as a resolved
/// snapshot retains them.
- fn resolved_values(
- mut values: EngineParameterValues,
- total_context: u64,
- ) -> EngineParameterValues {
+ fn resolved_values(mut values: ParameterValues, total_context: u64) -> ParameterValues {
values
.values
.extend(filled_context_per_request(total_context).values);
values
}
- fn unsigned_values(values: &[(&str, u64)]) -> EngineParameterValues {
- EngineParameterValues {
+ fn unsigned_values(values: &[(&str, u64)]) -> ParameterValues {
+ ParameterValues {
values: values
.iter()
.copied()
- .map(|(parameter_id, value)| EngineParameterValue {
+ .map(|(parameter_id, value)| ParameterValue {
parameter_id: parameter_id.to_owned(),
value: Some(EngineParameterValueKind::UnsignedInteger(value)),
})
diff --git a/backend/core/src/model/resolution.rs b/backend/core/src/model/resolution.rs
index b553c2c2..144db35e 100644
--- a/backend/core/src/model/resolution.rs
+++ b/backend/core/src/model/resolution.rs
@@ -13,12 +13,12 @@ use dotcore_catalog::{
use dotcore_domain::deployment::artifact::immutable_remote_launch_identity;
use dotcore_proto::dotcore::{
catalog::v1::{
- EngineArtifact, EngineParameterSchema, EngineParameterValue, EngineParameterValues,
- EngineRelease, Model, ModelArtifact, ModelArtifactBinding, ModelEngineConfiguration,
- ModelOptionValues, ModelProfile, NixArtifact, engine_artifact::Kind as EngineArtifactKind,
- engine_parameter_definition::ParameterType as EngineParameterType,
- engine_parameter_value::Value as EngineParameterValueKind,
+ EngineArtifact, EngineParameterSchema, EngineRelease, Model, ModelArtifact,
+ ModelArtifactBinding, ModelEngineConfiguration, ModelOptionValues, ModelProfile,
+ NixArtifact, ParameterValue, ParameterValues, engine_artifact::Kind as EngineArtifactKind,
model_option_parameter_value::Value as OptionParameterValueKind,
+ parameter_definition::ParameterType as EngineParameterType,
+ parameter_value::Value as EngineParameterValueKind,
},
deployment::v1::ArtifactPreparationPolicy,
model::v1::{
@@ -53,8 +53,8 @@ use crate::{
/// Only resource estimates keep values a launch would reject, with the reason beside them.
pub(crate) struct ResolvedLocalModelValues {
pub(crate) selection: LocalModelSelection,
- pub(crate) engine_parameter_values: EngineParameterValues,
- pub(crate) derived_engine_parameter_values: EngineParameterValues,
+ pub(crate) engine_parameter_values: ParameterValues,
+ pub(crate) derived_engine_parameter_values: ParameterValues,
pub(crate) engine_parameter_schema: EngineParameterSchema,
pub(crate) engine_parameter_schema_id: String,
pub(crate) option_values: Vec,
@@ -753,14 +753,14 @@ fn evaluate_selected_values(
)
.map_err(|error| sizing_unavailable(anyhow::Error::new(error)))?
.into_iter()
- .map(|(parameter_id, value)| EngineParameterValue {
+ .map(|(parameter_id, value)| ParameterValue {
parameter_id,
value: Some(EngineParameterValueKind::UnsignedInteger(value)),
})
.chain(filled_context_per_request)
.collect::>();
derived_values.sort_by(|left, right| left.parameter_id.cmp(&right.parameter_id));
- let derived_engine_parameter_values = EngineParameterValues {
+ let derived_engine_parameter_values = ParameterValues {
values: derived_values,
};
validate_derived_ranges(&derived_engine_parameter_values, &schema)?;
@@ -1055,7 +1055,7 @@ impl ModelLaunchResolutionError {
/// Rejects complete engine parameter values, each in range, that a launch can't start together.
fn validate_launch_values(
selection: &LocalModelSelection,
- values: &EngineParameterValues,
+ values: &ParameterValues,
) -> Result<(), ModelLaunchResolutionError> {
validate_context_per_request(selection, values)?;
crate::model::engine::validate_parameter_values(
@@ -1071,7 +1071,7 @@ fn validate_launch_values(
/// maximum context: the model's maximum context, or the configuration's lower maximum.
fn validate_context_per_request(
selection: &LocalModelSelection,
- values: &EngineParameterValues,
+ values: &ParameterValues,
) -> Result<(), ModelLaunchResolutionError> {
let Some(context_tokens) = crate::model::engine::unsigned_parameter_value(
values,
@@ -1094,7 +1094,7 @@ fn validate_context_per_request(
/// Rejects derived values outside their unsigned schema range with the offending value.
fn validate_derived_ranges(
- derived: &EngineParameterValues,
+ derived: &ParameterValues,
schema: &EngineParameterSchema,
) -> Result<(), ModelLaunchResolutionError> {
for value in &derived.values {
@@ -1163,10 +1163,9 @@ mod tests {
};
use dotcore_proto::dotcore::{
catalog::v1::{
- EngineParameterValue, EngineParameterValues, HardwareRequirements,
- ModelOptionParameterValue, ModelOptionValues, NixSystemStorePath, Selector,
- StorageBinding, StorageEnvironmentVariable, StorageKind, StorageScope,
- engine_parameter_value, model_option_parameter_value,
+ HardwareRequirements, ModelOptionParameterValue, ModelOptionValues, NixSystemStorePath,
+ ParameterValue, ParameterValues, Selector, StorageBinding, StorageEnvironmentVariable,
+ StorageKind, StorageScope, model_option_parameter_value, parameter_value,
},
engine::{
llama_cpp::v1::LlamaCppResolvedModelState, v1::resolved_engine_model_state::State,
@@ -1199,7 +1198,7 @@ mod tests {
"fixture-profile",
&LocalModelDeploymentConfiguration {
model_artifact_binding_id: "fixture-artifact".to_owned(),
- engine_parameter_values: Some(EngineParameterValues {
+ engine_parameter_values: Some(ParameterValues {
values: [prompt_cache_value(30_000)]
.into_iter()
.chain(context_and_request_values())
@@ -1261,7 +1260,7 @@ mod tests {
"fixture-profile",
&LocalModelDeploymentConfiguration {
model_artifact_binding_id: "fixture-artifact".to_owned(),
- engine_parameter_values: Some(EngineParameterValues {
+ engine_parameter_values: Some(ParameterValues {
values: [prompt_cache_value(30_000)]
.into_iter()
.chain(context_and_request_values())
@@ -1295,7 +1294,7 @@ mod tests {
"fixture-profile",
&LocalModelDeploymentConfiguration {
model_artifact_binding_id: "fixture-artifact".to_owned(),
- engine_parameter_values: Some(EngineParameterValues {
+ engine_parameter_values: Some(ParameterValues {
values: [prompt_cache_value(30_000)]
.into_iter()
.chain(context_and_request_values())
@@ -1365,7 +1364,7 @@ mod tests {
"fixture-profile",
&LocalModelDeploymentConfiguration {
model_artifact_binding_id: "fixture-artifact".to_owned(),
- engine_parameter_values: Some(EngineParameterValues {
+ engine_parameter_values: Some(ParameterValues {
values: [prompt_cache_value(30_000)]
.into_iter()
.chain(context_and_request_values())
@@ -1422,7 +1421,7 @@ mod tests {
);
let configuration = LocalModelDeploymentConfiguration {
model_artifact_binding_id: "sized".to_owned(),
- engine_parameter_values: Some(EngineParameterValues {
+ engine_parameter_values: Some(ParameterValues {
values: context_and_request_values(),
}),
custom_argument_tokens: vec!["--verbose".to_owned()],
@@ -1443,7 +1442,7 @@ mod tests {
resolve_local_values(&catalog, &derived_model_id(), "balanced", &configuration)
.expect("derived values");
- let expected_values = EngineParameterValues {
+ let expected_values = ParameterValues {
values: context_and_request_values()
.into_iter()
.chain([filled_context_per_request(), prompt_cache_value(4096)])
@@ -1467,7 +1466,7 @@ mod tests {
assert_eq!(values.engine_parameter_values, expected_values);
assert_eq!(
values.derived_engine_parameter_values,
- EngineParameterValues {
+ ParameterValues {
values: vec![filled_context_per_request(), prompt_cache_value(4096)],
}
);
@@ -1720,7 +1719,7 @@ mod tests {
"balanced",
&LocalModelDeploymentConfiguration {
model_artifact_binding_id: "sized".to_owned(),
- engine_parameter_values: Some(EngineParameterValues {
+ engine_parameter_values: Some(ParameterValues {
values: [prompt_cache_value(30_000)]
.into_iter()
.chain(context_and_request_values())
@@ -1736,7 +1735,7 @@ mod tests {
assert_eq!(
snapshot.engine_parameter_values,
- Some(EngineParameterValues {
+ Some(ParameterValues {
values: [prompt_cache_value(30_000)]
.into_iter()
.chain(context_and_request_values())
@@ -1772,7 +1771,7 @@ mod tests {
"balanced",
&LocalModelDeploymentConfiguration {
model_artifact_binding_id: "sized".to_owned(),
- engine_parameter_values: Some(EngineParameterValues {
+ engine_parameter_values: Some(ParameterValues {
values: context_and_request_values(),
}),
..Default::default()
@@ -1803,7 +1802,7 @@ mod tests {
completed,
LocalModelDeploymentConfiguration {
model_artifact_binding_id: "sized".to_owned(),
- engine_parameter_values: Some(EngineParameterValues {
+ engine_parameter_values: Some(ParameterValues {
values: [prompt_cache_value(30_000)]
.into_iter()
.chain(context_and_request_values())
@@ -1815,7 +1814,7 @@ mod tests {
);
assert_eq!(
values.engine_parameter_values,
- EngineParameterValues {
+ ParameterValues {
values: [prompt_cache_value(30_000)]
.into_iter()
.chain(context_and_request_values())
@@ -1825,7 +1824,7 @@ mod tests {
);
assert_eq!(
values.derived_engine_parameter_values,
- EngineParameterValues {
+ ParameterValues {
values: vec![filled_context_per_request()],
}
);
@@ -1841,7 +1840,7 @@ mod tests {
let complete = LocalModelDeploymentConfiguration {
model_artifact_binding_id: "second".to_owned(),
- engine_parameter_values: Some(EngineParameterValues {
+ engine_parameter_values: Some(ParameterValues {
values: [prompt_cache_value(8)]
.into_iter()
.chain(context_and_request_values())
@@ -1852,7 +1851,7 @@ mod tests {
assert_eq!(completed(complete.clone()), complete);
let partial = completed(LocalModelDeploymentConfiguration {
- engine_parameter_values: Some(EngineParameterValues {
+ engine_parameter_values: Some(ParameterValues {
values: vec![prompt_cache_value(8)],
}),
..Default::default()
@@ -1861,7 +1860,7 @@ mod tests {
partial,
LocalModelDeploymentConfiguration {
model_artifact_binding_id: "sized".to_owned(),
- engine_parameter_values: Some(EngineParameterValues {
+ engine_parameter_values: Some(ParameterValues {
values: [prompt_cache_value(8)]
.into_iter()
.chain(context_and_request_values())
@@ -1874,7 +1873,7 @@ mod tests {
resolve_local_values(&catalog, &derived_model_id(), "balanced", &partial)
.expect("overlaid values")
.derived_engine_parameter_values,
- EngineParameterValues {
+ ParameterValues {
values: vec![filled_context_per_request()],
}
);
@@ -2081,7 +2080,7 @@ mod tests {
assert_eq!(values.selection.binding.binding_id, "second");
assert_eq!(
values.engine_parameter_values,
- EngineParameterValues {
+ ParameterValues {
values: [prompt_cache_value(30_000)]
.into_iter()
.chain(context_and_request_values())
@@ -2136,7 +2135,7 @@ mod tests {
"fixture-profile",
&LocalModelDeploymentConfiguration {
model_artifact_binding_id: "fixture-artifact".to_owned(),
- engine_parameter_values: Some(EngineParameterValues {
+ engine_parameter_values: Some(ParameterValues {
values: [prompt_cache_value(30_000)]
.into_iter()
.chain(context_and_request_values())
@@ -2712,7 +2711,7 @@ mod tests {
"balanced",
&LocalModelDeploymentConfiguration {
model_artifact_binding_id: "sized".to_owned(),
- engine_parameter_values: Some(EngineParameterValues {
+ engine_parameter_values: Some(ParameterValues {
values: context_and_request_values(),
}),
..Default::default()
@@ -2799,7 +2798,7 @@ mod tests {
fn context_and_request_configuration() -> LocalModelDeploymentConfiguration {
LocalModelDeploymentConfiguration {
model_artifact_binding_id: "sized".to_owned(),
- engine_parameter_values: Some(EngineParameterValues {
+ engine_parameter_values: Some(ParameterValues {
values: context_and_request_values(),
}),
..Default::default()
@@ -2926,7 +2925,7 @@ mod tests {
.iter()
.find(|value| value.parameter_id == TOTAL_CONTEXT_PARAMETER_ID)
.and_then(|value| match value.value {
- Some(engine_parameter_value::Value::UnsignedInteger(value)) => Some(value),
+ Some(parameter_value::Value::UnsignedInteger(value)) => Some(value),
_ => None,
})
.expect("resolved total context"))
@@ -2969,13 +2968,13 @@ mod tests {
root
}
- fn unsigned_values(values: &[(&str, u64)]) -> EngineParameterValues {
- EngineParameterValues {
+ fn unsigned_values(values: &[(&str, u64)]) -> ParameterValues {
+ ParameterValues {
values: values
.iter()
- .map(|(parameter_id, value)| EngineParameterValue {
+ .map(|(parameter_id, value)| ParameterValue {
parameter_id: (*parameter_id).to_owned(),
- value: Some(engine_parameter_value::Value::UnsignedInteger(*value)),
+ value: Some(parameter_value::Value::UnsignedInteger(*value)),
})
.collect(),
}
@@ -2985,29 +2984,29 @@ mod tests {
CatalogModelId("fixture-model".to_owned())
}
- fn prompt_cache_value(value: u64) -> EngineParameterValue {
- EngineParameterValue {
+ fn prompt_cache_value(value: u64) -> ParameterValue {
+ ParameterValue {
parameter_id: "prompt-cache-memory-mib".to_owned(),
- value: Some(engine_parameter_value::Value::UnsignedInteger(value)),
+ value: Some(parameter_value::Value::UnsignedInteger(value)),
}
}
/// The context per request resolution fills from the fixtures' total context.
- fn filled_context_per_request() -> EngineParameterValue {
- EngineParameterValue {
+ fn filled_context_per_request() -> ParameterValue {
+ ParameterValue {
parameter_id: "context-per-request".to_owned(),
- value: Some(engine_parameter_value::Value::UnsignedInteger(32_768)),
+ value: Some(parameter_value::Value::UnsignedInteger(32_768)),
}
}
/// Total context and parallel request values the derivation fixtures supply, since their
/// sizing derives the prompt cache instead.
- fn context_and_request_values() -> Vec {
+ fn context_and_request_values() -> Vec {
[("total-context", 32_768), ("parallel-requests", 4)]
.into_iter()
- .map(|(parameter_id, value)| EngineParameterValue {
+ .map(|(parameter_id, value)| ParameterValue {
parameter_id: parameter_id.to_owned(),
- value: Some(engine_parameter_value::Value::UnsignedInteger(value)),
+ value: Some(parameter_value::Value::UnsignedInteger(value)),
})
.collect()
}
diff --git a/backend/core/src/model/resolved.rs b/backend/core/src/model/resolved.rs
index cc8fe061..a4578415 100644
--- a/backend/core/src/model/resolved.rs
+++ b/backend/core/src/model/resolved.rs
@@ -16,8 +16,8 @@ use dotcore_domain::{
};
use dotcore_proto::dotcore::{
catalog::v1::{
- EngineArtifact, EngineParameterValues, EngineRelease, HardwareRequirements,
- HuggingFaceArtifactSource, ModelInputCapabilities, StorageBinding,
+ EngineArtifact, EngineRelease, HardwareRequirements, HuggingFaceArtifactSource,
+ ModelInputCapabilities, ParameterValues, StorageBinding,
engine_artifact::Kind as EngineArtifactKind,
hugging_face_artifact_source::Content as HuggingFaceContent, nix_artifact::Provides,
},
@@ -624,7 +624,7 @@ pub(crate) fn selected_target_memory_architecture(
/// Requires every derived parameter id to name one retained engine parameter value.
fn validate_sizing(
sizing: Option<&ResolvedSizing>,
- values: &EngineParameterValues,
+ values: &ParameterValues,
) -> Result<(), CatalogError> {
let Some(sizing) = sizing else {
return Ok(());
diff --git a/backend/core/src/model/sizing_scenario/mod.rs b/backend/core/src/model/sizing_scenario/mod.rs
index 8969b702..e6d31163 100644
--- a/backend/core/src/model/sizing_scenario/mod.rs
+++ b/backend/core/src/model/sizing_scenario/mod.rs
@@ -19,8 +19,8 @@ use dotcore_catalog::{
};
use dotcore_proto::dotcore::{
catalog::v1::{
- EngineParameterSchema, EngineParameterValue, EngineParameterValues, HardwareRequirements,
- Model, ModelProfile, engine_parameter_value::Value as EngineParameterValueKind,
+ EngineParameterSchema, HardwareRequirements, Model, ModelProfile, ParameterValue,
+ ParameterValues, parameter_value::Value as EngineParameterValueKind,
},
deployment::v1::MemoryFit,
model::v1::{
@@ -406,7 +406,7 @@ fn largest_fitting_total_context(
set_unsigned_value(
configuration
.engine_parameter_values
- .get_or_insert_with(EngineParameterValues::default),
+ .get_or_insert_with(ParameterValues::default),
TOTAL_CONTEXT_PARAMETER_ID,
total_context,
);
@@ -559,7 +559,7 @@ fn with_scenario_values(
let values = configuration
.engine_parameter_values
- .get_or_insert_with(EngineParameterValues::default);
+ .get_or_insert_with(ParameterValues::default);
set_unsigned_value(
values,
TOTAL_CONTEXT_PARAMETER_ID,
@@ -579,8 +579,8 @@ fn with_scenario_values(
/// values are the ones a launch would resolve before sizing derivations.
fn with_filled_context_per_request(
declarations: &CatalogLocalModelDeclarations,
- mut values: EngineParameterValues,
-) -> EngineParameterValues {
+ mut values: ParameterValues,
+) -> ParameterValues {
if let Some(schema) = engine_parameter_schema(declarations) {
let filled = crate::model::engine::filled_context_per_request(
declarations.engine_kind,
@@ -599,7 +599,7 @@ fn with_filled_context_per_request(
fn accepts_context_values(
declarations: &CatalogLocalModelDeclarations,
schema: &EngineParameterSchema,
- complete: &EngineParameterValues,
+ complete: &ParameterValues,
) -> Option {
let mut in_range = true;
for parameter_id in [TOTAL_CONTEXT_PARAMETER_ID, CONTEXT_PER_REQUEST_PARAMETER_ID] {
@@ -658,8 +658,8 @@ fn engine_parameter_schema(
}
/// Replaces the value of one parameter, or adds it when the values leave it out.
-fn set_unsigned_value(values: &mut EngineParameterValues, parameter_id: &str, value: u64) {
- let value = EngineParameterValue {
+fn set_unsigned_value(values: &mut ParameterValues, parameter_id: &str, value: u64) {
+ let value = ParameterValue {
parameter_id: parameter_id.to_owned(),
value: Some(EngineParameterValueKind::UnsignedInteger(value)),
};
diff --git a/backend/core/src/model/sizing_scenario/tests.rs b/backend/core/src/model/sizing_scenario/tests.rs
index 5ff3346a..b685d0e3 100644
--- a/backend/core/src/model/sizing_scenario/tests.rs
+++ b/backend/core/src/model/sizing_scenario/tests.rs
@@ -1491,10 +1491,10 @@ version: "1"
parameterSchema:
schemaId: "llama-v2"
parameters:
- - { parameterId: "total-context", displayName: "Total context", exposed: true, unsignedInteger: { minimum: "1024", maximum: "16777216", unit: "tokens", defaultValue: "32768" } }
- - { parameterId: "context-per-request", displayName: "Context per request", exposed: true, unsignedInteger: { minimum: "1024", maximum: "16777216", unit: "tokens" } }
- - { parameterId: "parallel-requests", displayName: "Parallel requests", exposed: true, unsignedInteger: { minimum: "1", maximum: "64", unit: "requests", defaultValue: "4" } }
- - { parameterId: "prompt-cache-memory-mib", displayName: "Prompt cache memory", unsignedInteger: { minimum: "0", maximum: "65536", unit: "MiB" } }
+ - { parameterId: "total-context", displayName: "Total context", visibility: "PARAMETER_VISIBILITY_BASIC", unsignedInteger: { minimum: "1024", maximum: "16777216", unit: "tokens", defaultValue: "32768" } }
+ - { parameterId: "context-per-request", displayName: "Context per request", visibility: "PARAMETER_VISIBILITY_BASIC", unsignedInteger: { minimum: "1024", maximum: "16777216", unit: "tokens" } }
+ - { parameterId: "parallel-requests", displayName: "Parallel requests", visibility: "PARAMETER_VISIBILITY_ADVANCED", unsignedInteger: { minimum: "1", maximum: "64", unit: "requests", defaultValue: "4" } }
+ - { parameterId: "prompt-cache-memory-mib", displayName: "Prompt cache memory", visibility: "PARAMETER_VISIBILITY_HIDDEN", unsignedInteger: { minimum: "0", maximum: "65536", unit: "MiB" } }
artifacts:
- artifactId: "linux"
oci:
@@ -1601,13 +1601,13 @@ version: "1"
parameterSchema:
schemaId: "vllm-v2"
parameters:
- - { parameterId: "total-context", displayName: "Total context", exposed: true, unsignedInteger: { minimum: "1024", maximum: "16777216", unit: "tokens", defaultValue: "32768" } }
- - { parameterId: "context-per-request", displayName: "Context per request", exposed: true, unsignedInteger: { minimum: "1024", maximum: "1048576", unit: "tokens" } }
- - { parameterId: "parallel-requests", displayName: "Parallel requests", exposed: true, unsignedInteger: { minimum: "1", maximum: "1024", unit: "requests", defaultValue: "4" } }
- - { parameterId: "maximum-batched-tokens", displayName: "Maximum batched tokens", unsignedInteger: { minimum: "1", maximum: "1048576", unit: "tokens" } }
- - { parameterId: "maximum-cudagraph-capture-size", displayName: "Maximum CUDA graph capture size", unsignedInteger: { minimum: "1", maximum: "1024", unit: "sequences" } }
- - { parameterId: "kv-cache-memory-bytes", displayName: "KV cache memory", unsignedInteger: { minimum: "1", maximum: "68719476736", unit: "bytes" } }
- - { parameterId: "kv-offloading-size-gib", displayName: "KV offloading size", unsignedInteger: { minimum: "0", maximum: "1024", unit: "GiB" } }
+ - { parameterId: "total-context", displayName: "Total context", visibility: "PARAMETER_VISIBILITY_BASIC", unsignedInteger: { minimum: "1024", maximum: "16777216", unit: "tokens", defaultValue: "32768" } }
+ - { parameterId: "context-per-request", displayName: "Context per request", visibility: "PARAMETER_VISIBILITY_BASIC", unsignedInteger: { minimum: "1024", maximum: "1048576", unit: "tokens" } }
+ - { parameterId: "parallel-requests", displayName: "Parallel requests", visibility: "PARAMETER_VISIBILITY_ADVANCED", unsignedInteger: { minimum: "1", maximum: "1024", unit: "requests", defaultValue: "4" } }
+ - { parameterId: "maximum-batched-tokens", displayName: "Maximum batched tokens", visibility: "PARAMETER_VISIBILITY_HIDDEN", unsignedInteger: { minimum: "1", maximum: "1048576", unit: "tokens" } }
+ - { parameterId: "maximum-cudagraph-capture-size", displayName: "Maximum CUDA graph capture size", visibility: "PARAMETER_VISIBILITY_HIDDEN", unsignedInteger: { minimum: "1", maximum: "1024", unit: "sequences" } }
+ - { parameterId: "kv-cache-memory-bytes", displayName: "KV cache memory", visibility: "PARAMETER_VISIBILITY_HIDDEN", unsignedInteger: { minimum: "1", maximum: "68719476736", unit: "bytes" } }
+ - { parameterId: "kv-offloading-size-gib", displayName: "KV offloading size", visibility: "PARAMETER_VISIBILITY_HIDDEN", unsignedInteger: { minimum: "0", maximum: "1024", unit: "GiB" } }
artifacts:
- artifactId: "linux"
oci:
@@ -1695,10 +1695,10 @@ version: "1"
parameterSchema:
schemaId: "ninfer-v2"
parameters:
- - { parameterId: "total-context", displayName: "Total context", exposed: true, unsignedInteger: { minimum: "1024", maximum: "2097152", unit: "tokens", defaultValue: "32768" } }
- - { parameterId: "context-per-request", displayName: "Context per request", exposed: true, unsignedInteger: { minimum: "1024", maximum: "262144", unit: "tokens" } }
- - { parameterId: "parallel-requests", displayName: "Parallel requests", exposed: true, unsignedInteger: { minimum: "1", maximum: "8", unit: "requests", defaultValue: "4" } }
- - { parameterId: "host-context-mib", displayName: "Host context memory", unsignedInteger: { minimum: "0", maximum: "1048576", unit: "MiB" } }
+ - { parameterId: "total-context", displayName: "Total context", visibility: "PARAMETER_VISIBILITY_BASIC", unsignedInteger: { minimum: "1024", maximum: "2097152", unit: "tokens", defaultValue: "32768" } }
+ - { parameterId: "context-per-request", displayName: "Context per request", visibility: "PARAMETER_VISIBILITY_BASIC", unsignedInteger: { minimum: "1024", maximum: "262144", unit: "tokens" } }
+ - { parameterId: "parallel-requests", displayName: "Parallel requests", visibility: "PARAMETER_VISIBILITY_ADVANCED", unsignedInteger: { minimum: "1", maximum: "8", unit: "requests", defaultValue: "4" } }
+ - { parameterId: "host-context-mib", displayName: "Host context memory", visibility: "PARAMETER_VISIBILITY_HIDDEN", unsignedInteger: { minimum: "0", maximum: "1048576", unit: "MiB" } }
artifacts:
- artifactId: "linux"
oci:
diff --git a/backend/core/src/route_manager/projection.rs b/backend/core/src/route_manager/projection.rs
index 38dd58a6..5e33261b 100644
--- a/backend/core/src/route_manager/projection.rs
+++ b/backend/core/src/route_manager/projection.rs
@@ -248,7 +248,7 @@ fn modalities(supports_text: bool, supports_image: bool) -> Vec {
#[cfg(test)]
mod tests {
use dotcore_proto::dotcore::{
- catalog::v1::engine_parameter_value,
+ catalog::v1::parameter_value,
inference::v1::{FallbackGroup, FallbackGroupSelection, InferenceRoute},
model::v1::ModelDeploymentSnapshot,
};
@@ -394,7 +394,7 @@ mod tests {
.expect("fixture parameter values");
for value in &mut values.values {
if value.parameter_id == CONTEXT_PER_REQUEST_PARAMETER_ID {
- value.value = Some(engine_parameter_value::Value::UnsignedInteger(context));
+ value.value = Some(parameter_value::Value::UnsignedInteger(context));
}
}
ModelDeploymentDeclaration {
diff --git a/backend/core/src/telemetry/fit/mod.rs b/backend/core/src/telemetry/fit/mod.rs
index 1f92c589..ee677ef2 100644
--- a/backend/core/src/telemetry/fit/mod.rs
+++ b/backend/core/src/telemetry/fit/mod.rs
@@ -26,9 +26,7 @@ use dotcore_catalog::{
shared_parameters::TOTAL_CONTEXT_PARAMETER_ID,
};
use dotcore_proto::dotcore::{
- catalog::v1::{
- EngineParameterValues, ModelEngineConfiguration, ModelOptionValues, engine_parameter_value,
- },
+ catalog::v1::{ModelEngineConfiguration, ModelOptionValues, ParameterValues, parameter_value},
telemetry::v1::{
VllmStartupFacts, WorkloadResourceObservationKind, WorkloadResourceRecord,
workload_resource_observation::EngineFacts,
@@ -134,7 +132,7 @@ impl ObservedQuantity {
.iter()
.find(|value| &value.parameter_id == parameter)
.and_then(|value| match value.value {
- Some(engine_parameter_value::Value::UnsignedInteger(value)) => Some(value),
+ Some(parameter_value::Value::UnsignedInteger(value)) => Some(value),
_ => None,
}),
}
@@ -934,7 +932,7 @@ fn predict(
fn record_inputs(
configuration: &ModelEngineConfiguration,
record: &WorkloadResourceRecord,
-) -> (EngineParameterValues, Vec) {
+) -> (ParameterValues, Vec) {
let (engine_values, mut option_values) = record
.inputs
.as_ref()
@@ -1113,7 +1111,7 @@ fn total_context(record: &WorkloadResourceRecord) -> Option {
.iter()
.find(|value| value.parameter_id == TOTAL_CONTEXT_PARAMETER_ID)
.and_then(|value| match value.value {
- Some(engine_parameter_value::Value::UnsignedInteger(value)) => Some(value),
+ Some(parameter_value::Value::UnsignedInteger(value)) => Some(value),
_ => None,
})
}
diff --git a/backend/core/src/telemetry/fit/tests.rs b/backend/core/src/telemetry/fit/tests.rs
index c1bb56b8..8ae3e18d 100644
--- a/backend/core/src/telemetry/fit/tests.rs
+++ b/backend/core/src/telemetry/fit/tests.rs
@@ -5,7 +5,7 @@ use dotcore_catalog::test_support::{
use dotcore_proto::{
dotcore::{
catalog::v1::{
- EngineParameterValue, ModelOptionParameterValue, ModelOptionValues,
+ ModelOptionParameterValue, ModelOptionValues, ParameterValue,
model_option_parameter_value,
},
telemetry::v1::{
@@ -1466,10 +1466,10 @@ fn record(
model_artifact_id: "sized-weights".to_owned(),
engine_release_id: "fixture-engine".to_owned(),
engine_artifact_id: "linux-amd64".to_owned(),
- engine_parameter_values: Some(EngineParameterValues {
- values: vec![EngineParameterValue {
+ engine_parameter_values: Some(ParameterValues {
+ values: vec![ParameterValue {
parameter_id: "total-context".to_owned(),
- value: Some(engine_parameter_value::Value::UnsignedInteger(context_size)),
+ value: Some(parameter_value::Value::UnsignedInteger(context_size)),
}],
}),
..WorkloadResourceInputs::default()
diff --git a/backend/core/src/telemetry/mod.rs b/backend/core/src/telemetry/mod.rs
index d8fbb3bb..bf453c7a 100644
--- a/backend/core/src/telemetry/mod.rs
+++ b/backend/core/src/telemetry/mod.rs
@@ -187,9 +187,9 @@ mod tests {
use dotcore_proto::{
dotcore::{
catalog::v1::{
- EngineParameterValue, EngineParameterValues, ModelOptionParameterValue,
- ModelOptionValues, engine_parameter_value::Value as EngineValue,
+ ModelOptionParameterValue, ModelOptionValues, ParameterValue, ParameterValues,
model_option_parameter_value::Value as OptionValue,
+ parameter_value::Value as EngineValue,
},
observation::v1::AcceleratorMemoryArchitecture,
telemetry::v1::{
@@ -339,13 +339,13 @@ mod tests {
engine_release_id: "vllm-v0-32-0".to_owned(),
engine_artifact_id: "linux-amd64-cuda".to_owned(),
engine_artifact_reference: "docker.io/vllm/vllm-openai@sha256:d392".to_owned(),
- engine_parameter_values: Some(EngineParameterValues {
+ engine_parameter_values: Some(ParameterValues {
values: vec![
- EngineParameterValue {
+ ParameterValue {
parameter_id: "kv-cache-memory-bytes".to_owned(),
value: Some(EngineValue::UnsignedInteger(6_000_000_000)),
},
- EngineParameterValue {
+ ParameterValue {
parameter_id: "prefix-caching".to_owned(),
value: Some(EngineValue::Boolean(false)),
},
diff --git a/backend/core/src/test_support/mod.rs b/backend/core/src/test_support/mod.rs
index 1eca75c4..0232e50e 100644
--- a/backend/core/src/test_support/mod.rs
+++ b/backend/core/src/test_support/mod.rs
@@ -37,15 +37,15 @@ pub(crate) fn host_process_load_guard() -> std::sync::MutexGuard<'static, ()> {
/// Unsigned engine parameter values for the given parameter and value pairs, in order.
pub(crate) fn unsigned_parameter_values<'a>(
values: impl IntoIterator- ,
-) -> dotcore_proto::dotcore::catalog::v1::EngineParameterValues {
+) -> dotcore_proto::dotcore::catalog::v1::ParameterValues {
use dotcore_proto::dotcore::catalog::v1::{
- EngineParameterValue, EngineParameterValues, engine_parameter_value::Value,
+ ParameterValue, ParameterValues, parameter_value::Value,
};
- EngineParameterValues {
+ ParameterValues {
values: values
.into_iter()
- .map(|(parameter_id, value)| EngineParameterValue {
+ .map(|(parameter_id, value)| ParameterValue {
parameter_id: parameter_id.to_owned(),
value: Some(Value::UnsignedInteger(value)),
})
diff --git a/backend/core/src/test_support/ninfer.rs b/backend/core/src/test_support/ninfer.rs
index db974f00..bca2ced1 100644
--- a/backend/core/src/test_support/ninfer.rs
+++ b/backend/core/src/test_support/ninfer.rs
@@ -14,18 +14,18 @@ use dotcore_catalog::{
};
use dotcore_proto::dotcore::{
catalog::v1::{
- BooleanModelOptionParameterSchema, EngineParameterDefinition, EngineParameterSchema,
- EngineParameterValue, EngineParameterValues, EngineRelease, HardwareRequirements,
- HardwareSupport, HuggingFaceArtifactSource, HuggingFaceFileArtifact, InferenceRequestKind,
- Model, ModelArtifact, ModelArtifactBinding, ModelConfigurationOption,
+ BooleanModelOptionParameterSchema, EngineParameterSchema, EngineRelease,
+ HardwareRequirements, HardwareSupport, HuggingFaceArtifactSource, HuggingFaceFileArtifact,
+ InferenceRequestKind, Model, ModelArtifact, ModelArtifactBinding, ModelConfigurationOption,
ModelEngineConfiguration, ModelExecutionCapabilities, ModelImageDetail,
ModelImageInputCapacity, ModelImageMediaType, ModelInputCapabilities, ModelInputModality,
ModelOption, ModelOptionParameterDefinition, ModelOptionParameterValue, ModelOptionValues,
- ModelParallelShape, SpeculativeDecodingMethod, SpeculativeDecodingModelOption,
- UnsignedIntegerEngineParameterSchema, UnsignedIntegerModelOptionParameterSchema,
- VisionModelOption, engine_parameter_definition, engine_parameter_value,
- hugging_face_artifact_source, model_option, model_option_parameter_definition,
- model_option_parameter_value,
+ ModelParallelShape, ParameterDefinition, ParameterValue, ParameterValues,
+ ParameterVisibility, SpeculativeDecodingMethod, SpeculativeDecodingModelOption,
+ UnsignedIntegerModelOptionParameterSchema, UnsignedIntegerParameterSchema,
+ VisionModelOption, hugging_face_artifact_source, model_option,
+ model_option_parameter_definition, model_option_parameter_value, parameter_definition,
+ parameter_value,
},
engine::{
ninfer::v1::{
@@ -116,13 +116,13 @@ pub(crate) fn refresh_launch_plan(local: &mut ResolvedLocalModelDeploymentSnapsh
}
/// Returns ninfer engine parameter values for the given pairs.
-pub(crate) fn parameter_values
(values: [(&str, u64); N]) -> EngineParameterValues {
- EngineParameterValues {
+pub(crate) fn parameter_values(values: [(&str, u64); N]) -> ParameterValues {
+ ParameterValues {
values: values
.into_iter()
- .map(|(parameter_id, value)| EngineParameterValue {
+ .map(|(parameter_id, value)| ParameterValue {
parameter_id: parameter_id.to_owned(),
- value: Some(engine_parameter_value::Value::UnsignedInteger(value)),
+ value: Some(parameter_value::Value::UnsignedInteger(value)),
})
.collect(),
}
@@ -173,20 +173,21 @@ pub(crate) fn release() -> EngineRelease {
.into_iter()
.map(
|(parameter_id, display_name, minimum, maximum, unit, default_value)| {
- EngineParameterDefinition {
+ ParameterDefinition {
parameter_id: parameter_id.to_owned(),
display_name: display_name.to_owned(),
- parameter_type: Some(
- engine_parameter_definition::ParameterType::UnsignedInteger(
- UnsignedIntegerEngineParameterSchema {
- minimum,
- maximum,
- unit: unit.to_owned(),
- default_value,
- },
- ),
- ),
- exposed: true,
+ parameter_type: Some(parameter_definition::ParameterType::UnsignedInteger(
+ UnsignedIntegerParameterSchema {
+ minimum,
+ maximum,
+ unit: unit.to_owned(),
+ default_value,
+ },
+ )),
+ visibility: match parameter_id {
+ "total-context" | "context-per-request" => ParameterVisibility::Basic,
+ _ => ParameterVisibility::Advanced,
+ } as i32,
description: parameter_id.to_owned(),
}
},
diff --git a/backend/core/src/test_support/nix.rs b/backend/core/src/test_support/nix.rs
index 1271206f..b94b99b5 100644
--- a/backend/core/src/test_support/nix.rs
+++ b/backend/core/src/test_support/nix.rs
@@ -6,8 +6,7 @@ use dotcore_domain::{
};
use dotcore_proto::dotcore::{
catalog::v1::{
- EngineArtifact, NixArtifact, engine_artifact::Kind as EngineArtifactKind,
- engine_parameter_value,
+ EngineArtifact, NixArtifact, engine_artifact::Kind as EngineArtifactKind, parameter_value,
},
deployment::v1::ArtifactPreparationPolicy,
engine::v1::resolved_engine_model_state,
@@ -78,7 +77,7 @@ pub(crate) fn apple_metal_snapshot(model_id: &str) -> ModelDeploymentSnapshot {
.iter_mut()
.find(|value| value.parameter_id == "prompt-cache-memory-mib")
.expect("prompt cache value")
- .value = Some(engine_parameter_value::Value::UnsignedInteger(2048));
+ .value = Some(parameter_value::Value::UnsignedInteger(2048));
local.desired_launch_plan = Some(
crate::model::engine::EngineIntegrationRegistry::production()
diff --git a/backend/core/src/test_support/strata.rs b/backend/core/src/test_support/strata.rs
index 4e0091d9..c3be6bc9 100644
--- a/backend/core/src/test_support/strata.rs
+++ b/backend/core/src/test_support/strata.rs
@@ -11,14 +11,14 @@ use dotcore_catalog::shared_parameters::{
};
use dotcore_proto::dotcore::{
catalog::v1::{
- EngineParameterDefinition, EngineParameterSchema, EngineParameterValue,
- EngineParameterValues, EngineRelease, HardwareRequirements, HardwareSupport,
+ EngineParameterSchema, EngineRelease, HardwareRequirements, HardwareSupport,
HuggingFaceArtifactSource, HuggingFaceFileArtifact, HuggingFaceSnapshotArtifact,
HuggingFaceSnapshotFile, InferenceRequestKind, Model, ModelArtifact, ModelArtifactBinding,
ModelAuxiliaryArtifact, ModelEngineConfiguration, ModelExecutionCapabilities,
- ModelInputCapabilities, ModelInputModality, ModelParallelShape, StorageBinding,
- StorageKind, StorageScope, UnsignedIntegerEngineParameterSchema,
- engine_parameter_definition, engine_parameter_value, hugging_face_artifact_source,
+ ModelInputCapabilities, ModelInputModality, ModelParallelShape, ParameterDefinition,
+ ParameterValue, ParameterValues, ParameterVisibility, StorageBinding, StorageKind,
+ StorageScope, UnsignedIntegerParameterSchema, hugging_face_artifact_source,
+ parameter_definition, parameter_value,
},
engine::{
strata::v1::{
@@ -109,13 +109,13 @@ pub(crate) fn refresh_launch_plan(local: &mut ResolvedLocalModelDeploymentSnapsh
}
/// Returns Strata engine parameter values for the given pairs.
-pub(crate) fn parameter_values(values: [(&str, u64); N]) -> EngineParameterValues {
- EngineParameterValues {
+pub(crate) fn parameter_values(values: [(&str, u64); N]) -> ParameterValues {
+ ParameterValues {
values: values
.into_iter()
- .map(|(parameter_id, value)| EngineParameterValue {
+ .map(|(parameter_id, value)| ParameterValue {
parameter_id: parameter_id.to_owned(),
- value: Some(engine_parameter_value::Value::UnsignedInteger(value)),
+ value: Some(parameter_value::Value::UnsignedInteger(value)),
})
.collect(),
}
@@ -166,20 +166,21 @@ pub(crate) fn release() -> EngineRelease {
.into_iter()
.map(
|(parameter_id, display_name, minimum, maximum, unit, default_value)| {
- EngineParameterDefinition {
+ ParameterDefinition {
parameter_id: parameter_id.to_owned(),
display_name: display_name.to_owned(),
- parameter_type: Some(
- engine_parameter_definition::ParameterType::UnsignedInteger(
- UnsignedIntegerEngineParameterSchema {
- minimum,
- maximum,
- unit: unit.to_owned(),
- default_value,
- },
- ),
- ),
- exposed: true,
+ parameter_type: Some(parameter_definition::ParameterType::UnsignedInteger(
+ UnsignedIntegerParameterSchema {
+ minimum,
+ maximum,
+ unit: unit.to_owned(),
+ default_value,
+ },
+ )),
+ visibility: match parameter_id {
+ "total-context" | "context-per-request" => ParameterVisibility::Basic,
+ _ => ParameterVisibility::Advanced,
+ } as i32,
description: parameter_id.to_owned(),
}
},
diff --git a/backend/core/tests/backend/backend_integration.rs b/backend/core/tests/backend/backend_integration.rs
index 7c9bf21a..8590b00d 100644
--- a/backend/core/tests/backend/backend_integration.rs
+++ b/backend/core/tests/backend/backend_integration.rs
@@ -30,9 +30,9 @@ use dotcore_proto::dotcore::{
application_deployment_service_server::ApplicationDeploymentService as ApplicationServiceContract,
},
catalog::v1::{
- EngineParameterValue, EngineParameterValues, ListApplicationsRequest,
- ListEngineReleasesRequest, ListModelsRequest, UseCase,
- catalog_service_server::CatalogService as CatalogServiceContract, engine_parameter_value,
+ ListApplicationsRequest, ListEngineReleasesRequest, ListModelsRequest, ParameterValue,
+ ParameterValues, UseCase, catalog_service_server::CatalogService as CatalogServiceContract,
+ parameter_value,
},
deployment::v1::MemoryFit,
inference::v1::{DeleteRouteRequest, InferenceRoute, ListRoutesRequest, ReplaceRouteRequest},
@@ -4897,7 +4897,7 @@ async fn profile_compatibility_sizes_a_scenario_against_the_simulated_target_mem
.collect::>(),
[(
"fixture-profile",
- Some(EngineParameterValues {
+ Some(ParameterValues {
values: [
("prompt-cache-memory-mib", 0),
("total-context", total_context),
@@ -4905,9 +4905,9 @@ async fn profile_compatibility_sizes_a_scenario_against_the_simulated_target_mem
("context-per-request", context_per_request),
]
.into_iter()
- .map(|(parameter_id, value)| EngineParameterValue {
+ .map(|(parameter_id, value)| ParameterValue {
parameter_id: parameter_id.to_owned(),
- value: Some(engine_parameter_value::Value::UnsignedInteger(value)),
+ value: Some(parameter_value::Value::UnsignedInteger(value)),
})
.collect(),
}),
@@ -5198,9 +5198,9 @@ async fn rejected_local_configurations_return_their_reason_through_the_model_ser
.values;
for (parameter_id, value) in values {
parameters.retain(|parameter| parameter.parameter_id != *parameter_id);
- parameters.push(EngineParameterValue {
+ parameters.push(ParameterValue {
parameter_id: (*parameter_id).to_owned(),
- value: Some(engine_parameter_value::Value::UnsignedInteger(*value)),
+ value: Some(parameter_value::Value::UnsignedInteger(*value)),
});
}
CreateLocalModelDeploymentRequest {
diff --git a/backend/core/tests/backend/catalog_contracts.rs b/backend/core/tests/backend/catalog_contracts.rs
index 1d8e1e38..ccfad32c 100644
--- a/backend/core/tests/backend/catalog_contracts.rs
+++ b/backend/core/tests/backend/catalog_contracts.rs
@@ -30,9 +30,9 @@ use dotcore_proto::dotcore::{
EngineArtifactReference, EngineRelease, HardwareSupport, HuggingFaceSnapshotArtifact,
HuggingFaceSnapshotFile, IntelligenceScore, License, ListEngineReleasesRequest,
ListModelsRequest, ListUpstreamModelTemplatesRequest, Model, NixSystemStorePath,
- OciPlatform, Selector, UpstreamModelTemplate, UpstreamWireProtocol,
- engine_artifact::Kind as EngineArtifactKind, engine_parameter_value,
- hugging_face_artifact_source::Content as HuggingFaceContent,
+ OciPlatform, ParameterVisibility, Selector, UpstreamModelTemplate, UpstreamWireProtocol,
+ engine_artifact::Kind as EngineArtifactKind,
+ hugging_face_artifact_source::Content as HuggingFaceContent, parameter_value,
},
deployment::v1::ArtifactPreparationPolicy,
engine::v1::EngineKind,
@@ -678,7 +678,7 @@ fn catalog_sizing_resolves_estimates_into_the_deployment_snapshot() {
.iter_mut()
.find(|default| default.parameter_id == *parameter_id)
.unwrap_or_else(|| panic!("{parameter_id} default"))
- .value = Some(engine_parameter_value::Value::UnsignedInteger(*value));
+ .value = Some(parameter_value::Value::UnsignedInteger(*value));
}
resolve_local_launch_with_placeholder_identity(
&catalog,
@@ -2127,11 +2127,11 @@ fn catalog_rejects_invalid_parameters_and_unsupported_bindings() {
let catalog = synthetic_local_catalog();
let model_id = CatalogModelId("fixture-model".to_owned());
let (target, inventory) = target_capabilities(ExecutionPlatform::linux_amd64());
- let invalid = dotcore_proto::dotcore::catalog::v1::EngineParameterValues {
- values: vec![dotcore_proto::dotcore::catalog::v1::EngineParameterValue {
+ let invalid = dotcore_proto::dotcore::catalog::v1::ParameterValues {
+ values: vec![dotcore_proto::dotcore::catalog::v1::ParameterValue {
parameter_id: "total-context".to_owned(),
value: Some(
- dotcore_proto::dotcore::catalog::v1::engine_parameter_value::Value::UnsignedInteger(
+ dotcore_proto::dotcore::catalog::v1::parameter_value::Value::UnsignedInteger(
16_777_217,
),
),
@@ -2218,8 +2218,8 @@ fn llama_cpp_launch_rejects_a_context_per_request_above_the_model_maximum() {
.iter()
.find(|value| value.parameter_id == "context-per-request")
})
- .and_then(|value| value.value),
- Some(engine_parameter_value::Value::UnsignedInteger(262_144))
+ .and_then(|value| value.value.clone()),
+ Some(parameter_value::Value::UnsignedInteger(262_144))
);
let arguments = filled
.desired_launch_plan
@@ -2424,7 +2424,7 @@ fn catalog_retains_boolean_engine_parameters_and_their_schema() {
.iter()
.find(|value| value.parameter_id == "feature-enabled")
.and_then(|value| value.value.as_ref()),
- Some(&dotcore_proto::dotcore::catalog::v1::engine_parameter_value::Value::Boolean(false))
+ Some(&dotcore_proto::dotcore::catalog::v1::parameter_value::Value::Boolean(false))
);
assert_eq!(
snapshot
@@ -2449,9 +2449,8 @@ fn catalog_rejects_mismatched_boolean_values_and_invalid_profile_exposure() {
.iter_mut()
.find(|value| value.parameter_id == "feature-enabled")
.expect("feature-enabled default")
- .value = Some(
- dotcore_proto::dotcore::catalog::v1::engine_parameter_value::Value::UnsignedInteger(1),
- );
+ .value =
+ Some(dotcore_proto::dotcore::catalog::v1::parameter_value::Value::UnsignedInteger(1));
let error = catalog_builder()
.engine_validator(&ACCEPT_ENGINE_DECLARATIONS)
.engine_release("engine.yaml", synthetic_boolean_engine_release())
@@ -2539,7 +2538,7 @@ fn lowest_total_context(catalog: &Catalog, model: &Model, profile_id: &str) -> (
use dotcore_catalog::shared_parameters::{
CONTEXT_PER_REQUEST_PARAMETER_ID, TOTAL_CONTEXT_PARAMETER_ID,
};
- use dotcore_proto::dotcore::catalog::v1::engine_parameter_definition::ParameterType;
+ use dotcore_proto::dotcore::catalog::v1::parameter_definition::ParameterType;
let profile = model
.profiles
@@ -2575,7 +2574,7 @@ fn lowest_total_context(catalog: &Catalog, model: &Model, profile_id: &str) -> (
.flat_map(|defaults| &defaults.values)
.find(|value| value.parameter_id == CONTEXT_PER_REQUEST_PARAMETER_ID)
.and_then(|value| match value.value {
- Some(engine_parameter_value::Value::UnsignedInteger(tokens)) => Some(tokens),
+ Some(parameter_value::Value::UnsignedInteger(tokens)) => Some(tokens),
_ => None,
});
let lowest = context_per_request.map_or(smallest_total, |tokens| tokens.max(smallest_total));
@@ -3022,18 +3021,16 @@ fn repository_catalog_root() -> std::path::PathBuf {
}
/// Builds unsigned engine parameter values from parameter id and value pairs.
-fn unsigned_values(
- values: &[(&str, u64)],
-) -> dotcore_proto::dotcore::catalog::v1::EngineParameterValues {
- dotcore_proto::dotcore::catalog::v1::EngineParameterValues {
+fn unsigned_values(values: &[(&str, u64)]) -> dotcore_proto::dotcore::catalog::v1::ParameterValues {
+ dotcore_proto::dotcore::catalog::v1::ParameterValues {
values: values
.iter()
- .map(|(parameter_id, value)| {
- dotcore_proto::dotcore::catalog::v1::EngineParameterValue {
+ .map(
+ |(parameter_id, value)| dotcore_proto::dotcore::catalog::v1::ParameterValue {
parameter_id: (*parameter_id).to_owned(),
- value: Some(engine_parameter_value::Value::UnsignedInteger(*value)),
- }
- })
+ value: Some(parameter_value::Value::UnsignedInteger(*value)),
+ },
+ )
.collect(),
}
}
@@ -3183,15 +3180,15 @@ fn synthetic_boolean_engine_release() -> EngineRelease {
.as_mut()
.expect("fixture schema")
.parameters
- .push(dotcore_proto::dotcore::catalog::v1::EngineParameterDefinition {
+ .push(dotcore_proto::dotcore::catalog::v1::ParameterDefinition {
parameter_id: "feature-enabled".to_owned(),
display_name: "Feature enabled".to_owned(),
parameter_type: Some(
- dotcore_proto::dotcore::catalog::v1::engine_parameter_definition::ParameterType::Boolean(
- dotcore_proto::dotcore::catalog::v1::BooleanEngineParameterSchema {},
+ dotcore_proto::dotcore::catalog::v1::parameter_definition::ParameterType::Boolean(
+ dotcore_proto::dotcore::catalog::v1::BooleanParameterSchema::default(),
),
),
- exposed: true,
+ visibility: ParameterVisibility::Advanced as i32,
description: "Enables the synthetic feature.".to_owned(),
});
engine
@@ -3205,10 +3202,10 @@ fn synthetic_boolean_model() -> Model {
.as_mut()
.expect("fixture defaults")
.values
- .push(dotcore_proto::dotcore::catalog::v1::EngineParameterValue {
+ .push(dotcore_proto::dotcore::catalog::v1::ParameterValue {
parameter_id: "feature-enabled".to_owned(),
value: Some(
- dotcore_proto::dotcore::catalog::v1::engine_parameter_value::Value::Boolean(false),
+ dotcore_proto::dotcore::catalog::v1::parameter_value::Value::Boolean(false),
),
});
profile.exposed_parameter_ids = vec!["total-context".to_owned(), "feature-enabled".to_owned()];
@@ -3225,7 +3222,7 @@ parameterSchema:
parameters:
- parameterId: "total-context"
displayName: "Total context"
- exposed: true
+ visibility: "PARAMETER_VISIBILITY_BASIC"
unsignedInteger:
minimum: "1024"
maximum: "16777216"
@@ -3233,14 +3230,14 @@ parameterSchema:
defaultValue: "32768"
- parameterId: "context-per-request"
displayName: "Context per request"
- exposed: true
+ visibility: "PARAMETER_VISIBILITY_BASIC"
unsignedInteger:
minimum: "1024"
maximum: "16777216"
unit: "tokens"
- parameterId: "parallel-requests"
displayName: "Parallel requests"
- exposed: true
+ visibility: "PARAMETER_VISIBILITY_ADVANCED"
unsignedInteger:
minimum: "1"
maximum: "64"
@@ -3248,6 +3245,7 @@ parameterSchema:
defaultValue: "4"
- parameterId: "prompt-cache-memory-mib"
displayName: "Prompt cache memory"
+ visibility: "PARAMETER_VISIBILITY_HIDDEN"
unsignedInteger:
minimum: "0"
maximum: "65536"
diff --git a/backend/core/tests/gated/apple_hardware_integration.rs b/backend/core/tests/gated/apple_hardware_integration.rs
index 06e82b13..4a21cb6c 100644
--- a/backend/core/tests/gated/apple_hardware_integration.rs
+++ b/backend/core/tests/gated/apple_hardware_integration.rs
@@ -25,7 +25,7 @@ use dotcore_node_hardware::{
hardware::{SystemHardwareSnapshotCollector, SystemNvidiaAcceleratorAdapter},
};
use dotcore_proto::dotcore::{
- catalog::v1::engine_parameter_value,
+ catalog::v1::parameter_value,
deployment::v1::accelerator_memory_observation,
model::v1::{
CreateLocalModelDeploymentRequest, CreateModelDeploymentRequest, DirectRouteExposure,
@@ -646,7 +646,7 @@ fn model_configuration() -> LocalModelDeploymentConfiguration {
.iter_mut()
.find(|value| value.parameter_id == "prompt-cache-memory-mib")
.expect("prompt cache parameter value");
- prompt_cache.value = Some(engine_parameter_value::Value::UnsignedInteger(
+ prompt_cache.value = Some(parameter_value::Value::UnsignedInteger(
PROMPT_CACHE_MEMORY_MIB,
));
configuration
diff --git a/backend/core/tests/gated/decision_requests.rs b/backend/core/tests/gated/decision_requests.rs
index 5a857b2c..294ecea0 100644
--- a/backend/core/tests/gated/decision_requests.rs
+++ b/backend/core/tests/gated/decision_requests.rs
@@ -5,7 +5,7 @@
//! and check the response structure and error shapes. Model probabilities are never asserted.
use dotcore_proto::dotcore::{
- catalog::v1::engine_parameter_value, model::v1::LocalModelDeploymentConfiguration,
+ catalog::v1::parameter_value, model::v1::LocalModelDeploymentConfiguration,
};
use serde_json::{Value, json};
@@ -39,7 +39,7 @@ pub fn decision_model_configuration() -> LocalModelDeploymentConfiguration {
.iter_mut()
.find(|value| value.parameter_id == "parallel-requests")
.expect("parallel requests parameter value")
- .value = Some(engine_parameter_value::Value::UnsignedInteger(1));
+ .value = Some(parameter_value::Value::UnsignedInteger(1));
configuration
}
diff --git a/backend/core/tests/gated/nvidia_qualification.rs b/backend/core/tests/gated/nvidia_qualification.rs
index b048dd42..a49ddc0e 100644
--- a/backend/core/tests/gated/nvidia_qualification.rs
+++ b/backend/core/tests/gated/nvidia_qualification.rs
@@ -46,8 +46,8 @@ use dotcore_node_hardware::hardware::{
use dotcore_proto::dotcore::{
capacity::v1::{MemoryPoolKind, MemorySegmentSource},
catalog::v1::{
- EngineArtifact, EngineParameterValue, engine_artifact::Kind as EngineArtifactKind,
- engine_parameter_value, nix_artifact::Provides,
+ EngineArtifact, ParameterValue, engine_artifact::Kind as EngineArtifactKind,
+ nix_artifact::Provides, parameter_value,
},
deployment::v1::{
ArtifactPreparationPolicy, ExactPlacement, NodeResolvedLaunchIdentity,
@@ -959,9 +959,9 @@ fn configuration_at(
values
.values
.retain(|value| value.parameter_id != "total-context");
- values.values.push(EngineParameterValue {
+ values.values.push(ParameterValue {
parameter_id: "total-context".to_owned(),
- value: Some(engine_parameter_value::Value::UnsignedInteger(u64::from(
+ value: Some(parameter_value::Value::UnsignedInteger(u64::from(
total_context,
))),
});
diff --git a/backend/core/tests/multi_node/remote_model_lifecycle.rs b/backend/core/tests/multi_node/remote_model_lifecycle.rs
index 07bf820a..f0aafb98 100644
--- a/backend/core/tests/multi_node/remote_model_lifecycle.rs
+++ b/backend/core/tests/multi_node/remote_model_lifecycle.rs
@@ -1645,18 +1645,19 @@ parameterSchema:
parameters:
- parameterId: "total-context"
displayName: "Total context"
- exposed: true
+ visibility: "PARAMETER_VISIBILITY_BASIC"
unsignedInteger: { minimum: "1024", maximum: "16777216", unit: "tokens", defaultValue: "32768" }
- parameterId: "context-per-request"
displayName: "Context per request"
- exposed: true
+ visibility: "PARAMETER_VISIBILITY_BASIC"
unsignedInteger: { minimum: "1024", maximum: "16777216", unit: "tokens" }
- parameterId: "parallel-requests"
displayName: "Parallel requests"
- exposed: true
+ visibility: "PARAMETER_VISIBILITY_ADVANCED"
unsignedInteger: { minimum: "1", maximum: "64", unit: "requests", defaultValue: "4" }
- parameterId: "prompt-cache-memory-mib"
displayName: "Prompt cache memory"
+ visibility: "PARAMETER_VISIBILITY_HIDDEN"
unsignedInteger: { minimum: "0", maximum: "65536", unit: "MiB" }
artifacts:
- artifactId: "fixture-dev-linux"
diff --git a/backend/core/tests/support/mod.rs b/backend/core/tests/support/mod.rs
index a38c2c73..3bdc577f 100644
--- a/backend/core/tests/support/mod.rs
+++ b/backend/core/tests/support/mod.rs
@@ -35,9 +35,7 @@ use dotcore_domain::{
use dotcore_node_hardware::MachineDescription;
use dotcore_proto::{
dotcore::{
- catalog::v1::{
- EngineParameterValue, EngineParameterValues, OciImageReference, engine_parameter_value,
- },
+ catalog::v1::{OciImageReference, ParameterValue, ParameterValues, parameter_value},
inference::v1::{ListRoutesRequest, route_service_client::RouteServiceClient},
model::v1::{
CreateLocalModelDeploymentRequest, CreateModelDeploymentRequest, DirectRouteExposure,
@@ -142,16 +140,16 @@ pub fn local_model_configuration(
) -> dotcore_proto::dotcore::model::v1::LocalModelDeploymentConfiguration {
dotcore_proto::dotcore::model::v1::LocalModelDeploymentConfiguration {
model_artifact_binding_id: binding_id.to_owned(),
- engine_parameter_values: Some(EngineParameterValues {
+ engine_parameter_values: Some(ParameterValues {
values: [
("total-context", total_context),
("prompt-cache-memory-mib", 0),
("parallel-requests", 4),
]
.into_iter()
- .map(|(parameter_id, value)| EngineParameterValue {
+ .map(|(parameter_id, value)| ParameterValue {
parameter_id: parameter_id.to_owned(),
- value: Some(engine_parameter_value::Value::UnsignedInteger(value)),
+ value: Some(parameter_value::Value::UnsignedInteger(value)),
})
.collect(),
}),
@@ -2918,18 +2916,19 @@ parameterSchema:
parameters:
- parameterId: "total-context"
displayName: "Total context"
- exposed: true
+ visibility: "PARAMETER_VISIBILITY_BASIC"
unsignedInteger: { minimum: "1024", maximum: "16777216", unit: "tokens", defaultValue: "32768" }
- parameterId: "context-per-request"
displayName: "Context per request"
- exposed: true
+ visibility: "PARAMETER_VISIBILITY_BASIC"
unsignedInteger: { minimum: "1024", maximum: "16777216", unit: "tokens" }
- parameterId: "parallel-requests"
displayName: "Parallel requests"
- exposed: true
+ visibility: "PARAMETER_VISIBILITY_ADVANCED"
unsignedInteger: { minimum: "1", maximum: "64", unit: "requests", defaultValue: "4" }
- parameterId: "prompt-cache-memory-mib"
displayName: "Prompt cache memory"
+ visibility: "PARAMETER_VISIBILITY_HIDDEN"
unsignedInteger: { minimum: "0", maximum: "65536", unit: "MiB" }
artifacts:
- artifactId: "fixture-metal"
diff --git a/backend/domain/src/test_support/model.rs b/backend/domain/src/test_support/model.rs
index acbbb95f..9d7ed1aa 100644
--- a/backend/domain/src/test_support/model.rs
+++ b/backend/domain/src/test_support/model.rs
@@ -3,12 +3,12 @@
use dotcore_proto::dotcore::{
catalog::v1::{
- EngineArtifact, EngineParameterDefinition, EngineParameterSchema, EngineParameterValue,
- EngineParameterValues, HardwareRequirements, HuggingFaceArtifactSource,
+ EngineArtifact, EngineParameterSchema, HardwareRequirements, HuggingFaceArtifactSource,
HuggingFaceFileArtifact, InferenceRequestKind, ModelArtifact, ModelExecutionCapabilities,
- ModelInputCapabilities, ModelInputModality, ModelParallelShape, OciPlatform, Selector,
- UnsignedIntegerEngineParameterSchema, engine_parameter_definition, engine_parameter_value,
- hugging_face_artifact_source,
+ ModelInputCapabilities, ModelInputModality, ModelParallelShape, OciPlatform,
+ ParameterDefinition, ParameterValue, ParameterValues, ParameterVisibility, Selector,
+ UnsignedIntegerParameterSchema, hugging_face_artifact_source, parameter_definition,
+ parameter_value,
},
deployment::v1::ArtifactPreparationPolicy,
engine::{
@@ -217,13 +217,13 @@ pub fn local_model_configuration() -> LocalModelDeploymentConfiguration {
}
}
-fn unsigned_values(values: &[(&str, u64)]) -> EngineParameterValues {
- EngineParameterValues {
+fn unsigned_values(values: &[(&str, u64)]) -> ParameterValues {
+ ParameterValues {
values: values
.iter()
- .map(|(parameter_id, value)| EngineParameterValue {
+ .map(|(parameter_id, value)| ParameterValue {
parameter_id: (*parameter_id).to_owned(),
- value: Some(engine_parameter_value::Value::UnsignedInteger(*value)),
+ value: Some(parameter_value::Value::UnsignedInteger(*value)),
})
.collect(),
}
@@ -255,19 +255,22 @@ fn unsigned_parameter(
minimum: u64,
maximum: u64,
unit: &str,
-) -> EngineParameterDefinition {
- EngineParameterDefinition {
+) -> ParameterDefinition {
+ ParameterDefinition {
parameter_id: parameter_id.to_owned(),
display_name: display_name.to_owned(),
- parameter_type: Some(engine_parameter_definition::ParameterType::UnsignedInteger(
- UnsignedIntegerEngineParameterSchema {
+ parameter_type: Some(parameter_definition::ParameterType::UnsignedInteger(
+ UnsignedIntegerParameterSchema {
minimum,
maximum,
unit: unit.to_owned(),
default_value: None,
},
)),
- exposed: true,
+ visibility: match parameter_id {
+ "total-context" | "context-per-request" => ParameterVisibility::Basic,
+ _ => ParameterVisibility::Advanced,
+ } as i32,
description: String::new(),
}
}
diff --git a/backend/fixtures/development-catalog/engines/fixture.yaml b/backend/fixtures/development-catalog/engines/fixture.yaml
index e883bed8..0a2855f2 100644
--- a/backend/fixtures/development-catalog/engines/fixture.yaml
+++ b/backend/fixtures/development-catalog/engines/fixture.yaml
@@ -6,18 +6,19 @@ parameterSchema:
parameters:
- parameterId: "total-context"
displayName: "Total context"
- exposed: true
+ visibility: "PARAMETER_VISIBILITY_BASIC"
unsignedInteger: { minimum: "1024", maximum: "16777216", unit: "tokens", defaultValue: "32768" }
- parameterId: "context-per-request"
displayName: "Context per request"
- exposed: true
+ visibility: "PARAMETER_VISIBILITY_BASIC"
unsignedInteger: { minimum: "1024", maximum: "16777216", unit: "tokens" }
- parameterId: "parallel-requests"
displayName: "Parallel requests"
- exposed: true
+ visibility: "PARAMETER_VISIBILITY_ADVANCED"
unsignedInteger: { minimum: "1", maximum: "64", unit: "requests", defaultValue: "4" }
- parameterId: "prompt-cache-memory-mib"
displayName: "Prompt cache memory"
+ visibility: "PARAMETER_VISIBILITY_HIDDEN"
unsignedInteger: { minimum: "0", maximum: "65536", unit: "MiB" }
storageBindings:
- bindingName: "compile-cache"
diff --git a/backend/fixtures/recommendation-catalog/engines/llama.yaml b/backend/fixtures/recommendation-catalog/engines/llama.yaml
index d8fedf34..8fba6adb 100644
--- a/backend/fixtures/recommendation-catalog/engines/llama.yaml
+++ b/backend/fixtures/recommendation-catalog/engines/llama.yaml
@@ -4,10 +4,10 @@ version: "1"
parameterSchema:
schemaId: "llama-v2"
parameters:
- - { parameterId: "total-context", displayName: "Total context", exposed: true, unsignedInteger: { minimum: "1024", maximum: "16777216", unit: "tokens", defaultValue: "32768" } }
- - { parameterId: "context-per-request", displayName: "Context per request", exposed: true, unsignedInteger: { minimum: "1024", maximum: "16777216", unit: "tokens" } }
- - { parameterId: "parallel-requests", displayName: "Parallel requests", exposed: true, unsignedInteger: { minimum: "1", maximum: "64", unit: "requests", defaultValue: "4" } }
- - { parameterId: "prompt-cache-memory-mib", displayName: "Prompt cache memory", unsignedInteger: { minimum: "0", maximum: "65536", unit: "MiB" } }
+ - { parameterId: "total-context", displayName: "Total context", visibility: "PARAMETER_VISIBILITY_BASIC", unsignedInteger: { minimum: "1024", maximum: "16777216", unit: "tokens", defaultValue: "32768" } }
+ - { parameterId: "context-per-request", displayName: "Context per request", visibility: "PARAMETER_VISIBILITY_BASIC", unsignedInteger: { minimum: "1024", maximum: "16777216", unit: "tokens" } }
+ - { parameterId: "parallel-requests", displayName: "Parallel requests", visibility: "PARAMETER_VISIBILITY_ADVANCED", unsignedInteger: { minimum: "1", maximum: "64", unit: "requests", defaultValue: "4" } }
+ - { parameterId: "prompt-cache-memory-mib", displayName: "Prompt cache memory", visibility: "PARAMETER_VISIBILITY_HIDDEN", unsignedInteger: { minimum: "0", maximum: "65536", unit: "MiB" } }
artifacts:
- artifactId: "linux-amd64"
oci:
diff --git a/backend/fixtures/simulated-docker-site/catalog/engines/fixture.yaml b/backend/fixtures/simulated-docker-site/catalog/engines/fixture.yaml
index c71c03dc..7d8a5cb4 100644
--- a/backend/fixtures/simulated-docker-site/catalog/engines/fixture.yaml
+++ b/backend/fixtures/simulated-docker-site/catalog/engines/fixture.yaml
@@ -6,18 +6,19 @@ parameterSchema:
parameters:
- parameterId: "total-context"
displayName: "Total context"
- exposed: true
+ visibility: "PARAMETER_VISIBILITY_BASIC"
unsignedInteger: { minimum: "1024", maximum: "16777216", unit: "tokens", defaultValue: "32768" }
- parameterId: "context-per-request"
displayName: "Context per request"
- exposed: true
+ visibility: "PARAMETER_VISIBILITY_BASIC"
unsignedInteger: { minimum: "1024", maximum: "16777216", unit: "tokens" }
- parameterId: "parallel-requests"
displayName: "Parallel requests"
- exposed: true
+ visibility: "PARAMETER_VISIBILITY_ADVANCED"
unsignedInteger: { minimum: "1", maximum: "64", unit: "requests", defaultValue: "4" }
- parameterId: "prompt-cache-memory-mib"
displayName: "Prompt cache memory"
+ visibility: "PARAMETER_VISIBILITY_HIDDEN"
unsignedInteger: { minimum: "0", maximum: "65536", unit: "MiB" }
artifacts:
- artifactId: "fixture-linux"
diff --git a/backend/fixtures/simulated-site/catalog/engines/fixture.yaml b/backend/fixtures/simulated-site/catalog/engines/fixture.yaml
index c71c03dc..7d8a5cb4 100644
--- a/backend/fixtures/simulated-site/catalog/engines/fixture.yaml
+++ b/backend/fixtures/simulated-site/catalog/engines/fixture.yaml
@@ -6,18 +6,19 @@ parameterSchema:
parameters:
- parameterId: "total-context"
displayName: "Total context"
- exposed: true
+ visibility: "PARAMETER_VISIBILITY_BASIC"
unsignedInteger: { minimum: "1024", maximum: "16777216", unit: "tokens", defaultValue: "32768" }
- parameterId: "context-per-request"
displayName: "Context per request"
- exposed: true
+ visibility: "PARAMETER_VISIBILITY_BASIC"
unsignedInteger: { minimum: "1024", maximum: "16777216", unit: "tokens" }
- parameterId: "parallel-requests"
displayName: "Parallel requests"
- exposed: true
+ visibility: "PARAMETER_VISIBILITY_ADVANCED"
unsignedInteger: { minimum: "1", maximum: "64", unit: "requests", defaultValue: "4" }
- parameterId: "prompt-cache-memory-mib"
displayName: "Prompt cache memory"
+ visibility: "PARAMETER_VISIBILITY_HIDDEN"
unsignedInteger: { minimum: "0", maximum: "65536", unit: "MiB" }
artifacts:
- artifactId: "fixture-linux"
diff --git a/backend/fixtures/sizing-fit/catalog/engines/llama-cpp-linux-2026-10-19.yaml b/backend/fixtures/sizing-fit/catalog/engines/llama-cpp-linux-2026-10-19.yaml
index 38aeae74..147f0e2d 100644
--- a/backend/fixtures/sizing-fit/catalog/engines/llama-cpp-linux-2026-10-19.yaml
+++ b/backend/fixtures/sizing-fit/catalog/engines/llama-cpp-linux-2026-10-19.yaml
@@ -7,18 +7,19 @@ parameterSchema:
parameters:
- parameterId: "total-context"
displayName: "Total context"
- exposed: true
+ visibility: "PARAMETER_VISIBILITY_BASIC"
unsignedInteger: { minimum: "1024", maximum: "16777216", unit: "tokens", defaultValue: "32768" }
- parameterId: "context-per-request"
displayName: "Context per request"
- exposed: true
+ visibility: "PARAMETER_VISIBILITY_BASIC"
unsignedInteger: { minimum: "1024", maximum: "16777216", unit: "tokens" }
- parameterId: "parallel-requests"
displayName: "Parallel requests"
- exposed: true
+ visibility: "PARAMETER_VISIBILITY_ADVANCED"
unsignedInteger: { minimum: "1", maximum: "64", unit: "requests", defaultValue: "1" }
- parameterId: "prompt-cache-memory-mib"
displayName: "Prompt cache memory"
+ visibility: "PARAMETER_VISIBILITY_HIDDEN"
unsignedInteger: { minimum: "0", maximum: "65536", unit: "MiB" }
artifacts:
- artifactId: "cuda13"
diff --git a/backend/proto/tests/model_contract.rs b/backend/proto/tests/model_contract.rs
index e7d72a5c..02753841 100644
--- a/backend/proto/tests/model_contract.rs
+++ b/backend/proto/tests/model_contract.rs
@@ -15,20 +15,19 @@ use dotcore_proto::{
},
catalog::v1::{
ApplicationServiceArtifact, BooleanModelOptionParameterSchema, EngineArtifact,
- EngineArtifactReference, EngineParameterDefinition, EngineParameterSchema,
- EngineParameterValue, EngineParameterValues, EngineRelease, Model, ModelArtifact,
+ EngineArtifactReference, EngineParameterSchema, EngineRelease, Model, ModelArtifact,
ModelArtifactBinding, ModelAuxiliaryArtifact, ModelConfigurationOption,
ModelEngineConfiguration, ModelExecutionCapabilities, ModelInputCapabilities,
ModelInputModality, ModelOption, ModelOptionParameterDefinition,
ModelOptionParameterValue, ModelOptionValues, ModelParallelShape, ModelProfile,
- OciImageReference, OciPlatform, Selector, StringEnumModelOptionParameterSchema,
- UnsignedIntegerEngineParameterSchema, UnsignedIntegerModelOptionParameterSchema,
- VisionModelOption, engine_artifact,
- engine_parameter_definition::ParameterType as EngineParameterType,
- engine_parameter_value::Value as EngineParameterValueKind,
- model_option::Kind as ModelOptionKind,
+ OciImageReference, OciPlatform, ParameterDefinition, ParameterValue, ParameterValues,
+ ParameterVisibility, Selector, StringEnumModelOptionParameterSchema,
+ UnsignedIntegerModelOptionParameterSchema, UnsignedIntegerParameterSchema,
+ VisionModelOption, engine_artifact, model_option::Kind as ModelOptionKind,
model_option_parameter_definition::ParameterType as ModelOptionParameterType,
model_option_parameter_value::Value as ModelOptionValueKind,
+ parameter_definition::ParameterType as EngineParameterType,
+ parameter_value::Value as EngineParameterValueKind,
},
deployment::v1::{ArtifactPreparationPolicy, MemoryFit},
engine::{
@@ -238,8 +237,8 @@ fn generated_bindings_preserve_typed_status_and_byte_progress() {
#[test]
fn generated_bindings_preserve_exact_ordered_custom_argument_tokens_in_preview_and_snapshot() {
- let context_values = EngineParameterValues {
- values: vec![EngineParameterValue {
+ let context_values = ParameterValues {
+ values: vec![ParameterValue {
parameter_id: "total-context".to_owned(),
value: Some(EngineParameterValueKind::UnsignedInteger(30_000)),
}],
@@ -337,18 +336,18 @@ fn generated_bindings_preserve_exact_ordered_custom_argument_tokens_in_preview_a
engine_parameter_schema_id: "llama-cpp-b10156-v1".to_owned(),
engine_parameter_schema: Some(EngineParameterSchema {
schema_id: "llama-cpp-b10156-v1".to_owned(),
- parameters: vec![EngineParameterDefinition {
+ parameters: vec![ParameterDefinition {
parameter_id: "total-context".to_owned(),
display_name: "Total context".to_owned(),
parameter_type: Some(EngineParameterType::UnsignedInteger(
- UnsignedIntegerEngineParameterSchema {
+ UnsignedIntegerParameterSchema {
minimum: 1,
maximum: 131_072,
unit: "tokens".to_owned(),
default_value: None,
},
)),
- exposed: true,
+ visibility: ParameterVisibility::Basic as i32,
description: "Maximum prompt and generation context.".to_owned(),
}],
}),
@@ -427,8 +426,8 @@ fn generated_bindings_preserve_exact_ordered_custom_argument_tokens_in_preview_a
fn generated_bindings_preserve_minimal_generic_resource_estimates() {
let configuration = dotcore_proto::dotcore::model::v1::LocalModelDeploymentConfiguration {
model_artifact_binding_id: "q4-k-m".to_owned(),
- engine_parameter_values: Some(EngineParameterValues {
- values: vec![EngineParameterValue {
+ engine_parameter_values: Some(ParameterValues {
+ values: vec![ParameterValue {
parameter_id: "total-context".to_owned(),
value: Some(EngineParameterValueKind::UnsignedInteger(30_000)),
}],
@@ -466,8 +465,8 @@ fn generated_bindings_preserve_minimal_generic_resource_estimates() {
unit: "bytes".to_owned(),
},
],
- derived_engine_parameter_values: Some(EngineParameterValues {
- values: vec![EngineParameterValue {
+ derived_engine_parameter_values: Some(ParameterValues {
+ values: vec![ParameterValue {
parameter_id: "kv-cache-memory-bytes".to_owned(),
value: Some(EngineParameterValueKind::UnsignedInteger(5_443_884_300)),
}],
@@ -532,18 +531,18 @@ fn generated_bindings_preserve_minimal_generic_resource_estimates() {
fn generated_bindings_round_trip_model_configurations_and_typed_context_schema() {
let schema = EngineParameterSchema {
schema_id: "llama-cpp-b10156-v1".to_owned(),
- parameters: vec![EngineParameterDefinition {
+ parameters: vec![ParameterDefinition {
parameter_id: "total-context".to_owned(),
display_name: "Total context".to_owned(),
parameter_type: Some(EngineParameterType::UnsignedInteger(
- UnsignedIntegerEngineParameterSchema {
+ UnsignedIntegerParameterSchema {
minimum: 512,
maximum: 131_072,
unit: "tokens".to_owned(),
default_value: None,
},
)),
- exposed: true,
+ visibility: ParameterVisibility::Basic as i32,
description: "Maximum prompt and generation context.".to_owned(),
}],
};
@@ -581,8 +580,8 @@ fn generated_bindings_round_trip_model_configurations_and_typed_context_schema()
profile_id: "fast-start".to_owned(),
configuration_id: "cuda".to_owned(),
default_model_artifact_binding_id: "q4-k-m".to_owned(),
- engine_parameter_defaults: Some(EngineParameterValues {
- values: vec![EngineParameterValue {
+ engine_parameter_defaults: Some(ParameterValues {
+ values: vec![ParameterValue {
parameter_id: "total-context".to_owned(),
value: Some(EngineParameterValueKind::UnsignedInteger(30_000)),
}],
@@ -867,6 +866,7 @@ parameterSchema:
parameters:
- parameterId: total-context
displayName: Total context
+ visibility: "PARAMETER_VISIBILITY_BASIC"
unsignedInteger: { minimum: "1", maximum: "2", unit: tokens }
"#,
)
@@ -928,6 +928,7 @@ fn model_descriptors_define_typed_configuration_and_resolved_plan_contracts() {
.expect("the generated descriptor set must decode");
let application = descriptor_file(&descriptor, "dotcore/catalog/v1/application.proto");
let engine = descriptor_file(&descriptor, "dotcore/catalog/v1/engine_release.proto");
+ let parameter = descriptor_file(&descriptor, "dotcore/catalog/v1/parameter.proto");
let nix = descriptor_file(&descriptor, "dotcore/catalog/v1/nix.proto");
let selector = descriptor_file(&descriptor, "dotcore/catalog/v1/selector.proto");
let catalog = descriptor_file(&descriptor, "dotcore/catalog/v1/model.proto");
@@ -996,34 +997,40 @@ fn model_descriptors_define_typed_configuration_and_resolved_plan_contracts() {
field_layout(engine, "EngineParameterSchema"),
[
("schema_id", 1, "TYPE_STRING"),
- (
- "parameters",
- 2,
- ".dotcore.catalog.v1.EngineParameterDefinition"
- ),
+ ("parameters", 2, ".dotcore.catalog.v1.ParameterDefinition"),
]
);
assert_eq!(
- field_layout(engine, "EngineParameterDefinition"),
+ field_layout(parameter, "ParameterDefinition"),
[
("parameter_id", 1, "TYPE_STRING"),
("display_name", 2, "TYPE_STRING"),
+ ("description", 3, "TYPE_STRING"),
+ ("visibility", 7, ".dotcore.catalog.v1.ParameterVisibility"),
+ ("boolean", 4, ".dotcore.catalog.v1.BooleanParameterSchema"),
(
"unsigned_integer",
- 3,
- ".dotcore.catalog.v1.UnsignedIntegerEngineParameterSchema"
+ 5,
+ ".dotcore.catalog.v1.UnsignedIntegerParameterSchema"
),
(
- "boolean",
- 4,
- ".dotcore.catalog.v1.BooleanEngineParameterSchema",
+ "string_enum",
+ 6,
+ ".dotcore.catalog.v1.StringEnumParameterSchema"
),
- ("exposed", 5, "TYPE_BOOL"),
- ("description", 6, "TYPE_STRING"),
]
);
assert_eq!(
- field_numbers(engine, "UnsignedIntegerEngineParameterSchema"),
+ enum_values(parameter, "ParameterVisibility"),
+ [
+ ("PARAMETER_VISIBILITY_UNSPECIFIED", 0),
+ ("PARAMETER_VISIBILITY_BASIC", 1),
+ ("PARAMETER_VISIBILITY_ADVANCED", 2),
+ ("PARAMETER_VISIBILITY_HIDDEN", 3),
+ ]
+ );
+ assert_eq!(
+ field_numbers(parameter, "UnsignedIntegerParameterSchema"),
[
("minimum", 1),
("maximum", 2),
@@ -1032,11 +1039,16 @@ fn model_descriptors_define_typed_configuration_and_resolved_plan_contracts() {
]
);
assert_eq!(
- field_layout(engine, "EngineParameterValue"),
+ field_layout(parameter, "ParameterValues"),
+ [("values", 1, ".dotcore.catalog.v1.ParameterValue")]
+ );
+ assert_eq!(
+ field_layout(parameter, "ParameterValue"),
[
("parameter_id", 1, "TYPE_STRING"),
- ("unsigned_integer", 2, "TYPE_UINT64"),
- ("boolean", 3, "TYPE_BOOL"),
+ ("boolean", 2, "TYPE_BOOL"),
+ ("unsigned_integer", 3, "TYPE_UINT64"),
+ ("string", 4, "TYPE_STRING"),
]
);
@@ -1439,7 +1451,7 @@ fn model_descriptors_define_typed_configuration_and_resolved_plan_contracts() {
(
"engine_parameter_values",
3,
- ".dotcore.catalog.v1.EngineParameterValues"
+ ".dotcore.catalog.v1.ParameterValues"
),
(
"hardware_class_estimates",
diff --git a/backend/proto/tests/site_contract.rs b/backend/proto/tests/site_contract.rs
index 5671f938..611ce75a 100644
--- a/backend/proto/tests/site_contract.rs
+++ b/backend/proto/tests/site_contract.rs
@@ -19,14 +19,15 @@ use dotcore_proto::{
ApplicationEndpointProtocol, ApplicationGraph, ApplicationHttpReadiness,
ApplicationPrimaryEndpoint, ApplicationService, ApplicationServiceArtifact,
ApplicationServiceDependency, ApplicationServicePort, ApplicationServiceReadiness,
- EngineArtifact, EngineParameterDefinition, EngineParameterSchema, EngineParameterValue,
- EngineParameterValues, HuggingFaceArtifactSource, HuggingFaceFileArtifact,
- ModelArtifact, ModelExecutionCapabilities, ModelInputCapabilities, ModelInputModality,
- ModelParallelShape, OciDigest, OciImageReference, OciPlatform, OciRegistryLocation,
- OciRepository, Selector, StorageBinding, StorageEnvironmentVariable, StorageKind,
- StorageScope, UnsignedIntegerEngineParameterSchema, application_configuration_binding,
+ EngineArtifact, EngineParameterSchema, HuggingFaceArtifactSource,
+ HuggingFaceFileArtifact, ModelArtifact, ModelExecutionCapabilities,
+ ModelInputCapabilities, ModelInputModality, ModelParallelShape, OciDigest,
+ OciImageReference, OciPlatform, OciRegistryLocation, OciRepository,
+ ParameterDefinition, ParameterValue, ParameterValues, ParameterVisibility, Selector,
+ StorageBinding, StorageEnvironmentVariable, StorageKind, StorageScope,
+ UnsignedIntegerParameterSchema, application_configuration_binding,
application_service_artifact, application_service_readiness, engine_artifact,
- engine_parameter_definition, engine_parameter_value, hugging_face_artifact_source,
+ hugging_face_artifact_source, parameter_definition, parameter_value,
},
deployment::v1::{ArtifactPreparationPolicy, NodeResolvedLaunchIdentity},
engine::{
@@ -450,21 +451,21 @@ fn complete_model_snapshot() -> ModelDeploymentSnapshot {
supported_modalities: vec![ModelInputModality::Text as i32],
image_input_capacity: None,
}),
- engine_parameter_values: Some(EngineParameterValues {
- values: vec![EngineParameterValue {
+ engine_parameter_values: Some(ParameterValues {
+ values: vec![ParameterValue {
parameter_id: "total-context".to_owned(),
- value: Some(engine_parameter_value::Value::UnsignedInteger(8_192)),
+ value: Some(parameter_value::Value::UnsignedInteger(8_192)),
}],
}),
engine_parameter_schema_id: "schema-one".to_owned(),
engine_parameter_schema: Some(EngineParameterSchema {
schema_id: "schema-one".to_owned(),
- parameters: vec![EngineParameterDefinition {
+ parameters: vec![ParameterDefinition {
parameter_id: "total-context".to_owned(),
display_name: "Total context".to_owned(),
parameter_type: Some(
- engine_parameter_definition::ParameterType::UnsignedInteger(
- UnsignedIntegerEngineParameterSchema {
+ parameter_definition::ParameterType::UnsignedInteger(
+ UnsignedIntegerParameterSchema {
minimum: 1,
maximum: 131_072,
unit: "tokens".to_owned(),
@@ -472,7 +473,7 @@ fn complete_model_snapshot() -> ModelDeploymentSnapshot {
},
),
),
- exposed: true,
+ visibility: ParameterVisibility::Basic as i32,
description: "Maximum prompt and generation context.".to_owned(),
}],
}),
From f40398bbc25d7bf7dc7031084fc54039103c9bd4 Mon Sep 17 00:00:00 2001
From: Andre-Philippe Paquet
Date: Thu, 12 Nov 2026 13:38:15 -0500
Subject: [PATCH 03/40] unreviewed: refactor(catalog): declare engine parameter
visibility
---
catalog/applications/comfyui.yaml | 15 +++++++--------
catalog/engines/llama-cpp-macos.yaml | 8 ++++----
catalog/engines/llama-cpp.yaml | 8 ++++----
catalog/engines/ninfer.yaml | 8 ++++----
catalog/engines/strata.yaml | 8 ++++----
catalog/engines/vllm.yaml | 14 +++++++-------
6 files changed, 30 insertions(+), 31 deletions(-)
diff --git a/catalog/applications/comfyui.yaml b/catalog/applications/comfyui.yaml
index be1cf7ec..f9a6a363 100644
--- a/catalog/applications/comfyui.yaml
+++ b/catalog/applications/comfyui.yaml
@@ -12,14 +12,13 @@ notes: |-
version: "0.42.5"
graph:
parameters:
- - advanced: true
- definition:
- parameterId: "vram-mode"
- displayName: "VRAM mode"
- description: "How ComfyUI keeps models in GPU memory. Auto loads and offloads on demand; modes other than auto need a GPU target."
- stringEnum:
- values: ["auto", "highvram", "gpu-only", "novram"]
- defaultValue: "auto"
+ - parameterId: "vram-mode"
+ displayName: "VRAM mode"
+ visibility: "PARAMETER_VISIBILITY_ADVANCED"
+ description: "How ComfyUI keeps models in GPU memory. Auto loads and offloads on demand; modes other than auto need a GPU target."
+ stringEnum:
+ values: ["auto", "highvram", "gpu-only", "novram"]
+ defaultValue: "auto"
services:
- serviceName: "comfyui"
# The image and its digests are pinned by `apps/comfyui/docker-image.nix`.
diff --git a/catalog/engines/llama-cpp-macos.yaml b/catalog/engines/llama-cpp-macos.yaml
index 0e96f028..63520d32 100644
--- a/catalog/engines/llama-cpp-macos.yaml
+++ b/catalog/engines/llama-cpp-macos.yaml
@@ -11,7 +11,7 @@ parameterSchema:
# Shared parameters: the engine adapter in backend/core/src/model/engine/ maps them to flags.
- parameterId: "total-context"
displayName: "Total context"
- exposed: true
+ visibility: "PARAMETER_VISIBILITY_BASIC"
description: "Context memory the server holds from the start and shares among the requests it generates at once. Each active request stores its own tokens in it, even when requests start with the same prompt. When it fills, the server frees idle requests first, then fails the active ones."
unsignedInteger:
minimum: "1024"
@@ -20,7 +20,7 @@ parameterSchema:
defaultValue: "32768"
- parameterId: "context-per-request"
displayName: "Context per request"
- exposed: true
+ visibility: "PARAMETER_VISIBILITY_BASIC"
description: "Largest context one request may use from the total context. It reserves nothing: requests take memory from the total context as they grow. A new request reuses the start of an earlier prompt from an idle request or the prompt cache, but not from requests running at the same time."
unsignedInteger:
minimum: "1024"
@@ -28,7 +28,7 @@ parameterSchema:
unit: "tokens"
- parameterId: "parallel-requests"
displayName: "Parallel requests"
- exposed: true
+ visibility: "PARAMETER_VISIBILITY_ADVANCED"
description: "Requests the server generates at the same time; further requests wait in a queue. All of them share the total context, so each added request only adds its own small state."
unsignedInteger:
minimum: "1"
@@ -37,7 +37,7 @@ parameterSchema:
defaultValue: "4"
- parameterId: "prompt-cache-memory-mib"
displayName: "Prompt cache memory"
- exposed: true
+ visibility: "PARAMETER_VISIBILITY_ADVANCED"
description: "Memory kept to reuse repeated prompts, such as agent system prompts, without processing them again. Idle conversations move here and free their accelerator memory. It uses host memory, which unified-memory machines share with the model; 0 turns the cache off."
unsignedInteger:
minimum: "0"
diff --git a/catalog/engines/llama-cpp.yaml b/catalog/engines/llama-cpp.yaml
index 1420bc33..31b2ddb1 100644
--- a/catalog/engines/llama-cpp.yaml
+++ b/catalog/engines/llama-cpp.yaml
@@ -10,7 +10,7 @@ parameterSchema:
# Shared parameters: the engine adapter in backend/core/src/model/engine/ maps them to flags.
- parameterId: "total-context"
displayName: "Total context"
- exposed: true
+ visibility: "PARAMETER_VISIBILITY_BASIC"
description: "Context memory the server holds from the start and shares among the requests it generates at once. Each active request stores its own tokens in it, even when requests start with the same prompt. When it fills, the server frees idle requests first, then fails the active ones."
unsignedInteger:
minimum: "1024"
@@ -19,7 +19,7 @@ parameterSchema:
defaultValue: "32768"
- parameterId: "context-per-request"
displayName: "Context per request"
- exposed: true
+ visibility: "PARAMETER_VISIBILITY_BASIC"
description: "Largest context one request may use from the total context. It reserves nothing: requests take memory from the total context as they grow. A new request reuses the start of an earlier prompt from an idle request or the prompt cache, but not from requests running at the same time."
unsignedInteger:
minimum: "1024"
@@ -27,7 +27,7 @@ parameterSchema:
unit: "tokens"
- parameterId: "parallel-requests"
displayName: "Parallel requests"
- exposed: true
+ visibility: "PARAMETER_VISIBILITY_ADVANCED"
description: "Requests the server generates at the same time; further requests wait in a queue. All of them share the total context, so each added request only adds its own small state."
unsignedInteger:
minimum: "1"
@@ -36,7 +36,7 @@ parameterSchema:
defaultValue: "4"
- parameterId: "prompt-cache-memory-mib"
displayName: "Prompt cache memory"
- exposed: true
+ visibility: "PARAMETER_VISIBILITY_ADVANCED"
description: "Memory kept to reuse repeated prompts, such as agent system prompts, without processing them again. Idle conversations move here and free their accelerator memory. It uses host memory, which unified-memory machines share with the model; 0 turns the cache off."
unsignedInteger:
minimum: "0"
diff --git a/catalog/engines/ninfer.yaml b/catalog/engines/ninfer.yaml
index db8acab2..45337575 100644
--- a/catalog/engines/ninfer.yaml
+++ b/catalog/engines/ninfer.yaml
@@ -10,7 +10,7 @@ parameterSchema:
# Shared parameters: the engine adapter in backend/core/src/model/engine/ maps them to flags.
- parameterId: "total-context"
displayName: "Total context"
- exposed: true
+ visibility: "PARAMETER_VISIBILITY_BASIC"
description: "Context memory the server holds from the start and shares among the requests it generates at once. Requests that start with the same prompt share its memory instead of holding their own copy. When it fills, the server first moves cached prompts to host memory, then pauses the newest requests until memory frees up."
unsignedInteger:
minimum: "1024"
@@ -19,7 +19,7 @@ parameterSchema:
defaultValue: "32768"
- parameterId: "context-per-request"
displayName: "Context per request"
- exposed: true
+ visibility: "PARAMETER_VISIBILITY_BASIC"
description: "Largest context one request may use from the total context. It reserves nothing: requests take memory from the total context as they grow. A new request reuses the memory of a matching prompt start, from a running request, an idle conversation or the host context."
unsignedInteger:
minimum: "1024"
@@ -27,7 +27,7 @@ parameterSchema:
unit: "tokens"
- parameterId: "parallel-requests"
displayName: "Parallel requests"
- exposed: true
+ visibility: "PARAMETER_VISIBILITY_ADVANCED"
description: "Requests the server generates at the same time; further requests wait in a queue, and the server refuses new ones once the queue is full. Each added request also adds its own state on the accelerator."
unsignedInteger:
minimum: "1"
@@ -36,7 +36,7 @@ parameterSchema:
defaultValue: "4"
- parameterId: "host-context-mib"
displayName: "Host context memory"
- exposed: true
+ visibility: "PARAMETER_VISIBILITY_ADVANCED"
description: "Host memory where idle conversations and paused requests are kept, so they resume without processing their prompt again. 0 turns it off."
unsignedInteger:
minimum: "0"
diff --git a/catalog/engines/strata.yaml b/catalog/engines/strata.yaml
index 007e2364..50fb61bc 100644
--- a/catalog/engines/strata.yaml
+++ b/catalog/engines/strata.yaml
@@ -10,7 +10,7 @@ parameterSchema:
# Shared parameters: the engine adapter in backend/core/src/model/engine/ maps them to flags.
- parameterId: "total-context"
displayName: "Total context"
- exposed: true
+ visibility: "PARAMETER_VISIBILITY_BASIC"
description: "Context memory the server holds from the start. Each parallel request keeps its own part of it, as large as the context per request, so it is always the parallel requests times the context per request."
unsignedInteger:
minimum: "1024"
@@ -19,7 +19,7 @@ parameterSchema:
defaultValue: "32768"
- parameterId: "context-per-request"
displayName: "Context per request"
- exposed: true
+ visibility: "PARAMETER_VISIBILITY_BASIC"
description: "Largest context one request may use. The server holds this much context for each parallel request from the start, whether a request uses it or not."
unsignedInteger:
minimum: "1024"
@@ -27,7 +27,7 @@ parameterSchema:
unit: "tokens"
- parameterId: "parallel-requests"
displayName: "Parallel requests"
- exposed: true
+ visibility: "PARAMETER_VISIBILITY_ADVANCED"
description: "Requests the server generates at the same time; further requests wait for a free one. Each one holds its own context per request."
unsignedInteger:
minimum: "1"
@@ -36,7 +36,7 @@ parameterSchema:
defaultValue: "1"
- parameterId: "expert-vram-mib"
displayName: "Expert accelerator memory"
- exposed: true
+ visibility: "PARAMETER_VISIBILITY_ADVANCED"
description: "Accelerator memory the model's experts may use; the experts that don't fit stay in host memory. The model's sizing sets it unless you do."
unsignedInteger:
minimum: "1"
diff --git a/catalog/engines/vllm.yaml b/catalog/engines/vllm.yaml
index 7a0d09e9..ce4dfe10 100644
--- a/catalog/engines/vllm.yaml
+++ b/catalog/engines/vllm.yaml
@@ -10,7 +10,7 @@ parameterSchema:
# Shared parameters: the engine adapter in backend/core/src/model/engine/ maps them to flags.
- parameterId: "total-context"
displayName: "Total context"
- exposed: true
+ visibility: "PARAMETER_VISIBILITY_BASIC"
description: "Context memory the server holds from the start and shares among the requests it generates at once. Requests that start with the same prompt share its memory instead of holding their own copy. When it fills, the server pauses the newest requests and processes them again once memory frees up."
unsignedInteger:
minimum: "1024"
@@ -19,7 +19,7 @@ parameterSchema:
defaultValue: "32768"
- parameterId: "context-per-request"
displayName: "Context per request"
- exposed: true
+ visibility: "PARAMETER_VISIBILITY_BASIC"
description: "Largest context one request may use from the total context. It reserves nothing: requests take memory from the total context as they grow. A new request reuses the memory of a matching prompt start, from a running request, a finished one still held in the total context, or the KV offloading memory."
unsignedInteger:
minimum: "1024"
@@ -27,7 +27,7 @@ parameterSchema:
unit: "tokens"
- parameterId: "parallel-requests"
displayName: "Parallel requests"
- exposed: true
+ visibility: "PARAMETER_VISIBILITY_ADVANCED"
description: "Requests the server generates at the same time; further requests wait in a queue. They share the total context, and each one also holds model state of its own, so every added request adds memory."
unsignedInteger:
minimum: "1"
@@ -36,7 +36,7 @@ parameterSchema:
defaultValue: "4"
- parameterId: "maximum-batched-tokens"
displayName: "Maximum batched tokens"
- exposed: false
+ visibility: "PARAMETER_VISIBILITY_HIDDEN"
description: "Maximum tokens processed across one scheduled batch."
unsignedInteger:
minimum: "1"
@@ -44,7 +44,7 @@ parameterSchema:
unit: "tokens"
- parameterId: "maximum-cudagraph-capture-size"
displayName: "Maximum CUDA graph capture size"
- exposed: false
+ visibility: "PARAMETER_VISIBILITY_HIDDEN"
description: "Largest sequence batch captured in a CUDA graph."
unsignedInteger:
minimum: "1"
@@ -52,7 +52,7 @@ parameterSchema:
unit: "sequences"
- parameterId: "kv-cache-memory-bytes"
displayName: "KV cache memory"
- exposed: false
+ visibility: "PARAMETER_VISIBILITY_HIDDEN"
description: "Accelerator memory reserved for the total context, in bytes."
unsignedInteger:
minimum: "1"
@@ -60,7 +60,7 @@ parameterSchema:
unit: "bytes"
- parameterId: "kv-offloading-size-gib"
displayName: "KV offloading size"
- exposed: true
+ visibility: "PARAMETER_VISIBILITY_ADVANCED"
description: "Host memory where context evicted from the total context is kept, so a later request with the same prompt start reuses it instead of processing it again, in GiB. 0 turns it off."
unsignedInteger:
minimum: "0"
From 5a800b3afe70a24e6fc674da647fe92906c5d230 Mon Sep 17 00:00:00 2001
From: Andre-Philippe Paquet
Date: Thu, 12 Nov 2026 13:40:27 -0500
Subject: [PATCH 04/40] unreviewed: refactor(frontend): read engine parameters
through the shared definition
---
.../application-deploy-dialog.test.tsx | 39 ++++----
.../application-inspector.test.tsx | 24 +++--
.../applications/application-parameters.ts | 21 +++--
.../catalog/catalog-table-test-support.ts | 6 +-
.../catalog/model-catalog-page.test.tsx | 3 +-
.../deploy/model-deploy-context-step.tsx | 16 ++--
.../deploy/model-deploy-dialog.test.tsx | 6 +-
.../models/deploy/model-deploy-launch.ts | 12 +--
.../deploy/model-deploy-runtime-step.tsx | 10 +-
.../deploy/model-deploy-scenario.test.tsx | 6 +-
.../models/deploy/use-model-deploy-form.ts | 21 ++---
.../deploy/use-model-deploy-memory-preview.ts | 13 +--
.../deploy/use-resource-estimate.test.tsx | 4 +-
.../models/inspector/deployment-overview.tsx | 4 +-
.../models/inspector/model-inspector.test.tsx | 8 +-
.../src/features/models/model-test-support.ts | 9 +-
.../models/profiles/profile-compatibility.ts | 4 +-
.../models/runtime/context-size-field.tsx | 8 +-
.../runtime/engine-parameter-values.test.ts | 77 +++++++++++++++-
.../models/runtime/engine-parameter-values.ts | 92 +++++++++++--------
.../models/runtime/engine-parameters.tsx | 17 ++--
.../runtime/local-deployment-configuration.ts | 13 +--
.../runtime/runtime-schema-test-support.ts | 3 +-
23 files changed, 243 insertions(+), 173 deletions(-)
diff --git a/frontend/src/features/applications/application-deploy-dialog.test.tsx b/frontend/src/features/applications/application-deploy-dialog.test.tsx
index f16236ed..dffc4a45 100644
--- a/frontend/src/features/applications/application-deploy-dialog.test.tsx
+++ b/frontend/src/features/applications/application-deploy-dialog.test.tsx
@@ -13,7 +13,6 @@ import {
ApplicationConfigurationBindingSchema,
ApplicationDeploymentInputSchema,
ApplicationGraphSchema,
- ApplicationParameterSchema,
ApplicationSchema,
ApplicationServiceSchema,
} from "@/gen/dotcore/catalog/v1/application_pb";
@@ -24,6 +23,7 @@ import {
import {
ParameterDefinitionSchema,
ParameterValueSchema,
+ ParameterVisibility,
} from "@/gen/dotcore/catalog/v1/parameter_pb";
import {
InferenceRouteAvailabilitySchema,
@@ -1372,27 +1372,24 @@ describe("Application deployment dialog parameters", () => {
version: "1.0.0",
graph: create(ApplicationGraphSchema, {
parameters: [
- create(ApplicationParameterSchema, {
- advanced: true,
- definition: create(ParameterDefinitionSchema, {
- parameterId: "vram-mode",
- displayName: "VRAM mode",
- description: "How models stay in GPU memory.",
- parameterType: {
- case: "stringEnum",
- value: { values: ["auto", "highvram"], defaultValue: "auto" },
- },
- }),
+ create(ParameterDefinitionSchema, {
+ parameterId: "vram-mode",
+ displayName: "VRAM mode",
+ description: "How models stay in GPU memory.",
+ visibility: ParameterVisibility.ADVANCED,
+ parameterType: {
+ case: "stringEnum",
+ value: { values: ["auto", "highvram"], defaultValue: "auto" },
+ },
}),
- create(ApplicationParameterSchema, {
- definition: create(ParameterDefinitionSchema, {
- parameterId: "workers",
- displayName: "Workers",
- parameterType: {
- case: "unsignedInteger",
- value: { minimum: 1n, maximum: 8n, unit: "", defaultValue: 2n },
- },
- }),
+ create(ParameterDefinitionSchema, {
+ parameterId: "workers",
+ displayName: "Workers",
+ visibility: ParameterVisibility.BASIC,
+ parameterType: {
+ case: "unsignedInteger",
+ value: { minimum: 1n, maximum: 8n, unit: "", defaultValue: 2n },
+ },
}),
],
}),
diff --git a/frontend/src/features/applications/application-inspector.test.tsx b/frontend/src/features/applications/application-inspector.test.tsx
index 3489016c..d352ed88 100644
--- a/frontend/src/features/applications/application-inspector.test.tsx
+++ b/frontend/src/features/applications/application-inspector.test.tsx
@@ -26,6 +26,7 @@ import {
ApplicationSchema,
ApplicationServiceSchema,
} from "@/gen/dotcore/catalog/v1/application_pb";
+import { ParameterVisibility } from "@/gen/dotcore/catalog/v1/parameter_pb";
import {
type AcceleratorMemoryObservation,
AcceleratorMemoryObservationSchema,
@@ -516,22 +517,19 @@ describe("Application deployment dialog", () => {
graph: create(ApplicationGraphSchema, {
parameters: [
{
- advanced: true,
- definition: {
- parameterId: "vram-mode",
- displayName: "VRAM mode",
- parameterType: {
- case: "stringEnum",
- value: { values: ["auto", "highvram"], defaultValue: "auto" },
- },
+ parameterId: "vram-mode",
+ displayName: "VRAM mode",
+ visibility: ParameterVisibility.ADVANCED,
+ parameterType: {
+ case: "stringEnum",
+ value: { values: ["auto", "highvram"], defaultValue: "auto" },
},
},
{
- definition: {
- parameterId: "preview",
- displayName: "Previews",
- parameterType: { case: "boolean", value: { defaultValue: false } },
- },
+ parameterId: "preview",
+ displayName: "Previews",
+ visibility: ParameterVisibility.BASIC,
+ parameterType: { case: "boolean", value: { defaultValue: false } },
},
],
}),
diff --git a/frontend/src/features/applications/application-parameters.ts b/frontend/src/features/applications/application-parameters.ts
index 5d298a0c..aa2bb832 100644
--- a/frontend/src/features/applications/application-parameters.ts
+++ b/frontend/src/features/applications/application-parameters.ts
@@ -2,8 +2,12 @@ import { create } from "@bufbuild/protobuf";
import type { ParameterView, ParameterViewValue } from "@/components/parameters/parameter-view";
import type { ResolvedApplicationDeploymentSnapshot } from "@/gen/dotcore/application/v1/deployment_pb";
-import type { ApplicationParameter } from "@/gen/dotcore/catalog/v1/application_pb";
-import { type ParameterValue, ParameterValueSchema } from "@/gen/dotcore/catalog/v1/parameter_pb";
+import {
+ type ParameterDefinition,
+ type ParameterValue,
+ ParameterValueSchema,
+ ParameterVisibility,
+} from "@/gen/dotcore/catalog/v1/parameter_pb";
/** One application parameter as the deploy dialog shows it, with its catalog default. */
export interface ApplicationParameterField {
@@ -14,18 +18,17 @@ export interface ApplicationParameterField {
/** The application's parameters in declaration order; malformed declarations are skipped. */
export function applicationParameterFields(
- parameters: readonly ApplicationParameter[] | undefined,
+ parameters: readonly ParameterDefinition[] | undefined,
): ApplicationParameterField[] {
- return (parameters ?? []).flatMap((parameter): ApplicationParameterField[] => {
- const definition = parameter.definition;
- if (!definition) return [];
+ return (parameters ?? []).flatMap((definition): ApplicationParameterField[] => {
+ const advanced = definition.visibility === ParameterVisibility.ADVANCED;
const base = { id: definition.parameterId, label: definition.displayName };
const type = definition.parameterType;
switch (type.case) {
case "boolean":
return [
{
- advanced: parameter.advanced,
+ advanced,
defaultValue: type.value.defaultValue,
view: { ...base, hint: definition.description, type: { kind: "boolean" } },
},
@@ -35,7 +38,7 @@ export function applicationParameterFields(
const range = `${minimum}–${maximum}${unit ? ` ${unit}` : ""}`;
return [
{
- advanced: parameter.advanced,
+ advanced,
defaultValue,
view: {
...base,
@@ -48,7 +51,7 @@ export function applicationParameterFields(
case "stringEnum":
return [
{
- advanced: parameter.advanced,
+ advanced,
defaultValue: type.value.defaultValue,
view: {
...base,
diff --git a/frontend/src/features/models/catalog/catalog-table-test-support.ts b/frontend/src/features/models/catalog/catalog-table-test-support.ts
index b8093285..9bcef1ba 100644
--- a/frontend/src/features/models/catalog/catalog-table-test-support.ts
+++ b/frontend/src/features/models/catalog/catalog-table-test-support.ts
@@ -18,6 +18,7 @@ import {
ModelSchema,
SpeculativeDecodingMethod,
} from "@/gen/dotcore/catalog/v1/model_pb";
+import { ParameterVisibility } from "@/gen/dotcore/catalog/v1/parameter_pb";
import { EngineKind } from "@/gen/dotcore/engine/v1/engine_pb";
/**
@@ -95,14 +96,14 @@ function engineArtifact(artifactId: string, architecture: string) {
};
}
-// The engine exposes its total context to the profile only when `exposed` is set.
+// The engine shows its total context to the profile only when `exposed` is set.
function contextParameterSchema(schemaId: string, exposed: boolean): ParameterSchemaInit {
return {
parameters: [
{
displayName: "Total context",
- exposed,
parameterId: "total-context",
+ visibility: exposed ? ParameterVisibility.BASIC : ParameterVisibility.HIDDEN,
parameterType: {
case: "unsignedInteger" as const,
value: { maximum: 262144n, minimum: 1024n, unit: "tokens" },
@@ -111,6 +112,7 @@ function contextParameterSchema(schemaId: string, exposed: boolean): ParameterSc
{
displayName: "Prefix caching",
parameterId: "prefix-caching",
+ visibility: ParameterVisibility.HIDDEN,
parameterType: { case: "boolean" as const, value: {} },
},
],
diff --git a/frontend/src/features/models/catalog/model-catalog-page.test.tsx b/frontend/src/features/models/catalog/model-catalog-page.test.tsx
index 0af1eb25..85ccda91 100644
--- a/frontend/src/features/models/catalog/model-catalog-page.test.tsx
+++ b/frontend/src/features/models/catalog/model-catalog-page.test.tsx
@@ -24,6 +24,7 @@ import {
driverTooOld,
targetCompatibility,
} from "@/features/models/profiles/profile-compatibility-test-support";
+import { ParameterVisibility } from "@/gen/dotcore/catalog/v1/parameter_pb";
import {
ModelDeploymentIntentSchema,
ModelDeploymentSchema,
@@ -362,7 +363,7 @@ describe("ModelCatalogPage deploy", () => {
it("shows the service's reason when it rejects the total context", async () => {
const user = setupUser();
const release = llamaRelease();
- release.parameterSchema!.parameters[0].exposed = true;
+ release.parameterSchema!.parameters[0].visibility = ParameterVisibility.BASIC;
const { client } = renderModelsSection({
engineReleases: [release],
initialEntry: "/models/catalog",
diff --git a/frontend/src/features/models/deploy/model-deploy-context-step.tsx b/frontend/src/features/models/deploy/model-deploy-context-step.tsx
index 65eb31a0..a33db7bb 100644
--- a/frontend/src/features/models/deploy/model-deploy-context-step.tsx
+++ b/frontend/src/features/models/deploy/model-deploy-context-step.tsx
@@ -15,8 +15,8 @@ import {
maximumContext,
runtimeParameterGroups,
} from "@/features/models/runtime/engine-parameter-values";
-import type { EngineParameterDefinition } from "@/gen/dotcore/catalog/v1/engine_release_pb";
import type { Model, ModelProfile } from "@/gen/dotcore/catalog/v1/model_pb";
+import type { ParameterDefinition } from "@/gen/dotcore/catalog/v1/parameter_pb";
import type { FieldErrors } from "@/lib/form-validation";
import { formatCompactCount } from "@/lib/format";
@@ -25,7 +25,7 @@ interface ModelDeployContextStepProps {
contextHint?: string;
disabled: boolean;
engineParameterRefs: RefObject>;
- exposedParameters: readonly EngineParameterDefinition[];
+ exposedParameters: readonly ParameterDefinition[];
fieldErrors: FieldErrors;
/** The context per request the backend fills while the input is empty, from the latest estimate. */
filledContextPerRequest?: number;
@@ -33,13 +33,9 @@ interface ModelDeployContextStepProps {
/** The largest total context that fits the selected target, offered as the total's last chip. */
largestFit?: ContextSizeLargestFit;
model: Model;
- onParameterBlur: (parameter: EngineParameterDefinition) => void;
+ onParameterBlur: (parameter: ParameterDefinition) => void;
/** Receives each change, and whether it is settled, as a slider or preset is, or typed. */
- onParameterChange: (
- parameter: EngineParameterDefinition,
- input: string,
- settled?: boolean,
- ) => void;
+ onParameterChange: (parameter: ParameterDefinition, input: string, settled?: boolean) => void;
profile?: ModelProfile;
}
@@ -73,7 +69,7 @@ export function ModelDeployContextStep({
// Total context and context per request follow each other, so their sliders share one scale.
const sliderRange = contextSliderRange([totalContext, contextPerRequest]);
const contextField = (
- parameter: EngineParameterDefinition,
+ parameter: ParameterDefinition,
field: {
emptyValue?: number;
help?: ReactNode;
@@ -137,7 +133,7 @@ export function ModelDeployContextStep({
/** The range spanning every given context size parameter's schema range, if any is present. */
function contextSliderRange(
- parameters: readonly (EngineParameterDefinition | undefined)[],
+ parameters: readonly (ParameterDefinition | undefined)[],
): ContextSizeRange | undefined {
const ranges = parameters.flatMap((parameter) =>
parameter?.parameterType.case === "unsignedInteger"
diff --git a/frontend/src/features/models/deploy/model-deploy-dialog.test.tsx b/frontend/src/features/models/deploy/model-deploy-dialog.test.tsx
index f836813b..46c66b29 100644
--- a/frontend/src/features/models/deploy/model-deploy-dialog.test.tsx
+++ b/frontend/src/features/models/deploy/model-deploy-dialog.test.tsx
@@ -44,10 +44,10 @@ import {
} from "@/features/models/runtime/runtime-schema-test-support";
import {
EngineArtifactReferenceSchema,
- EngineParameterValueSchema,
EngineReleaseSchema,
} from "@/gen/dotcore/catalog/v1/engine_release_pb";
import { ModelSchema } from "@/gen/dotcore/catalog/v1/model_pb";
+import { ParameterValueSchema } from "@/gen/dotcore/catalog/v1/parameter_pb";
import { MemoryFit } from "@/gen/dotcore/deployment/v1/memory_fit_pb";
import { ExactPlacementSchema } from "@/gen/dotcore/deployment/v1/placement_pb";
import {
@@ -1884,11 +1884,11 @@ describe("ModelDeployDialog", () => {
engineParameterValues: {
values: [
{ parameterId: "total-context", value: { case: "unsignedInteger", value: 65536n } },
- create(EngineParameterValueSchema, {
+ create(ParameterValueSchema, {
parameterId: "parallel-requests",
value: { case: "unsignedInteger", value: 4n },
}),
- create(EngineParameterValueSchema, {
+ create(ParameterValueSchema, {
parameterId: "kv-cache-memory-bytes",
value: { case: "unsignedInteger", value: 7400000000n },
}),
diff --git a/frontend/src/features/models/deploy/model-deploy-launch.ts b/frontend/src/features/models/deploy/model-deploy-launch.ts
index 9422b676..ada91e04 100644
--- a/frontend/src/features/models/deploy/model-deploy-launch.ts
+++ b/frontend/src/features/models/deploy/model-deploy-launch.ts
@@ -19,11 +19,9 @@ import {
TOTAL_CONTEXT_PARAMETER_ID,
} from "@/features/models/runtime/engine-parameter-values";
import { defaultSpeculativeChoice } from "@/features/models/runtime/speculative-choice";
-import {
- type EngineParameterValues,
- type EngineRelease,
-} from "@/gen/dotcore/catalog/v1/engine_release_pb";
+import { type EngineRelease } from "@/gen/dotcore/catalog/v1/engine_release_pb";
import { type Model, type ModelProfile } from "@/gen/dotcore/catalog/v1/model_pb";
+import type { ParameterValues } from "@/gen/dotcore/catalog/v1/parameter_pb";
import {
type ExactPlacement,
ExactPlacementSchema,
@@ -73,7 +71,7 @@ export const launchRejectionField = "launchRejection";
* context follows the starting value of the target's largest fit until chosen.
*/
export function useLaunchEngineValues(
- scenarioValues: EngineParameterValues | undefined,
+ scenarioValues: ParameterValues | undefined,
engineRelease: EngineRelease | undefined,
profile: ModelProfile | undefined,
edits: ReadonlyMap,
@@ -133,9 +131,9 @@ export function scenarioInput(parameterId: string): ScenarioInput | undefined {
}
export function withoutUnsetContextPerRequest(
- values: EngineParameterValues | undefined,
+ values: ParameterValues | undefined,
scenario: SizingScenario | undefined,
-): EngineParameterValues | undefined {
+): ParameterValues | undefined {
if (!values || scenario?.contextTokensPerRequest !== undefined) return values;
return {
diff --git a/frontend/src/features/models/deploy/model-deploy-runtime-step.tsx b/frontend/src/features/models/deploy/model-deploy-runtime-step.tsx
index 3b73b1d6..b52d5550 100644
--- a/frontend/src/features/models/deploy/model-deploy-runtime-step.tsx
+++ b/frontend/src/features/models/deploy/model-deploy-runtime-step.tsx
@@ -22,12 +22,12 @@ import type { KvCachePrecisionOption } from "@/features/models/runtime/kv-cache-
import { KvCachePrecisionField } from "@/features/models/runtime/kv-cache-precision-field";
import type { SpeculativeChoice } from "@/features/models/runtime/speculative-choice";
import { SpeculativeDecodingField } from "@/features/models/runtime/speculative-decoding-field";
-import type { EngineParameterDefinition } from "@/gen/dotcore/catalog/v1/engine_release_pb";
import type {
Model,
ModelEngineConfiguration,
ModelProfile,
} from "@/gen/dotcore/catalog/v1/model_pb";
+import type { ParameterDefinition } from "@/gen/dotcore/catalog/v1/parameter_pb";
import type { FieldErrors } from "@/lib/form-validation";
interface ModelDeployRuntimeStepProps {
@@ -45,7 +45,7 @@ interface ModelDeployRuntimeStepProps {
engineParameterRefs: RefObject>;
/** The engine releases the deployment may run: the target's selected one, or every approved one. */
engineReleases: readonly string[];
- exposedParameters: readonly EngineParameterDefinition[];
+ exposedParameters: readonly ParameterDefinition[];
fieldErrors: FieldErrors;
inputs: ReadonlyMap;
/** Warns that host KV offload shares the accelerator's memory pool on this target. */
@@ -59,8 +59,8 @@ interface ModelDeployRuntimeStepProps {
model: Model;
onArtifactBlur: () => void;
onArtifactChange: (bindingId: string) => void;
- onParameterBlur: (parameter: EngineParameterDefinition) => void;
- onParameterChange: (parameter: EngineParameterDefinition, input: string) => void;
+ onParameterBlur: (parameter: ParameterDefinition) => void;
+ onParameterChange: (parameter: ParameterDefinition, input: string) => void;
onPreview: () => void;
previewButtonRef: RefObject;
previewDisabled: boolean;
@@ -122,7 +122,7 @@ export function ModelDeployRuntimeStep({
(candidate) => candidate.artifactId === binding?.modelArtifactId,
);
const groups = runtimeParameterGroups(exposedParameters, configuration);
- const parametersForm = (parameters: readonly EngineParameterDefinition[]) => (
+ const parametersForm = (parameters: readonly ParameterDefinition[]) => (
[value.parameterId, value.value.value]);
}
diff --git a/frontend/src/features/models/deploy/use-model-deploy-form.ts b/frontend/src/features/models/deploy/use-model-deploy-form.ts
index 2881bf1d..36271e99 100644
--- a/frontend/src/features/models/deploy/use-model-deploy-form.ts
+++ b/frontend/src/features/models/deploy/use-model-deploy-form.ts
@@ -74,13 +74,10 @@ import {
speculativeOptionOverrides,
} from "@/features/models/runtime/speculative-choice";
import type { ApplicationDeployment } from "@/gen/dotcore/application/v1/deployment_pb";
-import {
- type EngineParameterDefinition,
- type EngineParameterValues,
- type EngineRelease,
-} from "@/gen/dotcore/catalog/v1/engine_release_pb";
+import { type EngineRelease } from "@/gen/dotcore/catalog/v1/engine_release_pb";
import type { HardwareClass } from "@/gen/dotcore/catalog/v1/hardware_class_pb";
import type { Model } from "@/gen/dotcore/catalog/v1/model_pb";
+import type { ParameterDefinition, ParameterValues } from "@/gen/dotcore/catalog/v1/parameter_pb";
import type { ExactPlacement } from "@/gen/dotcore/deployment/v1/placement_pb";
import {
type ModelDeployment,
@@ -287,9 +284,7 @@ export function useModelDeployForm({
: withoutUnsetContextPerRequest(defaultReadValues, undefined);
// The values of a profile's first read under each scenario stay for the dialog's life, so later
// reads never swap them under the operator; a new "Sized for" starts from its own read.
- const [frozenValues, setFrozenValues] = useState>(
- new Map(),
- );
+ const [frozenValues, setFrozenValues] = useState>(new Map());
const frozenKey = profile ? `${profile.profileId}\0${sizingScenarioKey(scenario)}` : undefined;
if (frozenKey && readValues && !frozenValues.has(frozenKey))
setFrozenValues(new Map(frozenValues).set(frozenKey, readValues));
@@ -538,11 +533,7 @@ export function useModelDeployForm({
// chosen ones, starting from what the step shows. A settled change, from a slider or preset, also
// moves the other of the two to keep the edited one, and sizes "Sized for" when valid; typing
// waits until the input is left.
- const changeContext = (
- parameter: EngineParameterDefinition,
- nextInput: string,
- settled?: boolean,
- ) => {
+ const changeContext = (parameter: ParameterDefinition, nextInput: string, settled?: boolean) => {
const edited = scenarioInput(parameter.parameterId);
if (!edited) return;
@@ -579,7 +570,7 @@ export function useModelDeployForm({
invalidatePreview();
};
// A typed context settles once left, as a settled change does.
- const blurContext = (parameter: EngineParameterDefinition) => {
+ const blurContext = (parameter: ParameterDefinition) => {
if (!dirtyFields.isDirty(parameter.parameterId)) return;
const edited = scenarioInput(parameter.parameterId);
if (edited) setContext((previous) => settledContext(previous, edited));
@@ -605,7 +596,7 @@ export function useModelDeployForm({
const validateRoute = (values = exposureValues, deploymentName = name): string | undefined =>
validateDeploymentExposure(values, deploymentName, existingRouteNames);
const parameterErrors = (
- parameters: readonly EngineParameterDefinition[],
+ parameters: readonly ParameterDefinition[],
): [string, string | undefined][] =>
parameters.map((parameter) => [
parameter.parameterId,
diff --git a/frontend/src/features/models/deploy/use-model-deploy-memory-preview.ts b/frontend/src/features/models/deploy/use-model-deploy-memory-preview.ts
index d4be3eb2..c24bb9f7 100644
--- a/frontend/src/features/models/deploy/use-model-deploy-memory-preview.ts
+++ b/frontend/src/features/models/deploy/use-model-deploy-memory-preview.ts
@@ -20,11 +20,7 @@ import {
speculativeOptionOverrides,
} from "@/features/models/runtime/speculative-choice";
import type { ApplicationDeployment } from "@/gen/dotcore/application/v1/deployment_pb";
-import {
- type EngineParameterDefinition,
- type EngineParameterValues,
- type EngineRelease,
-} from "@/gen/dotcore/catalog/v1/engine_release_pb";
+import { type EngineRelease } from "@/gen/dotcore/catalog/v1/engine_release_pb";
import type {
Model,
ModelArtifactBinding,
@@ -32,6 +28,7 @@ import type {
ModelOptionValues,
ModelProfile,
} from "@/gen/dotcore/catalog/v1/model_pb";
+import type { ParameterDefinition, ParameterValues } from "@/gen/dotcore/catalog/v1/parameter_pb";
import type { ExactPlacement } from "@/gen/dotcore/deployment/v1/placement_pb";
import type { ModelDeployment } from "@/gen/dotcore/model/v1/deployment_pb";
import { LocalModelDeploymentAdvisory } from "@/gen/dotcore/model/v1/service_pb";
@@ -276,14 +273,14 @@ export interface ModelDeployMemoryPreviewOptions {
compatibilityRead?: ProfileCompatibility;
configuration?: ModelEngineConfiguration;
customArgumentTokens: string[];
- declaredValues?: EngineParameterValues;
+ declaredValues?: ParameterValues;
deployments?: readonly ModelDeployment[];
/** Parameters catalog sizing derives for the profile, which an empty input leaves to it. */
derivedParameterIds: ReadonlySet;
engineParameterInputs: ReadonlyMap;
engineRelease?: EngineRelease;
engineReleases: readonly EngineRelease[];
- exposedParameters: readonly EngineParameterDefinition[];
+ exposedParameters: readonly ParameterDefinition[];
launchValuesPending: boolean;
model: Model;
name: string;
@@ -298,7 +295,7 @@ export interface ModelDeployMemoryPreviewOptions {
storedSpeculativeChoice: SpeculativeChoice;
targetsLoading: boolean;
targetsLoadingPresentation: boolean;
- values?: EngineParameterValues;
+ values?: ParameterValues;
}
export interface LocalMemoryPreviewValues extends LocalResourceEstimateValues {
diff --git a/frontend/src/features/models/deploy/use-resource-estimate.test.tsx b/frontend/src/features/models/deploy/use-resource-estimate.test.tsx
index 5dd16c2c..852a1145 100644
--- a/frontend/src/features/models/deploy/use-resource-estimate.test.tsx
+++ b/frontend/src/features/models/deploy/use-resource-estimate.test.tsx
@@ -6,7 +6,7 @@ import {
dedicatedPreview,
gib,
} from "@/features/models/deploy/memory/model-deploy-memory-test-support";
-import { EngineParameterValuesSchema } from "@/gen/dotcore/catalog/v1/engine_release_pb";
+import { ParameterValuesSchema } from "@/gen/dotcore/catalog/v1/parameter_pb";
import {
type ExactPlacement,
ExactPlacementSchema,
@@ -146,7 +146,7 @@ function request(key: string, target?: string): ResourceEstimateRequest {
values: {
configuration: {
customArgumentTokens: [],
- engineParameterValues: create(EngineParameterValuesSchema),
+ engineParameterValues: create(ParameterValuesSchema),
modelArtifactBindingId: `binding-${key}`,
optionOverrides: [],
},
diff --git a/frontend/src/features/models/inspector/deployment-overview.tsx b/frontend/src/features/models/inspector/deployment-overview.tsx
index 61868f19..3c763fe0 100644
--- a/frontend/src/features/models/inspector/deployment-overview.tsx
+++ b/frontend/src/features/models/inspector/deployment-overview.tsx
@@ -29,8 +29,8 @@ import {
TOTAL_CONTEXT_PARAMETER_ID,
} from "@/features/models/runtime/engine-parameter-values";
import { deploymentKvCachePrecisionLabel } from "@/features/models/runtime/kv-cache-precision";
-import { type EngineParameterValues } from "@/gen/dotcore/catalog/v1/engine_release_pb";
import { type Model } from "@/gen/dotcore/catalog/v1/model_pb";
+import type { ParameterValues } from "@/gen/dotcore/catalog/v1/parameter_pb";
import {
type ModelDeployment,
type ResolvedLocalModelDeploymentSnapshot,
@@ -332,7 +332,7 @@ function ContextDetail({
}
function resolvedEngineParameterValue(
- values: EngineParameterValues | undefined,
+ values: ParameterValues | undefined,
parameterId: string,
): bigint | undefined {
const parameter = values?.values.find(
diff --git a/frontend/src/features/models/inspector/model-inspector.test.tsx b/frontend/src/features/models/inspector/model-inspector.test.tsx
index 430ac766..1eb10cca 100644
--- a/frontend/src/features/models/inspector/model-inspector.test.tsx
+++ b/frontend/src/features/models/inspector/model-inspector.test.tsx
@@ -13,8 +13,8 @@ import {
} from "@/features/models/model-test-support";
import { projectModelRows } from "@/features/models/profiles/model-row";
import { modelWithKvCachePrecision } from "@/features/models/runtime/runtime-schema-test-support";
-import { EngineParameterValuesSchema } from "@/gen/dotcore/catalog/v1/engine_release_pb";
import { ModelSchema, SpeculativeDecodingMethod } from "@/gen/dotcore/catalog/v1/model_pb";
+import { ParameterValuesSchema } from "@/gen/dotcore/catalog/v1/parameter_pb";
import {
type ModelDeployment,
ModelDeploymentDesiredState,
@@ -186,7 +186,7 @@ describe("model inspectors", () => {
const withOffload = create(ResolvedLocalModelDeploymentSnapshotSchema, {
...snapshot,
engineParameterSchema: kvOffloadReleases[0].parameterSchema,
- engineParameterValues: create(EngineParameterValuesSchema, {
+ engineParameterValues: create(ParameterValuesSchema, {
values: [
{ parameterId: "total-context", value: { case: "unsignedInteger", value: 8192n } },
{ parameterId: "kv-offloading-size-gib", value: { case: "unsignedInteger", value: 16n } },
@@ -235,7 +235,7 @@ describe("model inspectors", () => {
const withBoolean = create(ResolvedLocalModelDeploymentSnapshotSchema, {
...snapshot,
engineParameterSchema: multiParameterReleases[0].parameterSchema,
- engineParameterValues: create(EngineParameterValuesSchema, {
+ engineParameterValues: create(ParameterValuesSchema, {
values: [
{ parameterId: "total-context", value: { case: "unsignedInteger", value: 8192n } },
{
@@ -570,7 +570,7 @@ describe("model inspectors", () => {
({ derived, perRequest, shown }) => {
const resolved = create(ResolvedLocalModelDeploymentSnapshotSchema, {
...snapshot,
- engineParameterValues: create(EngineParameterValuesSchema, {
+ engineParameterValues: create(ParameterValuesSchema, {
values: [
{ parameterId: "total-context", value: { case: "unsignedInteger", value: 131072n } },
{
diff --git a/frontend/src/features/models/model-test-support.ts b/frontend/src/features/models/model-test-support.ts
index c10b16dc..32b87c51 100644
--- a/frontend/src/features/models/model-test-support.ts
+++ b/frontend/src/features/models/model-test-support.ts
@@ -2,6 +2,7 @@ import { create } from "@bufbuild/protobuf";
import { EngineReleaseSchema } from "@/gen/dotcore/catalog/v1/engine_release_pb";
import { ModelSchema } from "@/gen/dotcore/catalog/v1/model_pb";
+import { ParameterVisibility } from "@/gen/dotcore/catalog/v1/parameter_pb";
import { EngineKind } from "@/gen/dotcore/engine/v1/engine_pb";
import { ResolvedLocalModelDeploymentSnapshotSchema } from "@/gen/dotcore/model/v1/deployment_pb";
import { ScalarResourceEstimateSchema } from "@/gen/dotcore/model/v1/service_pb";
@@ -76,7 +77,7 @@ export const releases = [
{
parameterId: "total-context",
displayName: "Total context",
- exposed: true,
+ visibility: ParameterVisibility.BASIC,
parameterType: {
case: "unsignedInteger",
value: { minimum: 1024n, maximum: 32768n, unit: "tokens" },
@@ -96,7 +97,7 @@ export const multiParameterReleases = [
{
parameterId: "total-context",
displayName: "Total context",
- exposed: true,
+ visibility: ParameterVisibility.BASIC,
parameterType: {
case: "unsignedInteger",
value: { minimum: 1024n, maximum: 131072n, unit: "tokens" },
@@ -183,7 +184,7 @@ export const kvOffloadReleases = [
{
parameterId: "total-context",
displayName: "Total context",
- exposed: true,
+ visibility: ParameterVisibility.BASIC,
parameterType: {
case: "unsignedInteger",
value: { minimum: 1024n, maximum: 131072n, unit: "tokens" },
@@ -191,7 +192,7 @@ export const kvOffloadReleases = [
},
{
parameterId: "kv-offloading-size-gib",
- exposed: true,
+ visibility: ParameterVisibility.ADVANCED,
displayName: "KV offloading size",
parameterType: {
case: "unsignedInteger",
diff --git a/frontend/src/features/models/profiles/profile-compatibility.ts b/frontend/src/features/models/profiles/profile-compatibility.ts
index d77cff53..319fa035 100644
--- a/frontend/src/features/models/profiles/profile-compatibility.ts
+++ b/frontend/src/features/models/profiles/profile-compatibility.ts
@@ -1,6 +1,6 @@
import { executionTargetKey } from "@/domain/execution-target";
import { CONTEXT_PER_REQUEST_PARAMETER_ID } from "@/features/models/runtime/engine-parameter-values";
-import type { EngineParameterValues } from "@/gen/dotcore/catalog/v1/engine_release_pb";
+import type { ParameterValues } from "@/gen/dotcore/catalog/v1/parameter_pb";
import { MemoryFit as ProtoMemoryFit } from "@/gen/dotcore/deployment/v1/memory_fit_pb";
import {
type ListModelProfileCompatibilityResponse,
@@ -90,7 +90,7 @@ export interface ProfileCompatibility {
* read for a scenario, hidden parameters included and derived values left out. A profile the
* scenario can't serve has no entry.
*/
- engineValues: ReadonlyMap;
+ engineValues: ReadonlyMap;
/** Evaluated configured model execution targets, ordered by node then target. */
targets: readonly TargetIdentity[];
entries: ReadonlyMap;
diff --git a/frontend/src/features/models/runtime/context-size-field.tsx b/frontend/src/features/models/runtime/context-size-field.tsx
index 9090de8d..29717800 100644
--- a/frontend/src/features/models/runtime/context-size-field.tsx
+++ b/frontend/src/features/models/runtime/context-size-field.tsx
@@ -1,7 +1,7 @@
import type { ReactNode, RefObject } from "react";
import { FieldError } from "@/components/ui/field-error";
-import type { EngineParameterDefinition } from "@/gen/dotcore/catalog/v1/engine_release_pb";
+import type { ParameterDefinition } from "@/gen/dotcore/catalog/v1/parameter_pb";
import {
ContextSizeInput,
@@ -22,11 +22,11 @@ interface ContextSizeFieldProps {
inputRefs: RefObject>;
/** The largest size that fits the selected target, offered as the input's last chip. */
largestFit?: ContextSizeLargestFit;
- onBlur: (parameter: EngineParameterDefinition) => void;
+ onBlur: (parameter: ParameterDefinition) => void;
/** Receives each change, and whether it is settled rather than typing in progress. */
- onChange: (parameter: EngineParameterDefinition, input: string, settled: boolean) => void;
+ onChange: (parameter: ParameterDefinition, input: string, settled: boolean) => void;
/** An unsigned-integer context size parameter; its schema range bounds every control. */
- parameter: EngineParameterDefinition;
+ parameter: ParameterDefinition;
/** The range the slider is drawn over, shared with other context fields so they line up. */
sliderRange?: ContextSizeRange;
}
diff --git a/frontend/src/features/models/runtime/engine-parameter-values.test.ts b/frontend/src/features/models/runtime/engine-parameter-values.test.ts
index fd8867c0..fde68253 100644
--- a/frontend/src/features/models/runtime/engine-parameter-values.test.ts
+++ b/frontend/src/features/models/runtime/engine-parameter-values.test.ts
@@ -3,12 +3,14 @@ import { describe, expect, it } from "vitest";
import { EngineReleaseSchema } from "@/gen/dotcore/catalog/v1/engine_release_pb";
import { ModelProfileSchema } from "@/gen/dotcore/catalog/v1/model_pb";
+import { ParameterVisibility } from "@/gen/dotcore/catalog/v1/parameter_pb";
import { engineKindLabel } from "../profiles/model-row";
import {
effectiveEngineParameters,
engineParameterError,
engineParameterValues,
+ profileStartingValues,
} from "./engine-parameter-values";
describe("engineKindLabel", () => {
@@ -21,7 +23,7 @@ describe("sizing-derived parameters", () => {
const unsigned = (parameterId: string, defaultValue?: bigint) => ({
parameterId,
displayName: parameterId,
- exposed: true,
+ visibility: ParameterVisibility.ADVANCED,
parameterType: {
case: "unsignedInteger" as const,
value: { minimum: 1n, maximum: 1000n, unit: "MiB", defaultValue },
@@ -68,3 +70,76 @@ describe("sizing-derived parameters", () => {
).toBeUndefined();
});
});
+
+describe("starting values", () => {
+ const release = create(EngineReleaseSchema, {
+ parameterSchema: {
+ parameters: [
+ {
+ parameterId: "total-context",
+ displayName: "Total context",
+ visibility: ParameterVisibility.BASIC,
+ parameterType: {
+ case: "unsignedInteger",
+ value: { minimum: 1n, maximum: 1000n, unit: "tokens", defaultValue: 100n },
+ },
+ },
+ {
+ parameterId: "prefix-caching",
+ displayName: "Prefix caching",
+ visibility: ParameterVisibility.HIDDEN,
+ parameterType: { case: "boolean", value: { defaultValue: true } },
+ },
+ {
+ parameterId: "flash-attention",
+ displayName: "Flash attention",
+ visibility: ParameterVisibility.ADVANCED,
+ parameterType: { case: "boolean", value: { defaultValue: false } },
+ },
+ {
+ parameterId: "no-default",
+ displayName: "No default",
+ visibility: ParameterVisibility.ADVANCED,
+ parameterType: { case: "boolean", value: {} },
+ },
+ ],
+ },
+ });
+
+ it("takes the engine default of every typed parameter, booleans included, in schema order", () => {
+ const values = profileStartingValues(release, create(ModelProfileSchema, {}));
+
+ expect(values?.values.map(({ parameterId, value }) => [parameterId, value])).toEqual([
+ ["total-context", { case: "unsignedInteger", value: 100n }],
+ ["prefix-caching", { case: "boolean", value: true }],
+ ["flash-attention", { case: "boolean", value: false }],
+ ]);
+ });
+
+ it("keeps a profile's own value over the engine default", () => {
+ const profile = create(ModelProfileSchema, {
+ engineParameterDefaults: {
+ values: [{ parameterId: "prefix-caching", value: { case: "boolean", value: false } }],
+ },
+ });
+
+ expect(
+ profileStartingValues(release, profile)?.values.map(({ parameterId, value }) => [
+ parameterId,
+ value,
+ ]),
+ ).toEqual([
+ ["prefix-caching", { case: "boolean", value: false }],
+ ["total-context", { case: "unsignedInteger", value: 100n }],
+ ["flash-attention", { case: "boolean", value: false }],
+ ]);
+ });
+
+ it("shows every parameter that isn't hidden", () => {
+ expect(
+ effectiveEngineParameters(release, create(ModelProfileSchema, {})).map(
+ (parameter) => parameter.parameterId,
+ ),
+ ).toEqual(["total-context", "flash-attention", "no-default"]);
+ });
+});
diff --git a/frontend/src/features/models/runtime/engine-parameter-values.ts b/frontend/src/features/models/runtime/engine-parameter-values.ts
index 3c2eefd1..40cc5228 100644
--- a/frontend/src/features/models/runtime/engine-parameter-values.ts
+++ b/frontend/src/features/models/runtime/engine-parameter-values.ts
@@ -1,14 +1,15 @@
-import type {
- EngineParameterDefinition,
- EngineParameterValue,
- EngineParameterValues,
- EngineRelease,
-} from "@/gen/dotcore/catalog/v1/engine_release_pb";
+import type { EngineRelease } from "@/gen/dotcore/catalog/v1/engine_release_pb";
import type {
Model,
ModelEngineConfiguration,
ModelProfile,
} from "@/gen/dotcore/catalog/v1/model_pb";
+import {
+ type ParameterDefinition,
+ type ParameterValue,
+ type ParameterValues,
+ ParameterVisibility,
+} from "@/gen/dotcore/catalog/v1/parameter_pb";
/** The shared engine parameter that sets the total context in tokens, declared by every engine. */
export const TOTAL_CONTEXT_PARAMETER_ID = "total-context";
@@ -36,15 +37,15 @@ export const CONTEXT_PARAMETER_IDS: ReadonlySet = new Set([
*/
export interface RuntimeParameterGroups {
context: ContextParameterGroup;
- hostMemory: EngineParameterDefinition[];
- other: EngineParameterDefinition[];
- parallelRequests?: EngineParameterDefinition;
+ hostMemory: ParameterDefinition[];
+ other: ParameterDefinition[];
+ parallelRequests?: ParameterDefinition;
}
/** The context size parameters a profile exposes; every engine declares both. */
export interface ContextParameterGroup {
- contextPerRequest?: EngineParameterDefinition;
- totalContext?: EngineParameterDefinition;
+ contextPerRequest?: ParameterDefinition;
+ totalContext?: ParameterDefinition;
}
/**
@@ -54,7 +55,7 @@ export interface ContextParameterGroup {
* generic group.
*/
export function runtimeParameterGroups(
- parameters: readonly EngineParameterDefinition[],
+ parameters: readonly ParameterDefinition[],
configuration: ModelEngineConfiguration | undefined,
): RuntimeParameterGroups {
const hostMemoryIds = hostMemoryParameterIds(configuration);
@@ -95,10 +96,10 @@ export function maximumContext(
export function effectiveEngineParameters(
release: EngineRelease | undefined,
profile: ModelProfile | undefined,
-): EngineParameterDefinition[] {
+): ParameterDefinition[] {
const parameters = release?.parameterSchema?.parameters ?? [];
if (!profile?.exposedParameterIds.length)
- return parameters.filter((parameter) => parameter.exposed);
+ return parameters.filter((parameter) => parameter.visibility !== ParameterVisibility.HIDDEN);
return profile.exposedParameterIds.flatMap((parameterId) => {
const parameter = parameters.find((candidate) => candidate.parameterId === parameterId);
@@ -113,31 +114,23 @@ export function effectiveEngineParameters(
export function profileStartingValues(
release: EngineRelease | undefined,
profile: ModelProfile | undefined,
-): EngineParameterValues | undefined {
+): ParameterValues | undefined {
if (!profile) return undefined;
const values = [...(profile.engineParameterDefaults?.values ?? [])];
for (const parameter of release?.parameterSchema?.parameters ?? []) {
- const engineDefault =
- parameter.parameterType.case === "unsignedInteger"
- ? parameter.parameterType.value.defaultValue
- : undefined;
+ const engineDefault = parameterDefaultValue(parameter);
if (
engineDefault !== undefined &&
!values.some((value) => value.parameterId === parameter.parameterId)
) {
- values.push({
- parameterId: parameter.parameterId,
- value: { case: "unsignedInteger", value: engineDefault },
- } as EngineParameterValue);
+ values.push({ parameterId: parameter.parameterId, value: engineDefault } as ParameterValue);
}
}
- return { values } as EngineParameterValues;
+ return { values } as ParameterValues;
}
-export function engineParameterInputs(
- values: EngineParameterValues | undefined,
-): Map {
+export function engineParameterInputs(values: ParameterValues | undefined): Map {
return new Map(
(values?.values ?? []).flatMap((parameter): Array<[string, string]> => {
switch (parameter.value.case) {
@@ -152,11 +145,11 @@ export function engineParameterInputs(
}
export function engineParameterValues(
- declared: EngineParameterValues | undefined,
- parameters: readonly EngineParameterDefinition[],
+ declared: ParameterValues | undefined,
+ parameters: readonly ParameterDefinition[],
inputs: ReadonlyMap,
derived: ReadonlySet = noDerivedParameters,
-): EngineParameterValues | undefined {
+): ParameterValues | undefined {
if (parameters.some((parameter) => engineParameterError(parameter, inputs, derived)))
return undefined;
@@ -178,7 +171,7 @@ export function engineParameterValues(
if (index < 0) values.push(entry!);
else values[index] = entry!;
}
- return { values } as EngineParameterValues;
+ return { values } as ParameterValues;
}
/**
@@ -187,7 +180,7 @@ export function engineParameterValues(
* in `derived` is valid.
*/
export function engineParameterError(
- parameter: EngineParameterDefinition,
+ parameter: ParameterDefinition,
inputs: ReadonlyMap,
derived: ReadonlySet = noDerivedParameters,
): string | undefined {
@@ -215,7 +208,7 @@ export function engineParameterError(
* declares; undefined for the others, whose unit the field shows. The schema description
* documents the engine and stays out of the field.
*/
-export function engineParameterHelp(parameter: EngineParameterDefinition): string | undefined {
+export function engineParameterHelp(parameter: ParameterDefinition): string | undefined {
return sharedParameterHelp.get(parameter.parameterId);
}
@@ -242,7 +235,7 @@ const noDerivedParameters: ReadonlySet = new Set();
// An empty context per request or sizing-derived parameter is left out rather than rejected.
function omitsOptionalValue(
- parameter: EngineParameterDefinition,
+ parameter: ParameterDefinition,
input: string,
derived: ReadonlySet,
): boolean {
@@ -254,16 +247,16 @@ function omitsOptionalValue(
}
function editedEngineParameterValue(
- parameter: EngineParameterDefinition,
+ parameter: ParameterDefinition,
input: string,
-): EngineParameterValue | undefined {
+): ParameterValue | undefined {
switch (parameter.parameterType.case) {
case "boolean":
return input === "true" || input === "false"
? ({
parameterId: parameter.parameterId,
value: { case: "boolean", value: input === "true" },
- } as EngineParameterValue)
+ } as ParameterValue)
: undefined;
case "unsignedInteger": {
if (!/^\d+$/.test(input)) return undefined;
@@ -273,10 +266,33 @@ function editedEngineParameterValue(
? ({
parameterId: parameter.parameterId,
value: { case: "unsignedInteger", value },
- } as EngineParameterValue)
+ } as ParameterValue)
: undefined;
}
default:
return undefined;
}
}
+
+// The catalog default of a parameter, mirroring the backend helper; undefined when it declares none.
+function parameterDefaultValue(
+ parameter: ParameterDefinition,
+): ParameterValue["value"] | undefined {
+ const type = parameter.parameterType;
+ switch (type.case) {
+ case "boolean":
+ return type.value.defaultValue === undefined
+ ? undefined
+ : { case: "boolean", value: type.value.defaultValue };
+ case "unsignedInteger":
+ return type.value.defaultValue === undefined
+ ? undefined
+ : { case: "unsignedInteger", value: type.value.defaultValue };
+ case "stringEnum":
+ return type.value.defaultValue === undefined
+ ? undefined
+ : { case: "string", value: type.value.defaultValue };
+ default:
+ return undefined;
+ }
+}
diff --git a/frontend/src/features/models/runtime/engine-parameters.tsx b/frontend/src/features/models/runtime/engine-parameters.tsx
index c099c46d..6386d7eb 100644
--- a/frontend/src/features/models/runtime/engine-parameters.tsx
+++ b/frontend/src/features/models/runtime/engine-parameters.tsx
@@ -2,11 +2,8 @@ import type { RefObject } from "react";
import type { ParameterView, ParameterViewValue } from "@/components/parameters/parameter-view";
import { ParametersDetails, ParametersForm } from "@/components/parameters/parameters";
-import type {
- EngineParameterDefinition,
- EngineParameterSchema,
- EngineParameterValues,
-} from "@/gen/dotcore/catalog/v1/engine_release_pb";
+import type { EngineParameterSchema } from "@/gen/dotcore/catalog/v1/engine_release_pb";
+import type { ParameterDefinition, ParameterValues } from "@/gen/dotcore/catalog/v1/parameter_pb";
import { engineParameterHelp } from "./engine-parameter-values";
@@ -14,9 +11,9 @@ interface EngineParametersFormProps {
errors: ReadonlyMap;
inputs: ReadonlyMap;
inputRefs: RefObject>;
- onBlur: (parameter: EngineParameterDefinition) => void;
- onChange: (parameter: EngineParameterDefinition, input: string) => void;
- parameters: readonly EngineParameterDefinition[];
+ onBlur: (parameter: ParameterDefinition) => void;
+ onChange: (parameter: ParameterDefinition, input: string) => void;
+ parameters: readonly ParameterDefinition[];
}
export function EngineParametersForm({
@@ -53,7 +50,7 @@ export function EngineParametersDetails({
values,
}: {
schema: EngineParameterSchema | undefined;
- values: EngineParameterValues | undefined;
+ values: ParameterValues | undefined;
}) {
if (!schema || !values) return null;
@@ -66,7 +63,7 @@ export function EngineParametersDetails({
);
}
-function engineParameterViews(parameters: readonly EngineParameterDefinition[]): ParameterView[] {
+function engineParameterViews(parameters: readonly ParameterDefinition[]): ParameterView[] {
return parameters.flatMap((parameter): ParameterView[] => {
const base = {
hint: engineParameterHelp(parameter),
diff --git a/frontend/src/features/models/runtime/local-deployment-configuration.ts b/frontend/src/features/models/runtime/local-deployment-configuration.ts
index 87cebffa..b5291412 100644
--- a/frontend/src/features/models/runtime/local-deployment-configuration.ts
+++ b/frontend/src/features/models/runtime/local-deployment-configuration.ts
@@ -1,9 +1,6 @@
-import type {
- EngineParameterDefinition,
- EngineParameterValues,
- EngineRelease,
-} from "@/gen/dotcore/catalog/v1/engine_release_pb";
+import type { EngineRelease } from "@/gen/dotcore/catalog/v1/engine_release_pb";
import type { Model, ModelOptionValues, ModelProfile } from "@/gen/dotcore/catalog/v1/model_pb";
+import type { ParameterDefinition, ParameterValues } from "@/gen/dotcore/catalog/v1/parameter_pb";
import { formatQuantity } from "@/lib/format";
import {
@@ -19,7 +16,7 @@ import {
/** The configuration of one local model deployment, as the create, preview, and estimate requests carry it. */
export interface LocalDeploymentConfigurationValues {
customArgumentTokens: string[];
- engineParameterValues: EngineParameterValues;
+ engineParameterValues: ParameterValues;
modelArtifactBindingId: string;
/** Sparse option values layered over the profile's option defaults. */
optionOverrides: ModelOptionValues[];
@@ -30,7 +27,7 @@ export interface LocalDeploymentConfigurationEdits {
bindingId: string;
customArgumentTokens?: string[];
/** Engine parameter values replacing the profile's defaults, such as those of a sizing scenario. */
- declaredValues?: EngineParameterValues;
+ declaredValues?: ParameterValues;
/** Parameters catalog sizing derives for the profile, which an empty input leaves to it. */
derivedParameterIds?: ReadonlySet;
inputs: ReadonlyMap;
@@ -197,7 +194,7 @@ function editedParameterLabels(
// Formats valid values and keeps an invalid input as typed, so a summary never rounds a value the
// form still rejects into a plausible one.
function engineParameterInputLabel(
- parameter: EngineParameterDefinition,
+ parameter: ParameterDefinition,
inputs: ReadonlyMap,
): string {
const input = inputs.get(parameter.parameterId) ?? "";
diff --git a/frontend/src/features/models/runtime/runtime-schema-test-support.ts b/frontend/src/features/models/runtime/runtime-schema-test-support.ts
index dfdc7e0c..bb3bccc1 100644
--- a/frontend/src/features/models/runtime/runtime-schema-test-support.ts
+++ b/frontend/src/features/models/runtime/runtime-schema-test-support.ts
@@ -8,6 +8,7 @@ import {
ModelSchema,
SpeculativeDecodingMethod,
} from "@/gen/dotcore/catalog/v1/model_pb";
+import { ParameterVisibility } from "@/gen/dotcore/catalog/v1/parameter_pb";
import { EngineKind } from "@/gen/dotcore/engine/v1/engine_pb";
// Engine releases and a model mirroring the master catalog `catalog/engines/{llama-cpp,vllm,ninfer}.yaml`
@@ -333,7 +334,7 @@ function unsignedParameter(
return {
parameterId,
displayName,
- exposed,
+ visibility: exposed ? ParameterVisibility.ADVANCED : ParameterVisibility.HIDDEN,
description,
parameterType: {
case: "unsignedInteger" as const,
From a344d5ab5717d454821ebf2c141874e868e9e08b Mon Sep 17 00:00:00 2001
From: Andre-Philippe Paquet
Date: Thu, 12 Nov 2026 13:51:46 -0500
Subject: [PATCH 05/40] unreviewed: feat(proto): add profile visibility
overrides for engine parameters
---
.../src/gen/dotcore/catalog/v1/model_pb.ts | 73 +++++++++++++------
proto/dotcore/catalog/v1/model.proto | 14 +++-
2 files changed, 63 insertions(+), 24 deletions(-)
diff --git a/frontend/src/gen/dotcore/catalog/v1/model_pb.ts b/frontend/src/gen/dotcore/catalog/v1/model_pb.ts
index d865b4ae..a5cad620 100644
--- a/frontend/src/gen/dotcore/catalog/v1/model_pb.ts
+++ b/frontend/src/gen/dotcore/catalog/v1/model_pb.ts
@@ -12,7 +12,7 @@ import type { HardwareRequirements } from "./hardware_class_pb";
import { file_dotcore_catalog_v1_hardware_class } from "./hardware_class_pb";
import type { InferenceRequestKind, ModelInputCapabilities } from "./model_capabilities_pb";
import { file_dotcore_catalog_v1_model_capabilities } from "./model_capabilities_pb";
-import type { ParameterValues } from "./parameter_pb";
+import type { ParameterValues, ParameterVisibility } from "./parameter_pb";
import { file_dotcore_catalog_v1_parameter } from "./parameter_pb";
import type { EngineModelConfiguration } from "../../engine/v1/engine_pb";
import { file_dotcore_engine_v1_engine } from "../../engine/v1/engine_pb";
@@ -22,7 +22,7 @@ import type { Message } from "@bufbuild/protobuf";
* Describes the file dotcore/catalog/v1/model.proto.
*/
export const file_dotcore_catalog_v1_model: GenFile = /*@__PURE__*/
- fileDesc("Ch5kb3Rjb3JlL2NhdGFsb2cvdjEvbW9kZWwucHJvdG8SEmRvdGNvcmUuY2F0YWxvZy52MSL2AwoFTW9kZWwSEAoIbW9kZWxfaWQYASABKAkSFAoMZGlzcGxheV9uYW1lGAIgASgJEg8KB3ZlcnNpb24YAyABKAkSHgoWbWF4aW11bV9jb250ZXh0X3Rva2VucxgMIAEoDRI0CglhcnRpZmFjdHMYBCADKAsyIS5kb3Rjb3JlLmNhdGFsb2cudjEuTW9kZWxBcnRpZmFjdBIyCghwcm9maWxlcxgFIAMoCzIgLmRvdGNvcmUuY2F0YWxvZy52MS5Nb2RlbFByb2ZpbGUSRwoTYXV4aWxpYXJ5X2FydGlmYWN0cxgHIAMoCzIqLmRvdGNvcmUuY2F0YWxvZy52MS5Nb2RlbEF1eGlsaWFyeUFydGlmYWN0EkQKDmNvbmZpZ3VyYXRpb25zGAggAygLMiwuZG90Y29yZS5jYXRhbG9nLnYxLk1vZGVsRW5naW5lQ29uZmlndXJhdGlvbhIwCgdvcHRpb25zGAkgAygLMh8uZG90Y29yZS5jYXRhbG9nLnYxLk1vZGVsT3B0aW9uEjsKDGludGVsbGlnZW5jZRgKIAEoCzIlLmRvdGNvcmUuY2F0YWxvZy52MS5JbnRlbGxpZ2VuY2VTY29yZRIsCgdsaWNlbnNlGAsgASgLMhsuZG90Y29yZS5jYXRhbG9nLnYxLkxpY2Vuc2UiJAoRSW50ZWxsaWdlbmNlU2NvcmUSDwoHb3ZlcmFsbBgBIAEoASIoCgdMaWNlbnNlEgoKAmlkGAEgASgJEhEKCXRlcm1zX3VybBgCIAEoCSKAAQoNTW9kZWxBcnRpZmFjdBI9CgZzb3VyY2UYASABKAsyLS5kb3Rjb3JlLmNhdGFsb2cudjEuSHVnZ2luZ0ZhY2VBcnRpZmFjdFNvdXJjZRITCgthcnRpZmFjdF9pZBgCIAEoCRIbChN3ZWlnaHRfcXVhbnRpemF0aW9uGAMgASgJInYKFk1vZGVsQXV4aWxpYXJ5QXJ0aWZhY3QSHQoVYXV4aWxpYXJ5X2FydGlmYWN0X2lkGAEgASgJEj0KBnNvdXJjZRgCIAEoCzItLmRvdGNvcmUuY2F0YWxvZy52MS5IdWdnaW5nRmFjZUFydGlmYWN0U291cmNlIs4BChlIdWdnaW5nRmFjZUFydGlmYWN0U291cmNlEhIKCnJlcG9zaXRvcnkYASABKAkSEAoIcmV2aXNpb24YAiABKAkSOwoEZmlsZRgDIAEoCzIrLmRvdGNvcmUuY2F0YWxvZy52MS5IdWdnaW5nRmFjZUZpbGVBcnRpZmFjdEgAEkMKCHNuYXBzaG90GAQgASgLMi8uZG90Y29yZS5jYXRhbG9nLnYxLkh1Z2dpbmdGYWNlU25hcHNob3RBcnRpZmFjdEgAQgkKB2NvbnRlbnQiQgoXSHVnZ2luZ0ZhY2VGaWxlQXJ0aWZhY3QSDAoEcGF0aBgBIAEoCRIZChFwcm92ZW5hbmNlX2RpZ2VzdBgCIAEoCSJZChtIdWdnaW5nRmFjZVNuYXBzaG90QXJ0aWZhY3QSOgoFZmlsZXMYASADKAsyKy5kb3Rjb3JlLmNhdGFsb2cudjEuSHVnZ2luZ0ZhY2VTbmFwc2hvdEZpbGUiQgoXSHVnZ2luZ0ZhY2VTbmFwc2hvdEZpbGUSDAoEcGF0aBgBIAEoCRIZChFwcm92ZW5hbmNlX2RpZ2VzdBgCIAEoCSKiBQoYTW9kZWxFbmdpbmVDb25maWd1cmF0aW9uEhgKEGNvbmZpZ3VyYXRpb25faWQYASABKAkSRQoQZW5naW5lX2FydGlmYWN0cxgCIAMoCzIrLmRvdGNvcmUuY2F0YWxvZy52MS5FbmdpbmVBcnRpZmFjdFJlZmVyZW5jZRJDChFhcnRpZmFjdF9iaW5kaW5ncxgDIAMoCzIoLmRvdGNvcmUuY2F0YWxvZy52MS5Nb2RlbEFydGlmYWN0QmluZGluZxI9ChBoYXJkd2FyZV9zdXBwb3J0GAkgAygLMiMuZG90Y29yZS5jYXRhbG9nLnYxLkhhcmR3YXJlU3VwcG9ydBJJChRlbmdpbmVfY29uZmlndXJhdGlvbhgFIAEoCzIrLmRvdGNvcmUuZW5naW5lLnYxLkVuZ2luZU1vZGVsQ29uZmlndXJhdGlvbhJOChZleGVjdXRpb25fY2FwYWJpbGl0aWVzGAYgASgLMi4uZG90Y29yZS5jYXRhbG9nLnYxLk1vZGVsRXhlY3V0aW9uQ2FwYWJpbGl0aWVzEkYKEmlucHV0X2NhcGFiaWxpdGllcxgHIAEoCzIqLmRvdGNvcmUuY2F0YWxvZy52MS5Nb2RlbElucHV0Q2FwYWJpbGl0aWVzEj8KDXJlcXVlc3Rfa2luZHMYCyADKA4yKC5kb3Rjb3JlLmNhdGFsb2cudjEuSW5mZXJlbmNlUmVxdWVzdEtpbmQSPQoHb3B0aW9ucxgIIAMoCzIsLmRvdGNvcmUuY2F0YWxvZy52MS5Nb2RlbENvbmZpZ3VyYXRpb25PcHRpb24SIwoWbWF4aW11bV9jb250ZXh0X3Rva2VucxgKIAEoDUgAiAEBQhkKF19tYXhpbXVtX2NvbnRleHRfdG9rZW5zImUKFE1vZGVsQXJ0aWZhY3RCaW5kaW5nEhIKCmJpbmRpbmdfaWQYASABKAkSGQoRbW9kZWxfYXJ0aWZhY3RfaWQYAiABKAkSHgoWYXV4aWxpYXJ5X2FydGlmYWN0X2lkcxgDIAMoCSK5AgoMTW9kZWxQcm9maWxlEhIKCnByb2ZpbGVfaWQYASABKAkSGAoQY29uZmlndXJhdGlvbl9pZBgCIAEoCRIpCiFkZWZhdWx0X21vZGVsX2FydGlmYWN0X2JpbmRpbmdfaWQYAyABKAkSRgoZZW5naW5lX3BhcmFtZXRlcl9kZWZhdWx0cxgEIAEoCzIjLmRvdGNvcmUuY2F0YWxvZy52MS5QYXJhbWV0ZXJWYWx1ZXMSFAoMZGlzcGxheV9uYW1lGAUgASgJEhMKC2Rlc2NyaXB0aW9uGAYgASgJEj4KD29wdGlvbl9kZWZhdWx0cxgHIAMoCzIlLmRvdGNvcmUuY2F0YWxvZy52MS5Nb2RlbE9wdGlvblZhbHVlcxIdChVleHBvc2VkX3BhcmFtZXRlcl9pZHMYCCADKAkimgIKC01vZGVsT3B0aW9uEhEKCW9wdGlvbl9pZBgBIAEoCRIUCgxkaXNwbGF5X25hbWUYAiABKAkSNwoGdmlzaW9uGAMgASgLMiUuZG90Y29yZS5jYXRhbG9nLnYxLlZpc2lvbk1vZGVsT3B0aW9uSAASUgoUc3BlY3VsYXRpdmVfZGVjb2RpbmcYBCABKAsyMi5kb3Rjb3JlLmNhdGFsb2cudjEuU3BlY3VsYXRpdmVEZWNvZGluZ01vZGVsT3B0aW9uSAASTQoSa3ZfY2FjaGVfcHJlY2lzaW9uGAUgASgLMi8uZG90Y29yZS5jYXRhbG9nLnYxLkt2Q2FjaGVQcmVjaXNpb25Nb2RlbE9wdGlvbkgAQgYKBGtpbmQiEwoRVmlzaW9uTW9kZWxPcHRpb24iXwoeU3BlY3VsYXRpdmVEZWNvZGluZ01vZGVsT3B0aW9uEj0KBm1ldGhvZBgBIAEoDjItLmRvdGNvcmUuY2F0YWxvZy52MS5TcGVjdWxhdGl2ZURlY29kaW5nTWV0aG9kIh0KG0t2Q2FjaGVQcmVjaXNpb25Nb2RlbE9wdGlvbiLsAQoYTW9kZWxDb25maWd1cmF0aW9uT3B0aW9uEhEKCW9wdGlvbl9pZBgBIAEoCRJGCgpwYXJhbWV0ZXJzGAIgAygLMjIuZG90Y29yZS5jYXRhbG9nLnYxLk1vZGVsT3B0aW9uUGFyYW1ldGVyRGVmaW5pdGlvbhI9Cg5kZWZhdWx0X3ZhbHVlcxgDIAEoCzIlLmRvdGNvcmUuY2F0YWxvZy52MS5Nb2RlbE9wdGlvblZhbHVlcxIdChVzdXBwb3J0ZWRfYmluZGluZ19pZHMYBCADKAkSFwoPY29tcGF0aWJsZV93aGVuGAUgASgJItQCCh5Nb2RlbE9wdGlvblBhcmFtZXRlckRlZmluaXRpb24SFAoMcGFyYW1ldGVyX2lkGAEgASgJEhQKDGRpc3BsYXlfbmFtZRgCIAEoCRJICgdib29sZWFuGAMgASgLMjUuZG90Y29yZS5jYXRhbG9nLnYxLkJvb2xlYW5Nb2RlbE9wdGlvblBhcmFtZXRlclNjaGVtYUgAElkKEHVuc2lnbmVkX2ludGVnZXIYBCABKAsyPS5kb3Rjb3JlLmNhdGFsb2cudjEuVW5zaWduZWRJbnRlZ2VyTW9kZWxPcHRpb25QYXJhbWV0ZXJTY2hlbWFIABJPCgtzdHJpbmdfZW51bRgFIAEoCzI4LmRvdGNvcmUuY2F0YWxvZy52MS5TdHJpbmdFbnVtTW9kZWxPcHRpb25QYXJhbWV0ZXJTY2hlbWFIAEIQCg5wYXJhbWV0ZXJfdHlwZSIjCiFCb29sZWFuTW9kZWxPcHRpb25QYXJhbWV0ZXJTY2hlbWEiWwopVW5zaWduZWRJbnRlZ2VyTW9kZWxPcHRpb25QYXJhbWV0ZXJTY2hlbWESDwoHbWluaW11bRgBIAEoBBIPCgdtYXhpbXVtGAIgASgEEgwKBHVuaXQYAyABKAkiNgokU3RyaW5nRW51bU1vZGVsT3B0aW9uUGFyYW1ldGVyU2NoZW1hEg4KBnZhbHVlcxgBIAMoCSJlChFNb2RlbE9wdGlvblZhbHVlcxIRCglvcHRpb25faWQYASABKAkSPQoGdmFsdWVzGAIgAygLMi0uZG90Y29yZS5jYXRhbG9nLnYxLk1vZGVsT3B0aW9uUGFyYW1ldGVyVmFsdWUiewoZTW9kZWxPcHRpb25QYXJhbWV0ZXJWYWx1ZRIUCgxwYXJhbWV0ZXJfaWQYASABKAkSEQoHYm9vbGVhbhgCIAEoCEgAEhoKEHVuc2lnbmVkX2ludGVnZXIYAyABKARIABIQCgZzdHJpbmcYBCABKAlIAEIHCgV2YWx1ZSJnChpNb2RlbEV4ZWN1dGlvbkNhcGFiaWxpdGllcxJJChlzdXBwb3J0ZWRfcGFyYWxsZWxfc2hhcGVzGAEgAygOMiYuZG90Y29yZS5jYXRhbG9nLnYxLk1vZGVsUGFyYWxsZWxTaGFwZSJ1Cg9IYXJkd2FyZVN1cHBvcnQSGQoRaGFyZHdhcmVfY2xhc3NfaWQYASABKAkSRwoVaGFyZHdhcmVfcmVxdWlyZW1lbnRzGAIgASgLMiguZG90Y29yZS5jYXRhbG9nLnYxLkhhcmR3YXJlUmVxdWlyZW1lbnRzKm0KGVNwZWN1bGF0aXZlRGVjb2RpbmdNZXRob2QSKwonU1BFQ1VMQVRJVkVfREVDT0RJTkdfTUVUSE9EX1VOU1BFQ0lGSUVEEAASIwofU1BFQ1VMQVRJVkVfREVDT0RJTkdfTUVUSE9EX01UUBABYgZwcm90bzM", [file_dotcore_catalog_v1_engine_release, file_dotcore_catalog_v1_execution, file_dotcore_catalog_v1_hardware_class, file_dotcore_catalog_v1_model_capabilities, file_dotcore_catalog_v1_parameter, file_dotcore_engine_v1_engine]);
+ fileDesc("Ch5kb3Rjb3JlL2NhdGFsb2cvdjEvbW9kZWwucHJvdG8SEmRvdGNvcmUuY2F0YWxvZy52MSL2AwoFTW9kZWwSEAoIbW9kZWxfaWQYASABKAkSFAoMZGlzcGxheV9uYW1lGAIgASgJEg8KB3ZlcnNpb24YAyABKAkSHgoWbWF4aW11bV9jb250ZXh0X3Rva2VucxgMIAEoDRI0CglhcnRpZmFjdHMYBCADKAsyIS5kb3Rjb3JlLmNhdGFsb2cudjEuTW9kZWxBcnRpZmFjdBIyCghwcm9maWxlcxgFIAMoCzIgLmRvdGNvcmUuY2F0YWxvZy52MS5Nb2RlbFByb2ZpbGUSRwoTYXV4aWxpYXJ5X2FydGlmYWN0cxgHIAMoCzIqLmRvdGNvcmUuY2F0YWxvZy52MS5Nb2RlbEF1eGlsaWFyeUFydGlmYWN0EkQKDmNvbmZpZ3VyYXRpb25zGAggAygLMiwuZG90Y29yZS5jYXRhbG9nLnYxLk1vZGVsRW5naW5lQ29uZmlndXJhdGlvbhIwCgdvcHRpb25zGAkgAygLMh8uZG90Y29yZS5jYXRhbG9nLnYxLk1vZGVsT3B0aW9uEjsKDGludGVsbGlnZW5jZRgKIAEoCzIlLmRvdGNvcmUuY2F0YWxvZy52MS5JbnRlbGxpZ2VuY2VTY29yZRIsCgdsaWNlbnNlGAsgASgLMhsuZG90Y29yZS5jYXRhbG9nLnYxLkxpY2Vuc2UiJAoRSW50ZWxsaWdlbmNlU2NvcmUSDwoHb3ZlcmFsbBgBIAEoASIoCgdMaWNlbnNlEgoKAmlkGAEgASgJEhEKCXRlcm1zX3VybBgCIAEoCSKAAQoNTW9kZWxBcnRpZmFjdBI9CgZzb3VyY2UYASABKAsyLS5kb3Rjb3JlLmNhdGFsb2cudjEuSHVnZ2luZ0ZhY2VBcnRpZmFjdFNvdXJjZRITCgthcnRpZmFjdF9pZBgCIAEoCRIbChN3ZWlnaHRfcXVhbnRpemF0aW9uGAMgASgJInYKFk1vZGVsQXV4aWxpYXJ5QXJ0aWZhY3QSHQoVYXV4aWxpYXJ5X2FydGlmYWN0X2lkGAEgASgJEj0KBnNvdXJjZRgCIAEoCzItLmRvdGNvcmUuY2F0YWxvZy52MS5IdWdnaW5nRmFjZUFydGlmYWN0U291cmNlIs4BChlIdWdnaW5nRmFjZUFydGlmYWN0U291cmNlEhIKCnJlcG9zaXRvcnkYASABKAkSEAoIcmV2aXNpb24YAiABKAkSOwoEZmlsZRgDIAEoCzIrLmRvdGNvcmUuY2F0YWxvZy52MS5IdWdnaW5nRmFjZUZpbGVBcnRpZmFjdEgAEkMKCHNuYXBzaG90GAQgASgLMi8uZG90Y29yZS5jYXRhbG9nLnYxLkh1Z2dpbmdGYWNlU25hcHNob3RBcnRpZmFjdEgAQgkKB2NvbnRlbnQiQgoXSHVnZ2luZ0ZhY2VGaWxlQXJ0aWZhY3QSDAoEcGF0aBgBIAEoCRIZChFwcm92ZW5hbmNlX2RpZ2VzdBgCIAEoCSJZChtIdWdnaW5nRmFjZVNuYXBzaG90QXJ0aWZhY3QSOgoFZmlsZXMYASADKAsyKy5kb3Rjb3JlLmNhdGFsb2cudjEuSHVnZ2luZ0ZhY2VTbmFwc2hvdEZpbGUiQgoXSHVnZ2luZ0ZhY2VTbmFwc2hvdEZpbGUSDAoEcGF0aBgBIAEoCRIZChFwcm92ZW5hbmNlX2RpZ2VzdBgCIAEoCSKiBQoYTW9kZWxFbmdpbmVDb25maWd1cmF0aW9uEhgKEGNvbmZpZ3VyYXRpb25faWQYASABKAkSRQoQZW5naW5lX2FydGlmYWN0cxgCIAMoCzIrLmRvdGNvcmUuY2F0YWxvZy52MS5FbmdpbmVBcnRpZmFjdFJlZmVyZW5jZRJDChFhcnRpZmFjdF9iaW5kaW5ncxgDIAMoCzIoLmRvdGNvcmUuY2F0YWxvZy52MS5Nb2RlbEFydGlmYWN0QmluZGluZxI9ChBoYXJkd2FyZV9zdXBwb3J0GAkgAygLMiMuZG90Y29yZS5jYXRhbG9nLnYxLkhhcmR3YXJlU3VwcG9ydBJJChRlbmdpbmVfY29uZmlndXJhdGlvbhgFIAEoCzIrLmRvdGNvcmUuZW5naW5lLnYxLkVuZ2luZU1vZGVsQ29uZmlndXJhdGlvbhJOChZleGVjdXRpb25fY2FwYWJpbGl0aWVzGAYgASgLMi4uZG90Y29yZS5jYXRhbG9nLnYxLk1vZGVsRXhlY3V0aW9uQ2FwYWJpbGl0aWVzEkYKEmlucHV0X2NhcGFiaWxpdGllcxgHIAEoCzIqLmRvdGNvcmUuY2F0YWxvZy52MS5Nb2RlbElucHV0Q2FwYWJpbGl0aWVzEj8KDXJlcXVlc3Rfa2luZHMYCyADKA4yKC5kb3Rjb3JlLmNhdGFsb2cudjEuSW5mZXJlbmNlUmVxdWVzdEtpbmQSPQoHb3B0aW9ucxgIIAMoCzIsLmRvdGNvcmUuY2F0YWxvZy52MS5Nb2RlbENvbmZpZ3VyYXRpb25PcHRpb24SIwoWbWF4aW11bV9jb250ZXh0X3Rva2VucxgKIAEoDUgAiAEBQhkKF19tYXhpbXVtX2NvbnRleHRfdG9rZW5zImUKFE1vZGVsQXJ0aWZhY3RCaW5kaW5nEhIKCmJpbmRpbmdfaWQYASABKAkSGQoRbW9kZWxfYXJ0aWZhY3RfaWQYAiABKAkSHgoWYXV4aWxpYXJ5X2FydGlmYWN0X2lkcxgDIAMoCSL6AgoMTW9kZWxQcm9maWxlEhIKCnByb2ZpbGVfaWQYASABKAkSGAoQY29uZmlndXJhdGlvbl9pZBgCIAEoCRIpCiFkZWZhdWx0X21vZGVsX2FydGlmYWN0X2JpbmRpbmdfaWQYAyABKAkSRgoZZW5naW5lX3BhcmFtZXRlcl9kZWZhdWx0cxgEIAEoCzIjLmRvdGNvcmUuY2F0YWxvZy52MS5QYXJhbWV0ZXJWYWx1ZXMSXgolZW5naW5lX3BhcmFtZXRlcl92aXNpYmlsaXR5X292ZXJyaWRlcxgJIAMoCzIvLmRvdGNvcmUuY2F0YWxvZy52MS5QYXJhbWV0ZXJWaXNpYmlsaXR5T3ZlcnJpZGUSFAoMZGlzcGxheV9uYW1lGAUgASgJEhMKC2Rlc2NyaXB0aW9uGAYgASgJEj4KD29wdGlvbl9kZWZhdWx0cxgHIAMoCzIlLmRvdGNvcmUuY2F0YWxvZy52MS5Nb2RlbE9wdGlvblZhbHVlcyJwChtQYXJhbWV0ZXJWaXNpYmlsaXR5T3ZlcnJpZGUSFAoMcGFyYW1ldGVyX2lkGAEgASgJEjsKCnZpc2liaWxpdHkYAiABKA4yJy5kb3Rjb3JlLmNhdGFsb2cudjEuUGFyYW1ldGVyVmlzaWJpbGl0eSKaAgoLTW9kZWxPcHRpb24SEQoJb3B0aW9uX2lkGAEgASgJEhQKDGRpc3BsYXlfbmFtZRgCIAEoCRI3CgZ2aXNpb24YAyABKAsyJS5kb3Rjb3JlLmNhdGFsb2cudjEuVmlzaW9uTW9kZWxPcHRpb25IABJSChRzcGVjdWxhdGl2ZV9kZWNvZGluZxgEIAEoCzIyLmRvdGNvcmUuY2F0YWxvZy52MS5TcGVjdWxhdGl2ZURlY29kaW5nTW9kZWxPcHRpb25IABJNChJrdl9jYWNoZV9wcmVjaXNpb24YBSABKAsyLy5kb3Rjb3JlLmNhdGFsb2cudjEuS3ZDYWNoZVByZWNpc2lvbk1vZGVsT3B0aW9uSABCBgoEa2luZCITChFWaXNpb25Nb2RlbE9wdGlvbiJfCh5TcGVjdWxhdGl2ZURlY29kaW5nTW9kZWxPcHRpb24SPQoGbWV0aG9kGAEgASgOMi0uZG90Y29yZS5jYXRhbG9nLnYxLlNwZWN1bGF0aXZlRGVjb2RpbmdNZXRob2QiHQobS3ZDYWNoZVByZWNpc2lvbk1vZGVsT3B0aW9uIuwBChhNb2RlbENvbmZpZ3VyYXRpb25PcHRpb24SEQoJb3B0aW9uX2lkGAEgASgJEkYKCnBhcmFtZXRlcnMYAiADKAsyMi5kb3Rjb3JlLmNhdGFsb2cudjEuTW9kZWxPcHRpb25QYXJhbWV0ZXJEZWZpbml0aW9uEj0KDmRlZmF1bHRfdmFsdWVzGAMgASgLMiUuZG90Y29yZS5jYXRhbG9nLnYxLk1vZGVsT3B0aW9uVmFsdWVzEh0KFXN1cHBvcnRlZF9iaW5kaW5nX2lkcxgEIAMoCRIXCg9jb21wYXRpYmxlX3doZW4YBSABKAki1AIKHk1vZGVsT3B0aW9uUGFyYW1ldGVyRGVmaW5pdGlvbhIUCgxwYXJhbWV0ZXJfaWQYASABKAkSFAoMZGlzcGxheV9uYW1lGAIgASgJEkgKB2Jvb2xlYW4YAyABKAsyNS5kb3Rjb3JlLmNhdGFsb2cudjEuQm9vbGVhbk1vZGVsT3B0aW9uUGFyYW1ldGVyU2NoZW1hSAASWQoQdW5zaWduZWRfaW50ZWdlchgEIAEoCzI9LmRvdGNvcmUuY2F0YWxvZy52MS5VbnNpZ25lZEludGVnZXJNb2RlbE9wdGlvblBhcmFtZXRlclNjaGVtYUgAEk8KC3N0cmluZ19lbnVtGAUgASgLMjguZG90Y29yZS5jYXRhbG9nLnYxLlN0cmluZ0VudW1Nb2RlbE9wdGlvblBhcmFtZXRlclNjaGVtYUgAQhAKDnBhcmFtZXRlcl90eXBlIiMKIUJvb2xlYW5Nb2RlbE9wdGlvblBhcmFtZXRlclNjaGVtYSJbCilVbnNpZ25lZEludGVnZXJNb2RlbE9wdGlvblBhcmFtZXRlclNjaGVtYRIPCgdtaW5pbXVtGAEgASgEEg8KB21heGltdW0YAiABKAQSDAoEdW5pdBgDIAEoCSI2CiRTdHJpbmdFbnVtTW9kZWxPcHRpb25QYXJhbWV0ZXJTY2hlbWESDgoGdmFsdWVzGAEgAygJImUKEU1vZGVsT3B0aW9uVmFsdWVzEhEKCW9wdGlvbl9pZBgBIAEoCRI9CgZ2YWx1ZXMYAiADKAsyLS5kb3Rjb3JlLmNhdGFsb2cudjEuTW9kZWxPcHRpb25QYXJhbWV0ZXJWYWx1ZSJ7ChlNb2RlbE9wdGlvblBhcmFtZXRlclZhbHVlEhQKDHBhcmFtZXRlcl9pZBgBIAEoCRIRCgdib29sZWFuGAIgASgISAASGgoQdW5zaWduZWRfaW50ZWdlchgDIAEoBEgAEhAKBnN0cmluZxgEIAEoCUgAQgcKBXZhbHVlImcKGk1vZGVsRXhlY3V0aW9uQ2FwYWJpbGl0aWVzEkkKGXN1cHBvcnRlZF9wYXJhbGxlbF9zaGFwZXMYASADKA4yJi5kb3Rjb3JlLmNhdGFsb2cudjEuTW9kZWxQYXJhbGxlbFNoYXBlInUKD0hhcmR3YXJlU3VwcG9ydBIZChFoYXJkd2FyZV9jbGFzc19pZBgBIAEoCRJHChVoYXJkd2FyZV9yZXF1aXJlbWVudHMYAiABKAsyKC5kb3Rjb3JlLmNhdGFsb2cudjEuSGFyZHdhcmVSZXF1aXJlbWVudHMqbQoZU3BlY3VsYXRpdmVEZWNvZGluZ01ldGhvZBIrCidTUEVDVUxBVElWRV9ERUNPRElOR19NRVRIT0RfVU5TUEVDSUZJRUQQABIjCh9TUEVDVUxBVElWRV9ERUNPRElOR19NRVRIT0RfTVRQEAFiBnByb3RvMw", [file_dotcore_catalog_v1_engine_release, file_dotcore_catalog_v1_execution, file_dotcore_catalog_v1_hardware_class, file_dotcore_catalog_v1_model_capabilities, file_dotcore_catalog_v1_parameter, file_dotcore_engine_v1_engine]);
/**
* An immutable artifact-manager catalog declaration. The inference controller
@@ -516,6 +516,13 @@ export type ModelProfile = Message<"dotcore.catalog.v1.ModelProfile"> & {
*/
engineParameterDefaults?: ParameterValues;
+ /**
+ * Engine parameters this profile shows differently from the engine, at most one per parameter.
+ *
+ * @generated from field: repeated dotcore.catalog.v1.ParameterVisibilityOverride engine_parameter_visibility_overrides = 9;
+ */
+ engineParameterVisibilityOverrides: ParameterVisibilityOverride[];
+
/**
* Optional human-readable name for this profile.
*
@@ -536,13 +543,6 @@ export type ModelProfile = Message<"dotcore.catalog.v1.ModelProfile"> & {
* @generated from field: repeated dotcore.catalog.v1.ModelOptionValues option_defaults = 7;
*/
optionDefaults: ModelOptionValues[];
-
- /**
- * Exact ordered engine parameters exposed by this profile; empty uses engine defaults.
- *
- * @generated from field: repeated string exposed_parameter_ids = 8;
- */
- exposedParameterIds: string[];
};
/**
@@ -552,6 +552,35 @@ export type ModelProfile = Message<"dotcore.catalog.v1.ModelProfile"> & {
export const ModelProfileSchema: GenMessage = /*@__PURE__*/
messageDesc(file_dotcore_catalog_v1_model, 11);
+/**
+ * Sets where the console shows one engine parameter for a profile, in place of the engine's
+ * visibility.
+ *
+ * @generated from message dotcore.catalog.v1.ParameterVisibilityOverride
+ */
+export type ParameterVisibilityOverride = Message<"dotcore.catalog.v1.ParameterVisibilityOverride"> & {
+ /**
+ * Engine parameter identity.
+ *
+ * @generated from field: string parameter_id = 1;
+ */
+ parameterId: string;
+
+ /**
+ * Visibility the profile gives the parameter.
+ *
+ * @generated from field: dotcore.catalog.v1.ParameterVisibility visibility = 2;
+ */
+ visibility: ParameterVisibility;
+};
+
+/**
+ * Describes the message dotcore.catalog.v1.ParameterVisibilityOverride.
+ * Use `create(ParameterVisibilityOverrideSchema)` to create a new message.
+ */
+export const ParameterVisibilityOverrideSchema: GenMessage = /*@__PURE__*/
+ messageDesc(file_dotcore_catalog_v1_model, 12);
+
/**
* Declares one stable semantic option that configurations may implement.
*
@@ -607,7 +636,7 @@ export type ModelOption = Message<"dotcore.catalog.v1.ModelOption"> & {
* Use `create(ModelOptionSchema)` to create a new message.
*/
export const ModelOptionSchema: GenMessage = /*@__PURE__*/
- messageDesc(file_dotcore_catalog_v1_model, 12);
+ messageDesc(file_dotcore_catalog_v1_model, 13);
/**
* Identifies the semantic vision option kind.
@@ -622,7 +651,7 @@ export type VisionModelOption = Message<"dotcore.catalog.v1.VisionModelOption">
* Use `create(VisionModelOptionSchema)` to create a new message.
*/
export const VisionModelOptionSchema: GenMessage = /*@__PURE__*/
- messageDesc(file_dotcore_catalog_v1_model, 13);
+ messageDesc(file_dotcore_catalog_v1_model, 14);
/**
* Identifies the semantic speculative-decoding option kind and the draft method it uses. Its
@@ -644,7 +673,7 @@ export type SpeculativeDecodingModelOption = Message<"dotcore.catalog.v1.Specula
* Use `create(SpeculativeDecodingModelOptionSchema)` to create a new message.
*/
export const SpeculativeDecodingModelOptionSchema: GenMessage = /*@__PURE__*/
- messageDesc(file_dotcore_catalog_v1_model, 14);
+ messageDesc(file_dotcore_catalog_v1_model, 15);
/**
* Identifies the semantic KV cache precision option kind. Its configuration options declare one
@@ -660,7 +689,7 @@ export type KvCachePrecisionModelOption = Message<"dotcore.catalog.v1.KvCachePre
* Use `create(KvCachePrecisionModelOptionSchema)` to create a new message.
*/
export const KvCachePrecisionModelOptionSchema: GenMessage = /*@__PURE__*/
- messageDesc(file_dotcore_catalog_v1_model, 15);
+ messageDesc(file_dotcore_catalog_v1_model, 16);
/**
* Defines one option as implemented by a model-local engine configuration.
@@ -709,7 +738,7 @@ export type ModelConfigurationOption = Message<"dotcore.catalog.v1.ModelConfigur
* Use `create(ModelConfigurationOptionSchema)` to create a new message.
*/
export const ModelConfigurationOptionSchema: GenMessage = /*@__PURE__*/
- messageDesc(file_dotcore_catalog_v1_model, 16);
+ messageDesc(file_dotcore_catalog_v1_model, 17);
/**
* Defines one typed configuration-local option parameter.
@@ -766,7 +795,7 @@ export type ModelOptionParameterDefinition = Message<"dotcore.catalog.v1.ModelOp
* Use `create(ModelOptionParameterDefinitionSchema)` to create a new message.
*/
export const ModelOptionParameterDefinitionSchema: GenMessage = /*@__PURE__*/
- messageDesc(file_dotcore_catalog_v1_model, 17);
+ messageDesc(file_dotcore_catalog_v1_model, 18);
/**
* Defines a boolean option parameter.
@@ -781,7 +810,7 @@ export type BooleanModelOptionParameterSchema = Message<"dotcore.catalog.v1.Bool
* Use `create(BooleanModelOptionParameterSchemaSchema)` to create a new message.
*/
export const BooleanModelOptionParameterSchemaSchema: GenMessage = /*@__PURE__*/
- messageDesc(file_dotcore_catalog_v1_model, 18);
+ messageDesc(file_dotcore_catalog_v1_model, 19);
/**
* Defines bounds and units for an unsigned-integer option parameter.
@@ -816,7 +845,7 @@ export type UnsignedIntegerModelOptionParameterSchema = Message<"dotcore.catalog
* Use `create(UnsignedIntegerModelOptionParameterSchemaSchema)` to create a new message.
*/
export const UnsignedIntegerModelOptionParameterSchemaSchema: GenMessage = /*@__PURE__*/
- messageDesc(file_dotcore_catalog_v1_model, 19);
+ messageDesc(file_dotcore_catalog_v1_model, 20);
/**
* Defines the exact accepted values for a constrained string parameter.
@@ -837,7 +866,7 @@ export type StringEnumModelOptionParameterSchema = Message<"dotcore.catalog.v1.S
* Use `create(StringEnumModelOptionParameterSchemaSchema)` to create a new message.
*/
export const StringEnumModelOptionParameterSchemaSchema: GenMessage = /*@__PURE__*/
- messageDesc(file_dotcore_catalog_v1_model, 20);
+ messageDesc(file_dotcore_catalog_v1_model, 21);
/**
* Supplies typed values for one model option.
@@ -865,7 +894,7 @@ export type ModelOptionValues = Message<"dotcore.catalog.v1.ModelOptionValues">
* Use `create(ModelOptionValuesSchema)` to create a new message.
*/
export const ModelOptionValuesSchema: GenMessage = /*@__PURE__*/
- messageDesc(file_dotcore_catalog_v1_model, 21);
+ messageDesc(file_dotcore_catalog_v1_model, 22);
/**
* Supplies one typed option parameter value.
@@ -915,7 +944,7 @@ export type ModelOptionParameterValue = Message<"dotcore.catalog.v1.ModelOptionP
* Use `create(ModelOptionParameterValueSchema)` to create a new message.
*/
export const ModelOptionParameterValueSchema: GenMessage = /*@__PURE__*/
- messageDesc(file_dotcore_catalog_v1_model, 22);
+ messageDesc(file_dotcore_catalog_v1_model, 23);
/**
* Execution shapes that a model profile supports.
@@ -936,7 +965,7 @@ export type ModelExecutionCapabilities = Message<"dotcore.catalog.v1.ModelExecut
* Use `create(ModelExecutionCapabilitiesSchema)` to create a new message.
*/
export const ModelExecutionCapabilitiesSchema: GenMessage = /*@__PURE__*/
- messageDesc(file_dotcore_catalog_v1_model, 23);
+ messageDesc(file_dotcore_catalog_v1_model, 24);
/**
* Declares that a configuration runs on one shared hardware class. The class selector supplies the
@@ -965,7 +994,7 @@ export type HardwareSupport = Message<"dotcore.catalog.v1.HardwareSupport"> & {
* Use `create(HardwareSupportSchema)` to create a new message.
*/
export const HardwareSupportSchema: GenMessage = /*@__PURE__*/
- messageDesc(file_dotcore_catalog_v1_model, 24);
+ messageDesc(file_dotcore_catalog_v1_model, 25);
/**
* Draft methods that propose tokens for speculative decoding.
diff --git a/proto/dotcore/catalog/v1/model.proto b/proto/dotcore/catalog/v1/model.proto
index a2e57c3f..01d2d39a 100644
--- a/proto/dotcore/catalog/v1/model.proto
+++ b/proto/dotcore/catalog/v1/model.proto
@@ -191,6 +191,9 @@ message ModelProfile {
// Typed engine parameter defaults for this profile, overlaid on the engine defaults.
ParameterValues engine_parameter_defaults = 4;
+ // Engine parameters this profile shows differently from the engine, at most one per parameter.
+ repeated ParameterVisibilityOverride engine_parameter_visibility_overrides = 9;
+
// Optional human-readable name for this profile.
string display_name = 5;
@@ -199,9 +202,16 @@ message ModelProfile {
// Sparse option defaults overlaid on configuration defaults.
repeated ModelOptionValues option_defaults = 7;
+}
+
+// Sets where the console shows one engine parameter for a profile, in place of the engine's
+// visibility.
+message ParameterVisibilityOverride {
+ // Engine parameter identity.
+ string parameter_id = 1;
- // Exact ordered engine parameters exposed by this profile; empty uses engine defaults.
- repeated string exposed_parameter_ids = 8;
+ // Visibility the profile gives the parameter.
+ ParameterVisibility visibility = 2;
}
// Declares one stable semantic option that configurations may implement.
From 0fce17a41b6eeda5c3311bfeef5faa37134a910f Mon Sep 17 00:00:00 2001
From: Andre-Philippe Paquet
Date: Thu, 12 Nov 2026 14:00:44 -0500
Subject: [PATCH 06/40] unreviewed: feat(backend): validate profile visibility
overrides
---
backend/catalog/src/lib.rs | 51 +++++++++++++++
backend/catalog/src/shared_parameters.rs | 53 +++++++++++++++
.../catalog/src/validation/engine_release.rs | 30 +++++++--
backend/catalog/src/validation/mod.rs | 26 +++++++-
backend/catalog/src/validation/model.rs | 63 ++++++++++++------
.../core/tests/backend/catalog_contracts.rs | 64 ++++++++++++++++---
backend/proto/tests/model_contract.rs | 21 ++++--
7 files changed, 265 insertions(+), 43 deletions(-)
diff --git a/backend/catalog/src/lib.rs b/backend/catalog/src/lib.rs
index 82799ec2..52b86e36 100644
--- a/backend/catalog/src/lib.rs
+++ b/backend/catalog/src/lib.rs
@@ -1794,6 +1794,57 @@ artifacts:
);
}
+ #[test]
+ fn a_profile_hides_only_parameters_with_a_default_or_a_derivation_on_every_binding() {
+ let hidden = r#" engineParameterVisibilityOverrides:
+ - { parameterId: "prompt-cache-memory-mib", visibility: "PARAMETER_VISIBILITY_HIDDEN" }
+"#;
+ let load = |case: &str, sizing: &str, binding_sizing: &str, values: &str| {
+ let root = temporary_catalog_root().join(case);
+ create_empty_catalog_source(&root);
+ write_sizing_engine(&root);
+ write_sizing_model_with(&root, sizing, binding_sizing, &format!("{values}{hidden}"));
+ Catalog::load_with_policy(
+ &root,
+ OciArtifactPolicy::Development,
+ &ACCEPT_ENGINE_DECLARATIONS,
+ )
+ };
+
+ let error = load("unset", "", "", " values: []\n")
+ .expect_err("a hidden parameter without a value must reject")
+ .to_string();
+ assert!(
+ error.contains(
+ "profile \"balanced\" hides engine parameter \"prompt-cache-memory-mib\", which has no default and which sizing does not derive on every binding and hardware class"
+ ),
+ "{error}"
+ );
+ assert!(!error.contains("omits engine parameter"), "{error}");
+
+ load(
+ "defaulted",
+ "",
+ "",
+ &format!(" values:\n{}", SIZING_ENGINE_CACHE_DEFAULT),
+ )
+ .expect("a profile default gives the hidden parameter its value");
+ load(
+ "derived",
+ r#" sizing:
+ derivedParameters:
+ prompt-cache-memory-mib: "4096"
+ estimates:
+ acceleratorMemory:
+ unit: bytes
+ equation: "${prompt-cache-memory-mib}"
+"#,
+ UNPARAMETERIZED_BINDING_SIZING,
+ " values: []\n",
+ )
+ .expect("sizing gives the hidden parameter its value on every binding");
+ }
+
#[test]
fn profile_defaults_overlay_the_engine_defaults_per_parameter() {
let load = |profile_defaults: &str| {
diff --git a/backend/catalog/src/shared_parameters.rs b/backend/catalog/src/shared_parameters.rs
index c0fcc8d2..46925b43 100644
--- a/backend/catalog/src/shared_parameters.rs
+++ b/backend/catalog/src/shared_parameters.rs
@@ -90,6 +90,33 @@ pub(crate) fn profile_may_default(parameter_id: &str) -> bool {
.is_none_or(|shared| shared.profile_default)
}
+/// Whether the parameter belongs to the main view: the context parameters do, and no other
+/// parameter may be shown there by an engine or a profile.
+pub(crate) fn in_main_view(parameter_id: &str) -> bool {
+ SHARED_PARAMETERS.iter().any(|shared| {
+ shared.parameter_id == parameter_id && shared.visibility == ParameterVisibility::Basic
+ })
+}
+
+/// Returns why a profile may not give the parameter this visibility, or none when it may. The
+/// context parameters belong to the main view: a profile may hide them but not move them to the
+/// advanced view. Every other parameter stays out of the main view, so a profile may show it as
+/// advanced or hide it.
+pub(crate) fn profile_visibility_problem(
+ parameter_id: &str,
+ visibility: ParameterVisibility,
+) -> Option<&'static str> {
+ let main_view = in_main_view(parameter_id);
+ match visibility {
+ ParameterVisibility::Unspecified => Some("requires a basic, advanced or hidden visibility"),
+ ParameterVisibility::Advanced if main_view => Some("may be hidden but not advanced"),
+ ParameterVisibility::Basic if !main_view => Some("may be advanced or hidden but not basic"),
+ ParameterVisibility::Basic
+ | ParameterVisibility::Advanced
+ | ParameterVisibility::Hidden => None,
+ }
+}
+
/// Checks that an engine parameter schema declares every shared parameter with its fixed shape,
/// returning one error per problem. Engine payloads are left to the engine's own validation.
pub(crate) fn validate_shared_parameters(schema: &EngineParameterSchema) -> Vec {
@@ -331,6 +358,32 @@ mod tests {
assert!(profile_may_default("prompt-cache-memory-mib"));
}
+ #[test]
+ fn profiles_may_hide_any_parameter_but_keep_the_contexts_basic_and_the_rest_out_of_basic() {
+ use ParameterVisibility::{Advanced, Basic, Hidden, Unspecified};
+
+ for parameter_id in [TOTAL_CONTEXT_PARAMETER_ID, CONTEXT_PER_REQUEST_PARAMETER_ID] {
+ assert_eq!(profile_visibility_problem(parameter_id, Basic), None);
+ assert_eq!(profile_visibility_problem(parameter_id, Hidden), None);
+ assert_eq!(
+ profile_visibility_problem(parameter_id, Advanced),
+ Some("may be hidden but not advanced")
+ );
+ }
+ for parameter_id in [PARALLEL_REQUESTS_PARAMETER_ID, "prompt-cache-memory-mib"] {
+ assert_eq!(profile_visibility_problem(parameter_id, Advanced), None);
+ assert_eq!(profile_visibility_problem(parameter_id, Hidden), None);
+ assert_eq!(
+ profile_visibility_problem(parameter_id, Basic),
+ Some("may be advanced or hidden but not basic")
+ );
+ }
+ assert_eq!(
+ profile_visibility_problem(TOTAL_CONTEXT_PARAMETER_ID, Unspecified),
+ Some("requires a basic, advanced or hidden visibility")
+ );
+ }
+
#[test]
fn the_omitted_context_per_request_is_the_smaller_of_the_maximum_and_the_total_context() {
let schema = shared_schema();
diff --git a/backend/catalog/src/validation/engine_release.rs b/backend/catalog/src/validation/engine_release.rs
index 061692b1..0634824d 100644
--- a/backend/catalog/src/validation/engine_release.rs
+++ b/backend/catalog/src/validation/engine_release.rs
@@ -9,15 +9,16 @@ use std::collections::BTreeSet;
use dotcore_domain::{Accelerator, valid_id};
use dotcore_proto::dotcore::catalog::v1::{
- EngineArtifact, EngineParameterSchema, EngineRelease, ParameterDefinition, Selector,
- engine_artifact::Kind as EngineArtifactKind, parameter_definition::ParameterType,
+ EngineArtifact, EngineParameterSchema, EngineRelease, ParameterDefinition, ParameterVisibility,
+ Selector, engine_artifact::Kind as EngineArtifactKind, parameter_definition::ParameterType,
};
use super::common::{valid_selector_memory_architecture, validate_oci_reference};
use super::nix::{AcceptedProvides, nix_systems, validate_nix_artifact};
use super::storage::{StorageOwner, validate_storage_bindings};
use crate::{
- OciArtifactPolicy, shared_parameters::validate_shared_parameters,
+ OciArtifactPolicy,
+ shared_parameters::{in_main_view, validate_shared_parameters},
typed_parameters::validate_parameter_definition as validate_shared_definition,
};
@@ -233,7 +234,8 @@ fn validate_parameter_schema(
}
/// Validates one engine parameter definition: the shared definition rules, a unique id, and the
-/// engine owner rule that parameters are booleans or unsigned integers with a unit.
+/// engine owner rules that parameters are booleans or unsigned integers with a unit, and that only
+/// the context parameters are basic.
fn validate_parameter_definition(
definition: &ParameterDefinition,
ids: &mut BTreeSet,
@@ -253,14 +255,20 @@ fn validate_parameter_definition(
)),
Some(ParameterType::Boolean(_) | ParameterType::UnsignedInteger(_)) | None => {}
}
+ // The shared parameter check reports a context parameter in the wrong view.
+ if definition.visibility() == ParameterVisibility::Basic && !in_main_view(id) {
+ errors.push(anyhow::anyhow!(
+ "engine parameter {id:?} must be advanced or hidden; only the context parameters are basic"
+ ));
+ }
}
#[cfg(test)]
mod tests {
use dotcore_proto::dotcore::{
catalog::v1::{
- NixArtifact, NixDockerImage, ParameterVisibility, StorageBinding, StorageKind,
- StorageScope, nix_artifact::Provides,
+ NixArtifact, NixDockerImage, StorageBinding, StorageKind, StorageScope,
+ nix_artifact::Provides,
},
observation::v1::AcceleratorMemoryArchitecture,
};
@@ -431,7 +439,7 @@ mod tests {
}
#[test]
- fn engine_parameters_are_booleans_or_unsigned_integers_with_a_unit() {
+ fn engine_parameters_are_non_basic_booleans_or_unsigned_integers_with_a_unit() {
let definition = |parameter_type| ParameterDefinition {
parameter_id: "feature".to_owned(),
display_name: "Feature".to_owned(),
@@ -475,6 +483,14 @@ mod tests {
))),
["engine parameter \"feature\" must be a boolean or an unsigned integer"]
);
+ let mut basic = definition(boolean.clone());
+ basic.visibility = ParameterVisibility::Basic as i32;
+ assert_eq!(
+ errors(&basic),
+ [
+ "engine parameter \"feature\" must be advanced or hidden; only the context parameters are basic"
+ ]
+ );
let mut unplaced = definition(boolean);
unplaced.visibility = ParameterVisibility::Unspecified as i32;
assert_eq!(
diff --git a/backend/catalog/src/validation/mod.rs b/backend/catalog/src/validation/mod.rs
index 76d53eb6..b02129d2 100644
--- a/backend/catalog/src/validation/mod.rs
+++ b/backend/catalog/src/validation/mod.rs
@@ -11,7 +11,8 @@ use std::collections::{BTreeMap, BTreeSet};
use dotcore_proto::dotcore::catalog::v1::{
Application, EngineRelease, HardwareClass, HardwareClassRecommendations, Model,
- ModelEngineConfiguration, ParameterDefinition, ParameterValues, UpstreamModelTemplate,
+ ModelEngineConfiguration, ParameterDefinition, ParameterValues, ParameterVisibility,
+ UpstreamModelTemplate,
};
use super::{
@@ -458,6 +459,29 @@ fn validate_sizing_coverage(
.into_iter()
.filter(|parameter_id| parameter_id != CONTEXT_PER_REQUEST_PARAMETER_ID)
{
+ let hidden = profile.engine_parameter_visibility_overrides.iter().any(
+ |visibility_override| {
+ visibility_override.parameter_id == parameter_id
+ && visibility_override.visibility() == ParameterVisibility::Hidden
+ },
+ );
+ // A hidden parameter is one the operator can never set, so it needs a value
+ // on every binding and class.
+ if hidden
+ && class_bindings().any(|(hardware_class_id, binding)| {
+ !binding_sizing(hardware_class_id, &binding.binding_id)
+ .is_some_and(|sizing| sizing.derives(¶meter_id))
+ })
+ {
+ diagnostics.push(CatalogDiagnostic::new(
+ &location,
+ anyhow::anyhow!(
+ "profile {:?} hides engine parameter {parameter_id:?}, which has no default and which sizing does not derive on every binding and hardware class",
+ profile.profile_id
+ ),
+ ));
+ continue;
+ }
for (hardware_class_id, binding) in class_bindings() {
if !binding_sizing(hardware_class_id, &binding.binding_id)
.is_some_and(|sizing| sizing.derives(¶meter_id))
diff --git a/backend/catalog/src/validation/model.rs b/backend/catalog/src/validation/model.rs
index 04882982..3e46398b 100644
--- a/backend/catalog/src/validation/model.rs
+++ b/backend/catalog/src/validation/model.rs
@@ -14,8 +14,8 @@ use dotcore_proto::dotcore::catalog::v1::{
EngineArtifact, EngineRelease, HardwareClass, HuggingFaceArtifactSource, InferenceRequestKind,
IntelligenceScore, License, Model, ModelArtifact, ModelConfigurationOption,
ModelEngineConfiguration, ModelInputCapabilities, ModelInputModality,
- ModelOptionParameterDefinition, ParameterValue, ParameterValues, SpeculativeDecodingMethod,
- hugging_face_artifact_source::Content as HuggingFaceContent,
+ ModelOptionParameterDefinition, ModelProfile, ParameterValue, ParameterValues,
+ SpeculativeDecodingMethod, hugging_face_artifact_source::Content as HuggingFaceContent,
model_option::Kind as ModelOptionKind,
model_option_parameter_definition::ParameterType as ModelOptionParameterType,
parameter_definition::ParameterType as EngineParameterType,
@@ -30,7 +30,7 @@ use crate::{
valid_option_values,
},
selector::{SelectorRelation, selector_relation},
- shared_parameters::profile_may_default,
+ shared_parameters::{profile_may_default, profile_visibility_problem},
typed_parameters::default_value,
validation::{EngineDeclarationValidator, configuration_engine_release},
};
@@ -819,24 +819,7 @@ fn validate_profiles(
let Some(engine) = configuration_engine_release(configuration, engines) else {
continue;
};
- if !profile.exposed_parameter_ids.is_empty() {
- let mut exposed_ids = BTreeSet::new();
- let schema = engine.parameter_schema.as_ref();
- if profile.exposed_parameter_ids.iter().any(|parameter_id| {
- !exposed_ids.insert(parameter_id)
- || schema.is_none_or(|schema| {
- !schema
- .parameters
- .iter()
- .any(|definition| definition.parameter_id == *parameter_id)
- })
- }) {
- errors.push(anyhow::anyhow!(
- "profile {:?} has invalid exposed engine parameters",
- profile.profile_id
- ));
- }
- }
+ validate_visibility_overrides(profile, engine, errors);
match profile.engine_parameter_defaults.as_ref() {
Some(values) => {
validate_supplied_parameter_values(values, engine, &profile.profile_id, errors);
@@ -868,6 +851,44 @@ fn validate_profiles(
}
}
+/// Validates a profile's engine parameter visibility overrides: each names a parameter of the
+/// engine release once, with a visibility the shared parameter rules allow for profiles.
+fn validate_visibility_overrides(
+ profile: &ModelProfile,
+ engine: &EngineRelease,
+ errors: &mut Vec,
+) {
+ let profile_id = &profile.profile_id;
+ let mut ids = BTreeSet::new();
+ for visibility_override in &profile.engine_parameter_visibility_overrides {
+ let parameter_id = &visibility_override.parameter_id;
+ if !engine
+ .parameter_schema
+ .iter()
+ .flat_map(|schema| &schema.parameters)
+ .any(|definition| &definition.parameter_id == parameter_id)
+ {
+ errors.push(anyhow::anyhow!(
+ "profile {profile_id:?} overrides the visibility of engine parameter {parameter_id:?}, which is not a parameter of the engine release"
+ ));
+ continue;
+ }
+ if !ids.insert(parameter_id) {
+ errors.push(anyhow::anyhow!(
+ "profile {profile_id:?} overrides the visibility of engine parameter {parameter_id:?} more than once"
+ ));
+ continue;
+ }
+ if let Some(problem) =
+ profile_visibility_problem(parameter_id, visibility_override.visibility())
+ {
+ errors.push(anyhow::anyhow!(
+ "profile {profile_id:?} engine parameter {parameter_id:?} {problem}"
+ ));
+ }
+ }
+}
+
fn validate_profile_option_defaults(
profile: &dotcore_proto::dotcore::catalog::v1::ModelProfile,
configuration: &ModelEngineConfiguration,
diff --git a/backend/core/tests/backend/catalog_contracts.rs b/backend/core/tests/backend/catalog_contracts.rs
index ccfad32c..146855f1 100644
--- a/backend/core/tests/backend/catalog_contracts.rs
+++ b/backend/core/tests/backend/catalog_contracts.rs
@@ -2439,7 +2439,7 @@ fn catalog_retains_boolean_engine_parameters_and_their_schema() {
}
#[test]
-fn catalog_rejects_mismatched_boolean_values_and_invalid_profile_exposure() {
+fn catalog_rejects_mismatched_boolean_values_and_invalid_visibility_overrides() {
let mut mismatched = synthetic_boolean_model();
mismatched.profiles[0]
.engine_parameter_defaults
@@ -2465,24 +2465,55 @@ fn catalog_rejects_mismatched_boolean_values_and_invalid_profile_exposure() {
"unexpected validation error: {error}"
);
- for exposed_parameter_ids in [
- vec!["total-context".to_owned(), "missing".to_owned()],
- vec!["total-context".to_owned(), "total-context".to_owned()],
+ use ParameterVisibility::{Advanced, Basic, Hidden};
+
+ for (overrides, expected) in [
+ (
+ vec![("missing", Hidden)],
+ "profile \"balanced\" overrides the visibility of engine parameter \"missing\", which is not a parameter of the engine release",
+ ),
+ (
+ vec![("feature-enabled", Hidden), ("feature-enabled", Advanced)],
+ "profile \"balanced\" overrides the visibility of engine parameter \"feature-enabled\" more than once",
+ ),
+ (
+ vec![("total-context", Advanced)],
+ "profile \"balanced\" engine parameter \"total-context\" may be hidden but not advanced",
+ ),
+ (
+ vec![("context-per-request", Advanced)],
+ "profile \"balanced\" engine parameter \"context-per-request\" may be hidden but not advanced",
+ ),
+ (
+ vec![("feature-enabled", Basic)],
+ "profile \"balanced\" engine parameter \"feature-enabled\" may be advanced or hidden but not basic",
+ ),
] {
let mut invalid = synthetic_boolean_model();
- invalid.profiles[0].exposed_parameter_ids = exposed_parameter_ids;
+ invalid.profiles[0].engine_parameter_visibility_overrides =
+ visibility_overrides(&overrides);
let error = catalog_builder()
.engine_validator(&ACCEPT_ENGINE_DECLARATIONS)
.engine_release("engine.yaml", synthetic_boolean_engine_release())
.model("model.yaml", invalid)
.build()
- .expect_err("invalid profile exposure")
+ .expect_err("invalid visibility override")
.to_string();
assert!(
- error.contains("profile \"balanced\" has invalid exposed engine parameters"),
+ error.contains(expected),
"unexpected validation error: {error}"
);
}
+
+ // A profile may hide the contexts, which the engine and resolution fill, and a parameter it
+ // defaults.
+ let mut valid = synthetic_boolean_model();
+ valid.profiles[0].engine_parameter_visibility_overrides = visibility_overrides(&[
+ ("total-context", Hidden),
+ ("context-per-request", Hidden),
+ ("feature-enabled", Hidden),
+ ]);
+ synthetic_boolean_catalog(valid);
}
/// Returns a fresh target of the production runtime kind for `platform`: Docker with an NVIDIA
@@ -3150,7 +3181,8 @@ fn synthetic_sizing_catalog() -> Catalog {
"#,
&format!(
r#" values:
-{SIZING_ENGINE_CACHE_DEFAULT} exposedParameterIds: ["total-context", "prompt-cache-memory-mib", "parallel-requests"]
+{SIZING_ENGINE_CACHE_DEFAULT} engineParameterVisibilityOverrides:
+ - {{ parameterId: "prompt-cache-memory-mib", visibility: "PARAMETER_VISIBILITY_ADVANCED" }}
"#
),
);
@@ -3208,10 +3240,24 @@ fn synthetic_boolean_model() -> Model {
dotcore_proto::dotcore::catalog::v1::parameter_value::Value::Boolean(false),
),
});
- profile.exposed_parameter_ids = vec!["total-context".to_owned(), "feature-enabled".to_owned()];
model
}
+/// Builds profile visibility overrides from parameter id and visibility pairs.
+fn visibility_overrides(
+ overrides: &[(&str, ParameterVisibility)],
+) -> Vec {
+ overrides
+ .iter()
+ .map(|(parameter_id, visibility)| {
+ dotcore_proto::dotcore::catalog::v1::ParameterVisibilityOverride {
+ parameter_id: (*parameter_id).to_owned(),
+ visibility: *visibility as i32,
+ }
+ })
+ .collect()
+}
+
fn synthetic_engine_release() -> EngineRelease {
dotcore_proto::yaml::from_str(
r#"engineReleaseId: "fixture-engine"
diff --git a/backend/proto/tests/model_contract.rs b/backend/proto/tests/model_contract.rs
index 02753841..023e9ac6 100644
--- a/backend/proto/tests/model_contract.rs
+++ b/backend/proto/tests/model_contract.rs
@@ -21,9 +21,10 @@ use dotcore_proto::{
ModelInputModality, ModelOption, ModelOptionParameterDefinition,
ModelOptionParameterValue, ModelOptionValues, ModelParallelShape, ModelProfile,
OciImageReference, OciPlatform, ParameterDefinition, ParameterValue, ParameterValues,
- ParameterVisibility, Selector, StringEnumModelOptionParameterSchema,
- UnsignedIntegerModelOptionParameterSchema, UnsignedIntegerParameterSchema,
- VisionModelOption, engine_artifact, model_option::Kind as ModelOptionKind,
+ ParameterVisibility, ParameterVisibilityOverride, Selector,
+ StringEnumModelOptionParameterSchema, UnsignedIntegerModelOptionParameterSchema,
+ UnsignedIntegerParameterSchema, VisionModelOption, engine_artifact,
+ model_option::Kind as ModelOptionKind,
model_option_parameter_definition::ParameterType as ModelOptionParameterType,
model_option_parameter_value::Value as ModelOptionValueKind,
parameter_definition::ParameterType as EngineParameterType,
@@ -589,7 +590,10 @@ fn generated_bindings_round_trip_model_configurations_and_typed_context_schema()
display_name: "Fast start".to_owned(),
description: "Balances model quality and startup speed with Q4 weights.".to_owned(),
option_defaults: Vec::new(),
- exposed_parameter_ids: vec!["total-context".to_owned()],
+ engine_parameter_visibility_overrides: vec![ParameterVisibilityOverride {
+ parameter_id: "total-context".to_owned(),
+ visibility: ParameterVisibility::Hidden as i32,
+ }],
};
let model = Model {
model_id: "gemma-4-e2b".to_owned(),
@@ -1153,10 +1157,17 @@ fn model_descriptors_define_typed_configuration_and_resolved_plan_contracts() {
("configuration_id", 2),
("default_model_artifact_binding_id", 3),
("engine_parameter_defaults", 4),
+ ("engine_parameter_visibility_overrides", 9),
("display_name", 5),
("description", 6),
("option_defaults", 7),
- ("exposed_parameter_ids", 8),
+ ]
+ );
+ assert_eq!(
+ field_layout(catalog, "ParameterVisibilityOverride"),
+ [
+ ("parameter_id", 1, "TYPE_STRING"),
+ ("visibility", 2, ".dotcore.catalog.v1.ParameterVisibility"),
]
);
assert_eq!(
From 72e43bc026c4518f6719d9dd151743577760a2ce Mon Sep 17 00:00:00 2001
From: Andre-Philippe Paquet
Date: Thu, 12 Nov 2026 14:01:53 -0500
Subject: [PATCH 07/40] unreviewed: feat(frontend): apply profile visibility
overrides to engine parameters
---
.../deploy/model-deploy-dialog.test.tsx | 28 ++++++++-
.../src/features/models/model-test-support.ts | 4 +-
.../runtime/engine-parameter-values.test.ts | 59 +++++++++++++++++++
.../models/runtime/engine-parameter-values.ts | 21 ++++---
.../test/catalog-model-site-contract.test.ts | 2 +-
5 files changed, 103 insertions(+), 11 deletions(-)
diff --git a/frontend/src/features/models/deploy/model-deploy-dialog.test.tsx b/frontend/src/features/models/deploy/model-deploy-dialog.test.tsx
index 46c66b29..c4f875c7 100644
--- a/frontend/src/features/models/deploy/model-deploy-dialog.test.tsx
+++ b/frontend/src/features/models/deploy/model-deploy-dialog.test.tsx
@@ -46,8 +46,8 @@ import {
EngineArtifactReferenceSchema,
EngineReleaseSchema,
} from "@/gen/dotcore/catalog/v1/engine_release_pb";
-import { ModelSchema } from "@/gen/dotcore/catalog/v1/model_pb";
-import { ParameterValueSchema } from "@/gen/dotcore/catalog/v1/parameter_pb";
+import { ModelSchema, type ParameterVisibilityOverride } from "@/gen/dotcore/catalog/v1/model_pb";
+import { ParameterValueSchema, ParameterVisibility } from "@/gen/dotcore/catalog/v1/parameter_pb";
import { MemoryFit } from "@/gen/dotcore/deployment/v1/memory_fit_pb";
import { ExactPlacementSchema } from "@/gen/dotcore/deployment/v1/placement_pb";
import {
@@ -240,6 +240,30 @@ describe("ModelDeployDialog", () => {
);
});
+ it("follows the profile's visibility overrides on the Context and Runtime steps", async () => {
+ const user = setupUser();
+ const overridden = clone(ModelSchema, multiParameterModel);
+ overridden.profiles[0].engineParameterVisibilityOverrides = [
+ { parameterId: "total-context", visibility: ParameterVisibility.HIDDEN },
+ { parameterId: "parallel-requests", visibility: ParameterVisibility.ADVANCED },
+ { parameterId: "enable-prefix-caching", visibility: ParameterVisibility.HIDDEN },
+ ] as ParameterVisibilityOverride[];
+ renderDialog({ engineReleases: multiParameterReleases, model: overridden });
+
+ await screen.findByText("Step 1 of 3 — Profile");
+ await enableAdvanced(user);
+ await user.click(screen.getByRole("button", { name: "Next" }));
+ await user.click(screen.getByRole("button", { name: "Next" }));
+ expect(screen.getByText("Step 3 of 5 — Context")).toBeVisible();
+ expect(screen.getByText("This profile sets its context itself.")).toBeVisible();
+ expect(screen.queryByText("Total context")).not.toBeInTheDocument();
+
+ await user.click(screen.getByRole("button", { name: "Next" }));
+ expect(screen.getByText("Step 4 of 5 — Runtime")).toBeVisible();
+ expect(screen.getByRole("textbox", { name: "Parallel requests" })).toHaveValue("4");
+ expect(screen.queryByText(/Other parameters/)).not.toBeInTheDocument();
+ });
+
it("reveals invalid parallel requests with Advanced instead of deploying", async () => {
const user = setupUser();
const onSubmit = vi.fn();
diff --git a/frontend/src/features/models/model-test-support.ts b/frontend/src/features/models/model-test-support.ts
index 32b87c51..a3ea00dd 100644
--- a/frontend/src/features/models/model-test-support.ts
+++ b/frontend/src/features/models/model-test-support.ts
@@ -105,6 +105,7 @@ export const multiParameterReleases = [
},
{
parameterId: "parallel-requests",
+ visibility: ParameterVisibility.HIDDEN,
displayName: "Parallel requests",
parameterType: {
case: "unsignedInteger",
@@ -113,6 +114,7 @@ export const multiParameterReleases = [
},
{
parameterId: "kv-cache-memory-bytes",
+ visibility: ParameterVisibility.HIDDEN,
displayName: "KV cache memory",
parameterType: {
case: "unsignedInteger",
@@ -121,6 +123,7 @@ export const multiParameterReleases = [
},
{
parameterId: "enable-prefix-caching",
+ visibility: ParameterVisibility.ADVANCED,
displayName: "Prefix caching",
description: "Caches reusable prompt prefixes.",
parameterType: { case: "boolean", value: {} },
@@ -159,7 +162,6 @@ export const multiParameterModel = create(ModelSchema, {
description: "Runs the model through vLLM for text inference.",
configurationId: "vllm-cuda",
defaultModelArtifactBindingId: "nvfp4-binding",
- exposedParameterIds: ["enable-prefix-caching", "total-context"],
engineParameterDefaults: {
values: [
{ parameterId: "total-context", value: { case: "unsignedInteger", value: 32768n } },
diff --git a/frontend/src/features/models/runtime/engine-parameter-values.test.ts b/frontend/src/features/models/runtime/engine-parameter-values.test.ts
index fde68253..3bc3cdb6 100644
--- a/frontend/src/features/models/runtime/engine-parameter-values.test.ts
+++ b/frontend/src/features/models/runtime/engine-parameter-values.test.ts
@@ -143,3 +143,62 @@ describe("starting values", () => {
).toEqual(["total-context", "flash-attention", "no-default"]);
});
});
+
+describe("effective visibility", () => {
+ const parameter = (parameterId: string, visibility: ParameterVisibility) => ({
+ parameterId,
+ displayName: parameterId,
+ visibility,
+ parameterType: {
+ case: "unsignedInteger" as const,
+ value: { minimum: 1n, maximum: 10n, unit: "" },
+ },
+ });
+ const release = create(EngineReleaseSchema, {
+ parameterSchema: {
+ parameters: [
+ parameter("total-context", ParameterVisibility.BASIC),
+ parameter("parallel-requests", ParameterVisibility.ADVANCED),
+ parameter("kv-offload", ParameterVisibility.HIDDEN),
+ parameter("cache", ParameterVisibility.ADVANCED),
+ ],
+ },
+ });
+ const shown = (overrides: Array<[string, ParameterVisibility]>) =>
+ effectiveEngineParameters(
+ release,
+ create(ModelProfileSchema, {
+ engineParameterVisibilityOverrides: overrides.map(([parameterId, visibility]) => ({
+ parameterId,
+ visibility,
+ })),
+ }),
+ ).map((candidate) => candidate.parameterId);
+
+ it("shows the engine's basic and advanced parameters in schema order", () => {
+ expect(shown([])).toEqual(["total-context", "parallel-requests", "cache"]);
+ });
+
+ it("lets a profile hide an advanced parameter", () => {
+ expect(shown([["parallel-requests", ParameterVisibility.HIDDEN]])).toEqual([
+ "total-context",
+ "cache",
+ ]);
+ });
+
+ it("lets a profile show a hidden parameter, in schema order", () => {
+ expect(shown([["kv-offload", ParameterVisibility.ADVANCED]])).toEqual([
+ "total-context",
+ "parallel-requests",
+ "kv-offload",
+ "cache",
+ ]);
+ });
+
+ it("lets a profile hide the total context", () => {
+ expect(shown([["total-context", ParameterVisibility.HIDDEN]])).toEqual([
+ "parallel-requests",
+ "cache",
+ ]);
+ });
+});
diff --git a/frontend/src/features/models/runtime/engine-parameter-values.ts b/frontend/src/features/models/runtime/engine-parameter-values.ts
index 40cc5228..1bdce73f 100644
--- a/frontend/src/features/models/runtime/engine-parameter-values.ts
+++ b/frontend/src/features/models/runtime/engine-parameter-values.ts
@@ -93,18 +93,25 @@ export function maximumContext(
return known.length ? Math.min(...known) : undefined;
}
+/**
+ * The engine parameters the deploy dialog shows for `profile`, in schema order: each parameter's
+ * visibility is the engine's unless the profile overrides it, and hidden ones are left out.
+ */
export function effectiveEngineParameters(
release: EngineRelease | undefined,
profile: ModelProfile | undefined,
): ParameterDefinition[] {
- const parameters = release?.parameterSchema?.parameters ?? [];
- if (!profile?.exposedParameterIds.length)
- return parameters.filter((parameter) => parameter.visibility !== ParameterVisibility.HIDDEN);
+ const overrides = new Map(
+ (profile?.engineParameterVisibilityOverrides ?? []).map((override) => [
+ override.parameterId,
+ override.visibility,
+ ]),
+ );
- return profile.exposedParameterIds.flatMap((parameterId) => {
- const parameter = parameters.find((candidate) => candidate.parameterId === parameterId);
- return parameter ? [parameter] : [];
- });
+ return (release?.parameterSchema?.parameters ?? []).filter(
+ (parameter) =>
+ (overrides.get(parameter.parameterId) ?? parameter.visibility) !== ParameterVisibility.HIDDEN,
+ );
}
/**
diff --git a/frontend/src/test/catalog-model-site-contract.test.ts b/frontend/src/test/catalog-model-site-contract.test.ts
index 1a2f7e1f..332a7750 100644
--- a/frontend/src/test/catalog-model-site-contract.test.ts
+++ b/frontend/src/test/catalog-model-site-contract.test.ts
@@ -418,10 +418,10 @@ describe("desired site and current-state contracts", () => {
"configuration_id",
"default_model_artifact_binding_id",
"engine_parameter_defaults",
+ "engine_parameter_visibility_overrides",
"display_name",
"description",
"option_defaults",
- "exposed_parameter_ids",
]);
expect(ModelParallelShape.ONE).toBe(1);
From 94bb3d38ee6e26337d29a82171bcdaa213cef126 Mon Sep 17 00:00:00 2001
From: Andre-Philippe Paquet
Date: Thu, 12 Nov 2026 14:04:22 -0500
Subject: [PATCH 08/40] unreviewed: test(backend): expect the reported apple
gpu model
From 7e3cffda54debdf3bbce3a4602b31b30a626abfb Mon Sep 17 00:00:00 2001
From: Andre-Philippe Paquet
Date: Thu, 12 Nov 2026 15:13:48 -0500
Subject: [PATCH 09/40] unreviewed: docs(catalog): describe shared parameter
visibility and profile overrides
---
AGENTS.md | 4 ++--
docs/specs/catalog.md | 12 ++++++++----
2 files changed, 10 insertions(+), 6 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 301d223f..2118c0ca 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -200,8 +200,8 @@ in the same change that adds, removes or renames a folder it lists.
apart from site or development additions.
- **model-local engine configuration**: one model's launch recipe on one engine (artifact bindings,
approved engine artifacts, request kinds, sizing, hardware support).
-- **model profile**: a preset of defaults for one engine configuration; never holds total context
- or context per request.
+- **model profile**: a preset of defaults and engine parameter visibility overrides for one engine
+ configuration; never holds a total context or context per request value.
- **engine release**: one version of an engine (llama.cpp, vLLM) and the parameters it exposes.
An **engine artifact** is one runnable build of it.
- **hardware class**: a kind of execution hardware, nothing model-specific. **Hardware support** is
diff --git a/docs/specs/catalog.md b/docs/specs/catalog.md
index e9aea477..52f7e665 100644
--- a/docs/specs/catalog.md
+++ b/docs/specs/catalog.md
@@ -129,14 +129,16 @@ A preset of defaults for one engine configuration.
- Supplies defaults, never ongoing deployment authority.
- Supplies no default for total context or context per request.
+- May override the default and the visibility of its engine's parameters. It may hide total context
+ and context per request, never make them advanced.
- Carries no platform. The hardware class follows from the exact placement, so one profile serves
every hardware class its configuration supports.
### Engine release
- Declares the shared engine parameters (total context, context per request, parallel requests),
- exposed, with a default on total context and parallel requests and none on context per request.
- Each release sets its own bounds, defaults and descriptions.
+ with fixed visibilities, a default on total context and parallel requests and none on context per
+ request. Each release sets its own bounds, defaults and descriptions.
- May declare parameter defaults within their bounds. A default on a parameter that sizing derives
is rejected.
@@ -169,7 +171,8 @@ minimums.
### App graph
- Carries the app's portable artifact, configuration, storage, secret, dependency, readiness and
- endpoint requirements, and its typed deployment parameters, shown as basic or advanced.
+ endpoint requirements, and its typed deployment parameters, shown as basic or advanced, never
+ hidden.
- Engine releases and apps declare the storage they need. Deployments keep that declaration
([runtime](runtime.md)).
@@ -182,7 +185,8 @@ The artifact manager also owns artifact bytes ([storage](storage.md)).
#### Responsibilities
- Own the immutable catalog declarations and their lifecycle.
-- Own the shape of the shared engine parameters.
+- Own the typed parameter definition engine releases and apps share, with its basic, advanced or
+ hidden visibility, and the shape of the shared engine parameters.
- Validate catalogs as described above.
#### Boundaries
From 50d35e56eeb6a187c27cf83351540d9a3d7b36eb Mon Sep 17 00:00:00 2001
From: Andre-Philippe Paquet
Date: Thu, 12 Nov 2026 17:14:54 -0500
Subject: [PATCH 10/40] unreviewed: docs(catalog): write operator-facing
parameter descriptions
---
catalog/applications/comfyui.yaml | 2 +-
catalog/engines/llama-cpp-macos.yaml | 8 ++++----
catalog/engines/llama-cpp.yaml | 8 ++++----
catalog/engines/ninfer.yaml | 8 ++++----
catalog/engines/strata.yaml | 8 ++++----
catalog/engines/vllm.yaml | 14 +++++++-------
6 files changed, 24 insertions(+), 24 deletions(-)
diff --git a/catalog/applications/comfyui.yaml b/catalog/applications/comfyui.yaml
index f9a6a363..5dab581f 100644
--- a/catalog/applications/comfyui.yaml
+++ b/catalog/applications/comfyui.yaml
@@ -15,7 +15,7 @@ graph:
- parameterId: "vram-mode"
displayName: "VRAM mode"
visibility: "PARAMETER_VISIBILITY_ADVANCED"
- description: "How ComfyUI keeps models in GPU memory. Auto loads and offloads on demand; modes other than auto need a GPU target."
+ description: "How ComfyUI keeps models in GPU memory. Leave it on auto to load and offload on demand; use another mode to force models to stay on the GPU or to save GPU memory, which needs a GPU target."
stringEnum:
values: ["auto", "highvram", "gpu-only", "novram"]
defaultValue: "auto"
diff --git a/catalog/engines/llama-cpp-macos.yaml b/catalog/engines/llama-cpp-macos.yaml
index 63520d32..7b1cbbd6 100644
--- a/catalog/engines/llama-cpp-macos.yaml
+++ b/catalog/engines/llama-cpp-macos.yaml
@@ -12,7 +12,7 @@ parameterSchema:
- parameterId: "total-context"
displayName: "Total context"
visibility: "PARAMETER_VISIBILITY_BASIC"
- description: "Context memory the server holds from the start and shares among the requests it generates at once. Each active request stores its own tokens in it, even when requests start with the same prompt. When it fills, the server frees idle requests first, then fails the active ones."
+ description: "Memory the model holds for all requests at once; each active request stores its tokens in it, even when prompts start the same. Raise it to serve longer or more simultaneous conversations; when it fills, idle requests are dropped first, then active ones fail."
unsignedInteger:
minimum: "1024"
maximum: "16777216"
@@ -21,7 +21,7 @@ parameterSchema:
- parameterId: "context-per-request"
displayName: "Context per request"
visibility: "PARAMETER_VISIBILITY_BASIC"
- description: "Largest context one request may use from the total context. It reserves nothing: requests take memory from the total context as they grow. A new request reuses the start of an earlier prompt from an idle request or the prompt cache, but not from requests running at the same time."
+ description: "Largest context one request may use. It reserves nothing: requests draw from the total context as they grow. Lower it to stop one long request from filling the total context."
unsignedInteger:
minimum: "1024"
maximum: "16777216"
@@ -29,7 +29,7 @@ parameterSchema:
- parameterId: "parallel-requests"
displayName: "Parallel requests"
visibility: "PARAMETER_VISIBILITY_ADVANCED"
- description: "Requests the server generates at the same time; further requests wait in a queue. All of them share the total context, so each added request only adds its own small state."
+ description: "Requests generated at once; more wait in a queue. They share the total context, so raising it adds little memory; raise it when several people or agents use the model together."
unsignedInteger:
minimum: "1"
maximum: "64"
@@ -38,7 +38,7 @@ parameterSchema:
- parameterId: "prompt-cache-memory-mib"
displayName: "Prompt cache memory"
visibility: "PARAMETER_VISIBILITY_ADVANCED"
- description: "Memory kept to reuse repeated prompts, such as agent system prompts, without processing them again. Idle conversations move here and free their accelerator memory. It uses host memory, which unified-memory machines share with the model; 0 turns the cache off."
+ description: "Host memory used to reuse repeated prompts, such as agent system prompts, without processing them again. Raise it if the same long prompts come back often; on unified-memory machines it competes with the model, and 0 turns it off."
unsignedInteger:
minimum: "0"
maximum: "65536"
diff --git a/catalog/engines/llama-cpp.yaml b/catalog/engines/llama-cpp.yaml
index 31b2ddb1..762c7ea1 100644
--- a/catalog/engines/llama-cpp.yaml
+++ b/catalog/engines/llama-cpp.yaml
@@ -11,7 +11,7 @@ parameterSchema:
- parameterId: "total-context"
displayName: "Total context"
visibility: "PARAMETER_VISIBILITY_BASIC"
- description: "Context memory the server holds from the start and shares among the requests it generates at once. Each active request stores its own tokens in it, even when requests start with the same prompt. When it fills, the server frees idle requests first, then fails the active ones."
+ description: "Memory the model holds for all requests at once; each active request stores its tokens in it, even when prompts start the same. Raise it to serve longer or more simultaneous conversations; when it fills, idle requests are dropped first, then active ones fail."
unsignedInteger:
minimum: "1024"
maximum: "16777216"
@@ -20,7 +20,7 @@ parameterSchema:
- parameterId: "context-per-request"
displayName: "Context per request"
visibility: "PARAMETER_VISIBILITY_BASIC"
- description: "Largest context one request may use from the total context. It reserves nothing: requests take memory from the total context as they grow. A new request reuses the start of an earlier prompt from an idle request or the prompt cache, but not from requests running at the same time."
+ description: "Largest context one request may use. It reserves nothing: requests draw from the total context as they grow. Lower it to stop one long request from filling the total context."
unsignedInteger:
minimum: "1024"
maximum: "16777216"
@@ -28,7 +28,7 @@ parameterSchema:
- parameterId: "parallel-requests"
displayName: "Parallel requests"
visibility: "PARAMETER_VISIBILITY_ADVANCED"
- description: "Requests the server generates at the same time; further requests wait in a queue. All of them share the total context, so each added request only adds its own small state."
+ description: "Requests generated at once; more wait in a queue. They share the total context, so raising it adds little memory; raise it when several people or agents use the model together."
unsignedInteger:
minimum: "1"
maximum: "64"
@@ -37,7 +37,7 @@ parameterSchema:
- parameterId: "prompt-cache-memory-mib"
displayName: "Prompt cache memory"
visibility: "PARAMETER_VISIBILITY_ADVANCED"
- description: "Memory kept to reuse repeated prompts, such as agent system prompts, without processing them again. Idle conversations move here and free their accelerator memory. It uses host memory, which unified-memory machines share with the model; 0 turns the cache off."
+ description: "Host memory used to reuse repeated prompts, such as agent system prompts, without processing them again. Raise it if the same long prompts come back often; on unified-memory machines it competes with the model, and 0 turns it off."
unsignedInteger:
minimum: "0"
maximum: "65536"
diff --git a/catalog/engines/ninfer.yaml b/catalog/engines/ninfer.yaml
index 45337575..8b97faf2 100644
--- a/catalog/engines/ninfer.yaml
+++ b/catalog/engines/ninfer.yaml
@@ -11,7 +11,7 @@ parameterSchema:
- parameterId: "total-context"
displayName: "Total context"
visibility: "PARAMETER_VISIBILITY_BASIC"
- description: "Context memory the server holds from the start and shares among the requests it generates at once. Requests that start with the same prompt share its memory instead of holding their own copy. When it fills, the server first moves cached prompts to host memory, then pauses the newest requests until memory frees up."
+ description: "Memory the model holds for all requests at once; requests that start with the same prompt share it. Raise it to serve longer or more simultaneous conversations; when it fills, cached prompts move to host memory, then the newest requests pause."
unsignedInteger:
minimum: "1024"
maximum: "2097152"
@@ -20,7 +20,7 @@ parameterSchema:
- parameterId: "context-per-request"
displayName: "Context per request"
visibility: "PARAMETER_VISIBILITY_BASIC"
- description: "Largest context one request may use from the total context. It reserves nothing: requests take memory from the total context as they grow. A new request reuses the memory of a matching prompt start, from a running request, an idle conversation or the host context."
+ description: "Largest context one request may use. It reserves nothing: requests draw from the total context as they grow. Lower it to stop one long request from filling the total context."
unsignedInteger:
minimum: "1024"
maximum: "262144"
@@ -28,7 +28,7 @@ parameterSchema:
- parameterId: "parallel-requests"
displayName: "Parallel requests"
visibility: "PARAMETER_VISIBILITY_ADVANCED"
- description: "Requests the server generates at the same time; further requests wait in a queue, and the server refuses new ones once the queue is full. Each added request also adds its own state on the accelerator."
+ description: "Requests generated at once; more wait in a queue, and new ones are refused when the queue is full. Each added request uses some extra accelerator memory, so raise it only when several callers use the model together."
unsignedInteger:
minimum: "1"
maximum: "8"
@@ -37,7 +37,7 @@ parameterSchema:
- parameterId: "host-context-mib"
displayName: "Host context memory"
visibility: "PARAMETER_VISIBILITY_ADVANCED"
- description: "Host memory where idle conversations and paused requests are kept, so they resume without processing their prompt again. 0 turns it off."
+ description: "Host memory that keeps idle conversations and paused requests so they resume without processing their prompt again. Raise it for many long conversations that come back; 0 turns it off."
unsignedInteger:
minimum: "0"
maximum: "1048576"
diff --git a/catalog/engines/strata.yaml b/catalog/engines/strata.yaml
index 50fb61bc..5cf5f06e 100644
--- a/catalog/engines/strata.yaml
+++ b/catalog/engines/strata.yaml
@@ -11,7 +11,7 @@ parameterSchema:
- parameterId: "total-context"
displayName: "Total context"
visibility: "PARAMETER_VISIBILITY_BASIC"
- description: "Context memory the server holds from the start. Each parallel request keeps its own part of it, as large as the context per request, so it is always the parallel requests times the context per request."
+ description: "Memory the model holds for all requests at once. Each parallel request gets its own share as large as the context per request, so it is always the parallel requests times the context per request; change those two instead."
unsignedInteger:
minimum: "1024"
maximum: "2097152"
@@ -20,7 +20,7 @@ parameterSchema:
- parameterId: "context-per-request"
displayName: "Context per request"
visibility: "PARAMETER_VISIBILITY_BASIC"
- description: "Largest context one request may use. The server holds this much context for each parallel request from the start, whether a request uses it or not."
+ description: "Largest context one request may use. The server sets this much aside for every parallel request from the start, used or not, so keep it as small as your longest request needs."
unsignedInteger:
minimum: "1024"
maximum: "262144"
@@ -28,7 +28,7 @@ parameterSchema:
- parameterId: "parallel-requests"
displayName: "Parallel requests"
visibility: "PARAMETER_VISIBILITY_ADVANCED"
- description: "Requests the server generates at the same time; further requests wait for a free one. Each one holds its own context per request."
+ description: "Requests generated at once; more wait for a free one. Each holds its own context per request, so raising it multiplies the memory used; raise it only when several callers use the model together."
unsignedInteger:
minimum: "1"
maximum: "8"
@@ -37,7 +37,7 @@ parameterSchema:
- parameterId: "expert-vram-mib"
displayName: "Expert accelerator memory"
visibility: "PARAMETER_VISIBILITY_ADVANCED"
- description: "Accelerator memory the model's experts may use; the experts that don't fit stay in host memory. The model's sizing sets it unless you do."
+ description: "Accelerator memory the model's experts may use; experts that don't fit stay in host memory and run slower. The model's sizing sets it; raise it to speed up generation if memory allows, or lower it to free accelerator memory."
unsignedInteger:
minimum: "1"
maximum: "1048576"
diff --git a/catalog/engines/vllm.yaml b/catalog/engines/vllm.yaml
index ce4dfe10..053a0a38 100644
--- a/catalog/engines/vllm.yaml
+++ b/catalog/engines/vllm.yaml
@@ -11,7 +11,7 @@ parameterSchema:
- parameterId: "total-context"
displayName: "Total context"
visibility: "PARAMETER_VISIBILITY_BASIC"
- description: "Context memory the server holds from the start and shares among the requests it generates at once. Requests that start with the same prompt share its memory instead of holding their own copy. When it fills, the server pauses the newest requests and processes them again once memory frees up."
+ description: "Memory the model holds for all requests at once; requests that start with the same prompt share it. Raise it to serve longer or more simultaneous conversations; when it fills, the newest requests pause and are processed again once memory frees up."
unsignedInteger:
minimum: "1024"
maximum: "16777216"
@@ -20,7 +20,7 @@ parameterSchema:
- parameterId: "context-per-request"
displayName: "Context per request"
visibility: "PARAMETER_VISIBILITY_BASIC"
- description: "Largest context one request may use from the total context. It reserves nothing: requests take memory from the total context as they grow. A new request reuses the memory of a matching prompt start, from a running request, a finished one still held in the total context, or the KV offloading memory."
+ description: "Largest context one request may use. It reserves nothing: requests draw from the total context as they grow. Lower it to stop one long request from filling the total context."
unsignedInteger:
minimum: "1024"
maximum: "1048576"
@@ -28,7 +28,7 @@ parameterSchema:
- parameterId: "parallel-requests"
displayName: "Parallel requests"
visibility: "PARAMETER_VISIBILITY_ADVANCED"
- description: "Requests the server generates at the same time; further requests wait in a queue. They share the total context, and each one also holds model state of its own, so every added request adds memory."
+ description: "Requests generated at once; more wait in a queue. Each one also holds model state of its own, so every added request uses more memory; raise it when several callers use the model together."
unsignedInteger:
minimum: "1"
maximum: "1024"
@@ -37,7 +37,7 @@ parameterSchema:
- parameterId: "maximum-batched-tokens"
displayName: "Maximum batched tokens"
visibility: "PARAMETER_VISIBILITY_HIDDEN"
- description: "Maximum tokens processed across one scheduled batch."
+ description: "Most tokens processed in one step across all requests. Higher values read long prompts faster but use more memory; lower them if the model runs out of memory while reading prompts."
unsignedInteger:
minimum: "1"
maximum: "1048576"
@@ -45,7 +45,7 @@ parameterSchema:
- parameterId: "maximum-cudagraph-capture-size"
displayName: "Maximum CUDA graph capture size"
visibility: "PARAMETER_VISIBILITY_HIDDEN"
- description: "Largest sequence batch captured in a CUDA graph."
+ description: "Largest group of requests whose GPU work is recorded ahead of time for faster generation. Lower it to save a little memory and start-up time if you never run that many requests at once."
unsignedInteger:
minimum: "1"
maximum: "1024"
@@ -53,7 +53,7 @@ parameterSchema:
- parameterId: "kv-cache-memory-bytes"
displayName: "KV cache memory"
visibility: "PARAMETER_VISIBILITY_HIDDEN"
- description: "Accelerator memory reserved for the total context, in bytes."
+ description: "Accelerator memory set aside for the total context. The model's sizing sets it from the total context, so change it only to override that estimate."
unsignedInteger:
minimum: "1"
maximum: "1099511627776"
@@ -61,7 +61,7 @@ parameterSchema:
- parameterId: "kv-offloading-size-gib"
displayName: "KV offloading size"
visibility: "PARAMETER_VISIBILITY_ADVANCED"
- description: "Host memory where context evicted from the total context is kept, so a later request with the same prompt start reuses it instead of processing it again, in GiB. 0 turns it off."
+ description: "Host memory that keeps context evicted from the total context, so a later request with the same prompt start reuses it instead of processing it again. Raise it for long prompts that repeat; 0 turns it off."
unsignedInteger:
minimum: "0"
maximum: "1024"
From c7460b52bc4e95040342e2eda4e50cd488a926bc Mon Sep 17 00:00:00 2001
From: Andre-Philippe Paquet
Date: Thu, 12 Nov 2026 17:17:56 -0500
Subject: [PATCH 11/40] unreviewed: feat(frontend): add a field primitive with
a help popover
---
.../src/components/parameters/parameters.tsx | 125 ++++++-------
frontend/src/components/ui/field.test.tsx | 119 ++++++++++++
frontend/src/components/ui/field.tsx | 174 ++++++++++++++++++
.../application-deploy-dialog.test.tsx | 5 +-
4 files changed, 350 insertions(+), 73 deletions(-)
create mode 100644 frontend/src/components/ui/field.test.tsx
create mode 100644 frontend/src/components/ui/field.tsx
diff --git a/frontend/src/components/parameters/parameters.tsx b/frontend/src/components/parameters/parameters.tsx
index 48faaae7..802cda62 100644
--- a/frontend/src/components/parameters/parameters.tsx
+++ b/frontend/src/components/parameters/parameters.tsx
@@ -1,7 +1,7 @@
import type { RefObject } from "react";
import { Detail } from "@/components/ui/detail-list";
-import { FieldError } from "@/components/ui/field-error";
+import { Field } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { Switch } from "@/components/ui/switch";
@@ -37,100 +37,83 @@ export function ParametersForm({
parameters,
}: ParametersFormProps) {
return parameters.map((parameter) => {
- const error = errors.get(parameter.id);
const controlId = `${idPrefix}-${parameter.id}`;
- const hintId = `${controlId}-description`;
- const errorId = `${controlId}-error`;
const unitId = `${controlId}-unit`;
const unit = parameter.type.kind === "unsignedInteger" ? parameter.type.unit : "";
- const describedBy =
- [parameter.hint ? hintId : "", unit ? unitId : "", error ? errorId : ""]
- .filter(Boolean)
- .join(" ") || undefined;
+ const field = {
+ controlId,
+ error: errors.get(parameter.id),
+ help: parameter.hint,
+ label: parameter.label,
+ };
const register = (control: HTMLElement | null) => {
inputRefs.current.set(parameter.id, control);
};
- const hint = parameter.hint ? (
-
- {parameter.hint}
-
- ) : null;
- const fieldError = error ? {error} : null;
if (parameter.type.kind === "boolean") {
return (
-
-
- {parameter.label}
+
+ {(control) => (
onBlur(parameter.id)}
onCheckedChange={(checked) => onChange(parameter.id, String(checked))}
ref={register}
/>
-
- {hint}
- {fieldError}
-
+ )}
+
);
}
if (parameter.type.kind === "stringEnum") {
+ const { values } = parameter.type;
return (
-
- {parameter.label}
- onBlur(parameter.id)}
- onChange={(event) => onChange(parameter.id, event.target.value)}
- ref={register}
- value={inputs.get(parameter.id) ?? ""}
- >
- {parameter.type.values.map((value) => (
-
- {value}
-
- ))}
-
- {hint}
- {fieldError}
-
+
+ {(control) => (
+ onBlur(parameter.id)}
+ onChange={(event) => onChange(parameter.id, event.target.value)}
+ ref={register}
+ value={inputs.get(parameter.id) ?? ""}
+ >
+ {values.map((value) => (
+
+ {value}
+
+ ))}
+
+ )}
+
);
}
- const input = (
- onBlur(parameter.id)}
- onChange={(event) => onChange(parameter.id, event.target.value)}
- ref={register}
- value={inputs.get(parameter.id) ?? ""}
- />
- );
return (
-
-
{parameter.label}
- {unit ? (
-
- {input}
-
- {unit}
-
-
- ) : (
- input
- )}
- {hint}
- {fieldError}
-
+
+ {(control) => {
+ const input = (
+ onBlur(parameter.id)}
+ onChange={(event) => onChange(parameter.id, event.target.value)}
+ ref={register}
+ value={inputs.get(parameter.id) ?? ""}
+ />
+ );
+ return unit ? (
+
+ {input}
+
+ {unit}
+
+
+ ) : (
+ input
+ );
+ }}
+
);
});
}
diff --git a/frontend/src/components/ui/field.test.tsx b/frontend/src/components/ui/field.test.tsx
new file mode 100644
index 00000000..471cf918
--- /dev/null
+++ b/frontend/src/components/ui/field.test.tsx
@@ -0,0 +1,119 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { Field } from "@/components/ui/field";
+import { Input } from "@/components/ui/input";
+import { setTiming, timing } from "@/lib/timing";
+import { setupUser } from "@/test/user";
+
+const help = "Requests generated at once; more wait in a queue.";
+
+function ParallelRequestsField({ error }: { error?: string }) {
+ return (
+ <>
+ Use largest fit}
+ controlId="parallel"
+ error={error}
+ help={help}
+ label="Parallel requests"
+ >
+ {(control) => }
+
+ Elsewhere
+ >
+ );
+}
+
+function helpButton() {
+ return screen.getByRole("button", { name: "About Parallel requests" });
+}
+
+function helpPopover() {
+ return screen.queryByRole("dialog", { name: "About Parallel requests" });
+}
+
+describe("Field", () => {
+ it("names the control with its label and describes it with the help while the popover is closed", () => {
+ render( );
+
+ const input = screen.getByRole("textbox", { name: "Parallel requests" });
+ expect(helpPopover()).toBeNull();
+ expect(input).toHaveAccessibleDescription(help);
+ expect(input).not.toHaveAttribute("aria-invalid");
+ expect(screen.getByText(help)).not.toBeVisible();
+ expect(screen.getByRole("button", { name: "Use largest fit" })).toBeVisible();
+ });
+
+ it("describes an invalid control with its help, then its error", () => {
+ render( );
+
+ const input = screen.getByRole("textbox", { name: "Parallel requests" });
+ expect(input).toHaveAttribute("aria-invalid", "true");
+ expect(input).toHaveAccessibleDescription(`${help} Enter a whole number.`);
+ expect(screen.getByRole("alert")).toHaveTextContent("Enter a whole number.");
+ });
+
+ it("opens the help on hover and closes it when the pointer leaves", async () => {
+ const user = setupUser();
+ render( );
+
+ await user.hover(helpButton());
+ expect(
+ await screen.findByRole("dialog", { name: "About Parallel requests" }),
+ ).toHaveTextContent(help);
+
+ await user.unhover(helpButton());
+ await waitFor(() => expect(helpPopover()).toBeNull());
+ });
+
+ it("opens the help on keyboard focus, keeps it on Enter and closes it on Tab away", async () => {
+ const user = setupUser();
+ render( );
+
+ await user.tab();
+ expect(helpButton()).toHaveFocus();
+ expect(
+ await screen.findByRole("dialog", { name: "About Parallel requests" }),
+ ).toHaveTextContent(help);
+
+ await user.keyboard("{Enter}");
+ expect(helpPopover()).not.toBeNull();
+ expect(helpButton()).toHaveFocus();
+
+ await user.tab();
+ expect(screen.getByRole("textbox", { name: "Parallel requests" })).toHaveFocus();
+ await waitFor(() => expect(helpPopover()).toBeNull());
+ });
+
+ it("opens the help on click and closes it on Escape", async () => {
+ // A hover delay longer than the test keeps the click the only way the help opens.
+ setTiming({ ...timing(), tooltipOpenMs: 60_000 });
+ const user = setupUser();
+ render( );
+
+ await user.click(helpButton());
+ expect(
+ await screen.findByRole("dialog", { name: "About Parallel requests" }),
+ ).toHaveTextContent(help);
+
+ await user.keyboard("{Escape}");
+ await waitFor(() => expect(helpPopover()).toBeNull());
+ });
+
+ it("opens the help on tap and closes it on a tap outside", async () => {
+ const user = setupUser();
+ render( );
+
+ await user.pointer({ keys: "[TouchA]", target: helpButton() });
+ expect(
+ await screen.findByRole("dialog", { name: "About Parallel requests" }),
+ ).toHaveTextContent(help);
+
+ await user.pointer({
+ keys: "[TouchA]",
+ target: screen.getByRole("button", { name: "Elsewhere" }),
+ });
+ await waitFor(() => expect(helpPopover()).toBeNull());
+ });
+});
diff --git a/frontend/src/components/ui/field.tsx b/frontend/src/components/ui/field.tsx
new file mode 100644
index 00000000..ee9497b8
--- /dev/null
+++ b/frontend/src/components/ui/field.tsx
@@ -0,0 +1,174 @@
+import { CircleQuestionMark } from "lucide-react";
+import { type ReactNode, useRef, useState } from "react";
+
+import { FieldError } from "@/components/ui/field-error";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { timing } from "@/lib/timing";
+import { cn } from "@/lib/utils";
+
+/** Props a field hands its control, so the label, help and error all point at it. */
+interface FieldControlProps {
+ "aria-describedby": string | undefined;
+ "aria-invalid": "true" | undefined;
+ id: string;
+}
+
+interface FieldProps {
+ /** Buttons that change the value for the operator, shown under the control. */
+ actions?: ReactNode;
+ /** Renders the control from the props it must carry. */
+ children: (control: FieldControlProps) => ReactNode;
+ className?: string;
+ /** Element ID of the control; the description and error IDs derive from it. */
+ controlId: string;
+ /** IDs of other elements describing the control, read after the help and before the error. */
+ describedBy?: string;
+ error?: string;
+ /** What the field does and the facts that bound it, shown in the help popover. */
+ help?: ReactNode;
+ label: string;
+ /** `inline` puts a compact control, such as a switch, on the label's row. */
+ layout?: "inline" | "stacked";
+}
+
+/**
+ * One form field: its label with an optional help popover, the control, a validation error and
+ * buttons acting on the value. Help stays out of the layout; the control keeps it as its
+ * accessible description whether or not the popover is open.
+ */
+function Field({
+ actions,
+ children,
+ className,
+ controlId,
+ describedBy,
+ error,
+ help,
+ label,
+ layout = "stacked",
+}: FieldProps) {
+ const descriptionId = `${controlId}-description`;
+ const errorId = `${controlId}-error`;
+ const control = children({
+ "aria-describedby":
+ [help ? descriptionId : "", describedBy ?? "", error ? errorId : ""]
+ .filter(Boolean)
+ .join(" ") || undefined,
+ "aria-invalid": error ? "true" : undefined,
+ id: controlId,
+ });
+ const labelRow = (
+
+ {label}
+ {help ? (
+
+ {help}
+
+ ) : null}
+
+ );
+
+ return (
+
+ {layout === "inline" ? (
+
+ {labelRow}
+ {control}
+
+ ) : (
+ <>
+ {labelRow}
+ {control}
+ >
+ )}
+ {error ?
{error} : null}
+ {actions ?
{actions}
: null}
+
+ );
+}
+
+/**
+ * The `?` button beside a field label and its help popover. It opens on hover, keyboard focus,
+ * click and tap. Because the popover unmounts while closed, a hidden copy of the help carries
+ * `descriptionId` for the control's `aria-describedby`.
+ */
+function FieldHelp({
+ children,
+ descriptionId,
+ label,
+}: {
+ children: ReactNode;
+ descriptionId: string;
+ label: string;
+}) {
+ const [open, setOpen] = useState(false);
+ const openedByFocus = useRef(false);
+ const pointerDown = useRef(false);
+
+ const change = (nextOpen: boolean) => {
+ openedByFocus.current = false;
+ setOpen(nextOpen);
+ };
+
+ return (
+ <>
+ {
+ // A press on a trigger that keyboard focus opened keeps the help open instead of
+ // toggling it shut.
+ if (!nextOpen && openedByFocus.current && details.reason === "trigger-press") {
+ openedByFocus.current = false;
+ return;
+ }
+ change(nextOpen);
+ }}
+ open={open}
+ >
+ {
+ pointerDown.current = false;
+ if (openedByFocus.current) change(false);
+ }}
+ onFocus={() => {
+ // A pointer press focuses the button too; its click opens the help instead.
+ if (pointerDown.current || open) return;
+ openedByFocus.current = true;
+ setOpen(true);
+ }}
+ onPointerDown={() => {
+ pointerDown.current = true;
+ }}
+ onPointerUp={() => {
+ pointerDown.current = false;
+ }}
+ onPointerCancel={() => {
+ pointerDown.current = false;
+ }}
+ openOnHover
+ type="button"
+ >
+
+
+
+ {children}
+
+
+
+ {children}
+
+ >
+ );
+}
+
+export { Field, FieldHelp };
+export type { FieldControlProps, FieldProps };
diff --git a/frontend/src/features/applications/application-deploy-dialog.test.tsx b/frontend/src/features/applications/application-deploy-dialog.test.tsx
index dffc4a45..c000adca 100644
--- a/frontend/src/features/applications/application-deploy-dialog.test.tsx
+++ b/frontend/src/features/applications/application-deploy-dialog.test.tsx
@@ -1414,8 +1414,9 @@ describe("Application deployment dialog parameters", () => {
const onSubmit = renderDialog();
const parameters = screen.getByRole("region", { name: "Parameters" });
- expect(within(parameters).getByRole("textbox", { name: "Workers" })).toHaveValue("2");
- expect(within(parameters).getByText("1–8")).toBeVisible();
+ const workers = within(parameters).getByRole("textbox", { name: "Workers" });
+ expect(workers).toHaveValue("2");
+ expect(workers).toHaveAccessibleDescription("1–8");
expect(within(parameters).queryByRole("combobox", { name: "VRAM mode" })).toBeNull();
await user.click(screen.getByRole("checkbox", { name: "Advanced" }));
From 3237f88b69ca092008bd33aea74ffac56b63a01a Mon Sep 17 00:00:00 2001
From: Andre-Philippe Paquet
Date: Thu, 12 Nov 2026 17:26:04 -0500
Subject: [PATCH 12/40] unreviewed: feat(frontend): move deploy dialog help
into field popovers
---
.../components/parameters/parameter-view.ts | 4 +-
.../components/parameters/parameters.test.tsx | 2 +-
.../src/components/parameters/parameters.tsx | 25 +++--
frontend/src/components/ui/combobox.test.tsx | 32 +++++-
frontend/src/components/ui/combobox.tsx | 72 ++++++++++---
frontend/src/components/ui/field.test.tsx | 22 ++++
frontend/src/components/ui/field.tsx | 22 ++--
frontend/src/components/ui/popover.tsx | 4 +-
.../application-deploy-dialog.test.tsx | 6 +-
.../application-deploy-dialog.tsx | 86 +++++++--------
.../applications/application-parameters.ts | 7 +-
.../catalog/model-catalog-page.test.tsx | 6 +-
.../deploy/model-deploy-context-step.tsx | 33 +++---
.../deploy/model-deploy-dialog.test.tsx | 41 +++++--
.../deploy/model-deploy-runtime-step.tsx | 5 -
.../deploy/model-deploy-runtime.test.tsx | 41 +++++--
.../deploy/model-deploy-scenario.test.tsx | 53 +++++----
.../deploy/model-deploy-target-step.tsx | 2 +-
.../models/deployment-exposure-fields.tsx | 15 ++-
.../models/runtime/context-size-field.tsx | 102 +++++++++---------
.../models/runtime/engine-parameter-values.ts | 14 ---
.../models/runtime/engine-parameters.tsx | 4 +-
.../runtime/speculative-decoding-field.tsx | 98 +++++++++--------
.../upstream/upstream-endpoint-fields.tsx | 94 ++++++++--------
.../models/upstream/upstream-model.test.tsx | 37 ++++---
frontend/src/test/field-help.ts | 19 ++++
26 files changed, 521 insertions(+), 325 deletions(-)
create mode 100644 frontend/src/test/field-help.ts
diff --git a/frontend/src/components/parameters/parameter-view.ts b/frontend/src/components/parameters/parameter-view.ts
index 3ed8b17f..7611ea30 100644
--- a/frontend/src/components/parameters/parameter-view.ts
+++ b/frontend/src/components/parameters/parameter-view.ts
@@ -2,8 +2,8 @@ import { formatQuantity } from "@/lib/format";
/** One typed parameter as the shared form and details render it, independent of its owner. */
export interface ParameterView {
- /** A short line under the field; absent when the label and value say enough. */
- hint?: string;
+ /** What the parameter does, shown in the field's help; absent when the label says enough. */
+ description?: string;
id: string;
label: string;
type: ParameterViewType;
diff --git a/frontend/src/components/parameters/parameters.test.tsx b/frontend/src/components/parameters/parameters.test.tsx
index 7d5d1259..e4e16b2d 100644
--- a/frontend/src/components/parameters/parameters.test.tsx
+++ b/frontend/src/components/parameters/parameters.test.tsx
@@ -38,7 +38,7 @@ describe("ParametersForm", () => {
const cache = screen.getByRole("textbox", { name: "Prompt cache memory" });
expect(cache).toHaveValue("2048");
expect(cache.parentElement?.textContent).toBe("MiB");
- expect(cache).toHaveAccessibleDescription("MiB");
+ expect(cache).not.toHaveAccessibleDescription();
const slots = screen.getByRole("textbox", { name: "Slots" });
expect(slots.parentElement?.textContent).toBe("Slots");
expect(slots).not.toHaveAccessibleDescription();
diff --git a/frontend/src/components/parameters/parameters.tsx b/frontend/src/components/parameters/parameters.tsx
index 802cda62..01fb30d5 100644
--- a/frontend/src/components/parameters/parameters.tsx
+++ b/frontend/src/components/parameters/parameters.tsx
@@ -38,12 +38,11 @@ export function ParametersForm({
}: ParametersFormProps) {
return parameters.map((parameter) => {
const controlId = `${idPrefix}-${parameter.id}`;
- const unitId = `${controlId}-unit`;
const unit = parameter.type.kind === "unsignedInteger" ? parameter.type.unit : "";
const field = {
controlId,
error: errors.get(parameter.id),
- help: parameter.hint,
+ help: parameter.description || undefined,
label: parameter.label,
};
const register = (control: HTMLElement | null) => {
@@ -90,7 +89,13 @@ export function ParametersForm({
}
return (
-
+ : null
+ }
+ key={parameter.id}
+ >
{(control) => {
const input = (
{input}
-
- {unit}
-
+ {unit}
) : (
input
@@ -118,6 +121,16 @@ export function ParametersForm({
});
}
+/** The accepted range of a number parameter, with its unit when it has one. */
+function RangeFact({ type }: { type: { maximum: bigint; minimum: bigint; unit: string } }) {
+ return (
+
+ Range {`${type.minimum}–${type.maximum}`}
+ {type.unit ? ` ${type.unit}` : ""}
+
+ );
+}
+
/** Detail rows for retained parameter values, in view order; a missing or mistyped value is skipped. */
export function ParametersDetails({
parameters,
diff --git a/frontend/src/components/ui/combobox.test.tsx b/frontend/src/components/ui/combobox.test.tsx
index f422c211..3748b2fe 100644
--- a/frontend/src/components/ui/combobox.test.tsx
+++ b/frontend/src/components/ui/combobox.test.tsx
@@ -38,7 +38,7 @@ function SingleCombobox({
loading={loading}
loadingMessageVisible={loadingMessageVisible}
searchable={searchable}
- description="Choose one deployment."
+ help="Choose one deployment."
/>
{value ?? "none"}
>
@@ -93,6 +93,36 @@ function MultipleCombobox({
}
describe("Combobox", () => {
+ it("describes the input with its help and shows it in a popover beside the label", async () => {
+ const user = setupUser();
+ render( );
+
+ expect(screen.getByRole("combobox", { name: "Deployment" })).toHaveAccessibleDescription(
+ "Choose one deployment.",
+ );
+ await user.click(screen.getByRole("button", { name: "About Deployment" }));
+ expect(await screen.findByRole("dialog", { name: "About Deployment" })).toHaveTextContent(
+ "Choose one deployment.",
+ );
+ });
+
+ it("shows its status under the input and in the input's description", () => {
+ render(
+ {}}
+ options={options}
+ status="Loading deployments…"
+ value={null}
+ />,
+ );
+
+ expect(screen.getByText("Loading deployments…")).toBeVisible();
+ expect(screen.getByRole("combobox", { name: "Deployment" })).toHaveAccessibleDescription(
+ "Loading deployments…",
+ );
+ });
+
it("filters options by label and stable ID", async () => {
const user = setupUser();
render( );
diff --git a/frontend/src/components/ui/combobox.tsx b/frontend/src/components/ui/combobox.tsx
index 12ff9d66..0563af0a 100644
--- a/frontend/src/components/ui/combobox.tsx
+++ b/frontend/src/components/ui/combobox.tsx
@@ -2,6 +2,7 @@ import { Combobox as ComboboxPrimitive } from "@base-ui/react/combobox";
import { CheckIcon, ChevronDownIcon, XIcon } from "lucide-react";
import * as React from "react";
+import { FieldHelp } from "@/components/ui/field";
import { useInspectorFloatingLayerRegistration } from "@/components/ui/inspector-shell";
import { ResourceMarker } from "@/components/ui/resource-marker";
import { StatusDot } from "@/components/ui/status-badge";
@@ -31,9 +32,10 @@ type ComboboxSharedProps = {
options: readonly ComboboxOption[];
ariaInvalid?: boolean;
className?: string;
- description?: string;
disabled?: boolean;
errorDescriptionId?: string;
+ /** What the field does, shown in a help popover beside the label. */
+ help?: string;
/** Keeps the label for assistive technology only, where the surrounding layout names the field. */
hideLabel?: boolean;
inputRef?: React.Ref;
@@ -41,6 +43,8 @@ type ComboboxSharedProps = {
loadingMessageVisible?: boolean;
onBlur?: React.FocusEventHandler;
placeholder?: string;
+ /** The field's current state, such as that its options are loading, shown under the input. */
+ status?: string;
};
type SingleComboboxProps = ComboboxSharedProps & {
@@ -77,9 +81,9 @@ function SingleCombobox({
ariaInvalid,
className,
clearable = true,
- description,
disabled,
errorDescriptionId,
+ help,
hideLabel = false,
inputRef,
label,
@@ -90,12 +94,18 @@ function SingleCombobox({
options,
placeholder,
searchable = true,
+ status,
value,
}: SingleComboboxProps) {
const descriptionId = React.useId();
const inputId = React.useId();
const labelId = React.useId();
- const describedBy = [description ? descriptionId : undefined, errorDescriptionId]
+ const statusId = React.useId();
+ const describedBy = [
+ help ? descriptionId : undefined,
+ status ? statusId : undefined,
+ errorDescriptionId,
+ ]
.filter(Boolean)
.join(" ");
const registerFloatingLayer = useInspectorFloatingLayerRegistration();
@@ -111,7 +121,14 @@ function SingleCombobox({
value={value}
>
-
+
- {description ? (
-
- {description}
+ {status ? (
+
+ {status}
) : null}
@@ -152,9 +169,9 @@ function MultipleCombobox({
ariaInvalid,
className,
closeOnSelect = false,
- description,
disabled,
errorDescriptionId,
+ help,
hideLabel = false,
inputRef,
label,
@@ -164,12 +181,18 @@ function MultipleCombobox({
onValueChange,
options,
placeholder = "Search options",
+ status,
value,
}: MultipleComboboxProps) {
const descriptionId = React.useId();
const inputId = React.useId();
const labelId = React.useId();
- const describedBy = [description ? descriptionId : undefined, errorDescriptionId]
+ const statusId = React.useId();
+ const describedBy = [
+ help ? descriptionId : undefined,
+ status ? statusId : undefined,
+ errorDescriptionId,
+ ]
.filter(Boolean)
.join(" ");
const optionLabel = createOptionLabel(options);
@@ -206,7 +229,14 @@ function MultipleCombobox({
value={value}
>
-
+
{/* Chips and the input wrap as one flow beside the trigger, which stays on the first line.
The vertical padding centers the first line on the taller trigger. */}
@@ -253,9 +283,9 @@ function MultipleCombobox({
- {description ? (
-
- {description}
+ {status ? (
+
+ {status}
) : null}
@@ -275,17 +305,21 @@ function MultipleCombobox({
* technology, label included, and only a label reference survives that hiding.
*/
function ComboboxLabel({
+ descriptionId,
+ help,
hidden,
id,
inputId,
label,
}: {
+ descriptionId: string;
+ help?: string;
hidden: boolean;
id: string;
inputId: string;
label: string;
}) {
- return (
+ const labelElement = (
);
+ if (!help) return labelElement;
+
+ return (
+
+ {labelElement}
+
+ {help}
+
+
+ );
}
/**
diff --git a/frontend/src/components/ui/field.test.tsx b/frontend/src/components/ui/field.test.tsx
index 471cf918..9467b3bb 100644
--- a/frontend/src/components/ui/field.test.tsx
+++ b/frontend/src/components/ui/field.test.tsx
@@ -54,6 +54,28 @@ describe("Field", () => {
expect(screen.getByRole("alert")).toHaveTextContent("Enter a whole number.");
});
+ it("shows facts only in the popover, not in the accessible description", async () => {
+ const user = setupUser();
+ render(
+ Range 1–64 requests
}
+ help={help}
+ label="Parallel requests"
+ >
+ {(control) => }
+ ,
+ );
+
+ expect(screen.getByRole("textbox", { name: "Parallel requests" })).toHaveAccessibleDescription(
+ help,
+ );
+ await user.click(helpButton());
+ expect(
+ await screen.findByRole("dialog", { name: "About Parallel requests" }),
+ ).toHaveTextContent(`${help}Range 1–64 requests`);
+ });
+
it("opens the help on hover and closes it when the pointer leaves", async () => {
const user = setupUser();
render( );
diff --git a/frontend/src/components/ui/field.tsx b/frontend/src/components/ui/field.tsx
index ee9497b8..2e2c331f 100644
--- a/frontend/src/components/ui/field.tsx
+++ b/frontend/src/components/ui/field.tsx
@@ -24,6 +24,8 @@ interface FieldProps {
/** IDs of other elements describing the control, read after the help and before the error. */
describedBy?: string;
error?: string;
+ /** Facts about the value, such as its range and unit, shown only in the popover. */
+ facts?: ReactNode;
/** What the field does and the facts that bound it, shown in the help popover. */
help?: ReactNode;
label: string;
@@ -43,6 +45,7 @@ function Field({
controlId,
describedBy,
error,
+ facts,
help,
label,
layout = "stacked",
@@ -60,8 +63,8 @@ function Field({
const labelRow = (
{label}
- {help ? (
-
+ {help || facts ? (
+
{help}
) : null}
@@ -95,10 +98,13 @@ function Field({
function FieldHelp({
children,
descriptionId,
+ facts,
label,
}: {
- children: ReactNode;
+ children?: ReactNode;
descriptionId: string;
+ /** Facts shown after the help in the popover but left out of the accessible description. */
+ facts?: ReactNode;
label: string;
}) {
const [open, setOpen] = useState(false);
@@ -156,16 +162,20 @@ function FieldHelp({
align="start"
aria-label={`About ${label}`}
className="w-auto max-w-xs gap-1.5 text-xs"
+ collisionPadding={16}
finalFocus={false}
initialFocus={false}
side="top"
>
{children}
+ {facts ? {facts}
: null}
-
- {children}
-
+ {children ? (
+
+ {children}
+
+ ) : null}
>
);
}
diff --git a/frontend/src/components/ui/popover.tsx b/frontend/src/components/ui/popover.tsx
index 6f8eb3aa..ffa22335 100644
--- a/frontend/src/components/ui/popover.tsx
+++ b/frontend/src/components/ui/popover.tsx
@@ -29,13 +29,14 @@ function PopoverContent({
alignOffset = 0,
anchor,
className,
+ collisionPadding,
side = "bottom",
sideOffset = 4,
...props
}: PopoverPrimitive.Popup.Props &
Pick<
PopoverPrimitive.Positioner.Props,
- "align" | "alignOffset" | "anchor" | "side" | "sideOffset"
+ "align" | "alignOffset" | "anchor" | "collisionPadding" | "side" | "sideOffset"
>) {
return (
@@ -44,6 +45,7 @@ function PopoverContent({
alignOffset={alignOffset}
anchor={anchor}
className="isolate z-popup"
+ collisionPadding={collisionPadding}
side={side}
sideOffset={sideOffset}
>
diff --git a/frontend/src/features/applications/application-deploy-dialog.test.tsx b/frontend/src/features/applications/application-deploy-dialog.test.tsx
index c000adca..6051f738 100644
--- a/frontend/src/features/applications/application-deploy-dialog.test.tsx
+++ b/frontend/src/features/applications/application-deploy-dialog.test.tsx
@@ -1416,7 +1416,11 @@ describe("Application deployment dialog parameters", () => {
const parameters = screen.getByRole("region", { name: "Parameters" });
const workers = within(parameters).getByRole("textbox", { name: "Workers" });
expect(workers).toHaveValue("2");
- expect(workers).toHaveAccessibleDescription("1–8");
+ await user.click(within(parameters).getByRole("button", { name: "About Workers" }));
+ expect(await screen.findByRole("dialog", { name: "About Workers" })).toHaveTextContent(
+ "Range 1–8",
+ );
+ await user.keyboard("{Escape}");
expect(within(parameters).queryByRole("combobox", { name: "VRAM mode" })).toBeNull();
await user.click(screen.getByRole("checkbox", { name: "Advanced" }));
diff --git a/frontend/src/features/applications/application-deploy-dialog.tsx b/frontend/src/features/applications/application-deploy-dialog.tsx
index e19ba329..bfcece51 100644
--- a/frontend/src/features/applications/application-deploy-dialog.tsx
+++ b/frontend/src/features/applications/application-deploy-dialog.tsx
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { parameterDisplayValue } from "@/components/parameters/parameter-view";
import { ParametersForm } from "@/components/parameters/parameters";
import { Detail, DetailList } from "@/components/ui/detail-list";
+import { Field } from "@/components/ui/field";
import { FieldError } from "@/components/ui/field-error";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { Notice } from "@/components/ui/notice";
@@ -437,55 +438,44 @@ export function ApplicationDeployDialog({
{step === firstStep && routeBindings.length ? (
-
-
- Inference route
-
-
setSelectedRouteId(event.target.value)}
- value={selectedRouteId}
- >
- No inference route
- {chatRoutes.map((view) =>
- view.route ? (
-
- {routeOptionLabel(view)}
+
+ {(control) => (
+ setSelectedRouteId(event.target.value)}
+ value={selectedRouteId}
+ >
+ No inference route
+ {chatRoutes.map((view) =>
+ view.route ? (
+
+ {routeOptionLabel(view)}
+
+ ) : null,
+ )}
+ {selectedRouteId && !selectedRoute ? (
+
+ Removed route
- ) : null,
- )}
- {selectedRouteId && !selectedRoute ? (
-
- Removed route
-
- ) : null}
-
-
- Optional. A linked route passes its current name to the application. Without one,
- the application still reaches the inference API.
-
- {routeSelectionError ? (
-
- {routeSelectionError}
-
- ) : null}
+ ) : null}
+
+ )}
+
+
{selectedRouteServingNotice ? (
{selectedRouteServingNotice}
diff --git a/frontend/src/features/applications/application-parameters.ts b/frontend/src/features/applications/application-parameters.ts
index aa2bb832..a7c9d8d0 100644
--- a/frontend/src/features/applications/application-parameters.ts
+++ b/frontend/src/features/applications/application-parameters.ts
@@ -30,19 +30,18 @@ export function applicationParameterFields(
{
advanced,
defaultValue: type.value.defaultValue,
- view: { ...base, hint: definition.description, type: { kind: "boolean" } },
+ view: { ...base, description: definition.description, type: { kind: "boolean" } },
},
];
case "unsignedInteger": {
const { defaultValue, maximum, minimum, unit } = type.value;
- const range = `${minimum}–${maximum}${unit ? ` ${unit}` : ""}`;
return [
{
advanced,
defaultValue,
view: {
...base,
- hint: definition.description ? `${definition.description} ${range}` : range,
+ description: definition.description,
type: { kind: "unsignedInteger", maximum, minimum, unit },
},
},
@@ -55,7 +54,7 @@ export function applicationParameterFields(
defaultValue: type.value.defaultValue,
view: {
...base,
- hint: definition.description,
+ description: definition.description,
type: { kind: "stringEnum", values: type.value.values },
},
},
diff --git a/frontend/src/features/models/catalog/model-catalog-page.test.tsx b/frontend/src/features/models/catalog/model-catalog-page.test.tsx
index 85ccda91..c7f74d90 100644
--- a/frontend/src/features/models/catalog/model-catalog-page.test.tsx
+++ b/frontend/src/features/models/catalog/model-catalog-page.test.tsx
@@ -532,9 +532,9 @@ describe("ModelCatalogPage deploy", () => {
);
const takenLocal =
- 'An inference route named "test-model" already exists. Change the route or the deployment name.';
+ 'The model name callers send in requests. It follows the deployment name until you change it. An inference route named "test-model" already exists. Change the route or the deployment name.';
const takenUpstream =
- 'An inference route named "private-model" already exists. Change the route or the deployment name.';
+ 'The model name callers send in requests. It follows the deployment name until you change it. An inference route named "private-model" already exists. Change the route or the deployment name.';
it("shows the taken route on the route field and blocks Deploy after a rejected local deploy", async () => {
const { dialog, user } = await openLocalDialog(conflict);
@@ -559,7 +559,7 @@ describe("ModelCatalogPage deploy", () => {
expect(
within(dialog).getByRole("textbox", { name: "Route name" }),
).toHaveAccessibleDescription(
- "An inference route with this name already exists. Change the route or the deployment name.",
+ "The model name callers send in requests. It follows the deployment name until you change it. An inference route with this name already exists. Change the route or the deployment name.",
),
);
});
diff --git a/frontend/src/features/models/deploy/model-deploy-context-step.tsx b/frontend/src/features/models/deploy/model-deploy-context-step.tsx
index a33db7bb..0205c1f2 100644
--- a/frontend/src/features/models/deploy/model-deploy-context-step.tsx
+++ b/frontend/src/features/models/deploy/model-deploy-context-step.tsx
@@ -11,7 +11,6 @@ import type {
ContextSizeRange,
} from "@/features/models/runtime/context-size-input";
import {
- engineParameterHelp,
maximumContext,
runtimeParameterGroups,
} from "@/features/models/runtime/engine-parameter-values";
@@ -21,7 +20,7 @@ import type { FieldErrors } from "@/lib/form-validation";
import { formatCompactCount } from "@/lib/format";
interface ModelDeployContextStepProps {
- /** The largest total context that fits the selected target, shown beside the total context. */
+ /** The largest total context that fits the selected target, shown in the total context's help. */
contextHint?: string;
disabled: boolean;
engineParameterRefs: RefObject>;
@@ -71,9 +70,10 @@ export function ModelDeployContextStep({
const contextField = (
parameter: ParameterDefinition,
field: {
+ actions?: ReactNode;
emptyValue?: number;
- help?: ReactNode;
- hint?: string;
+ facts?: readonly string[];
+ help?: string;
largestFit?: ContextSizeLargestFit;
} = {},
) => (
@@ -100,15 +100,14 @@ export function ModelDeployContextStep({
)}
{totalContext
? contextField(totalContext, {
- help: engineParameterHelp(totalContext),
- hint: contextHint,
+ facts: contextHint ? [contextHint] : undefined,
+ help: totalContext.description,
largestFit,
})
: null}
{contextPerRequest
? contextField(contextPerRequest, {
- emptyValue: filledContextPerRequest,
- help: inputs.get(contextPerRequest.parameterId) ? (
+ actions: inputs.get(contextPerRequest.parameterId) ? (
Follow the total
- ) : (
- "Filled from the total context, up to the model maximum."
- ),
- hint:
- maximum === undefined
- ? undefined
- : `Up to ${formatCompactCount(maximum)} (model maximum)`,
+ ) : undefined,
+ emptyValue: filledContextPerRequest,
+ facts: [
+ ...(maximum === undefined
+ ? []
+ : [`Up to ${formatCompactCount(maximum)} (model maximum)`]),
+ ...(inputs.get(contextPerRequest.parameterId)
+ ? []
+ : ["Left empty, it follows the total context."]),
+ ],
+ help: contextPerRequest.description,
})
: null}
diff --git a/frontend/src/features/models/deploy/model-deploy-dialog.test.tsx b/frontend/src/features/models/deploy/model-deploy-dialog.test.tsx
index c4f875c7..f0af8f5a 100644
--- a/frontend/src/features/models/deploy/model-deploy-dialog.test.tsx
+++ b/frontend/src/features/models/deploy/model-deploy-dialog.test.tsx
@@ -69,6 +69,7 @@ import {
} from "@/gen/dotcore/site/v1/service_pb";
import { productionTiming, setTiming } from "@/lib/timing";
import type { FakeResourceWatch } from "@/test/fake-resource-watch";
+import { readFieldHelpFacts } from "@/test/field-help";
import { expectResourceMarker, inspectorTitle } from "@/test/resource-marker";
import { setupUser } from "@/test/user";
@@ -1282,11 +1283,17 @@ describe("ModelDeployDialog", () => {
expect(screen.getByText("Step 2 of 5 — Target")).toBeVisible();
expect(screen.getByRole("combobox", { name: "Execution target" })).toBeDisabled();
expect(screen.queryByText("Loading configured execution targets…")).not.toBeInTheDocument();
+ expect(
+ screen.getByRole("combobox", { name: "Execution target" }),
+ ).not.toHaveAccessibleDescription();
await act(async () => {
await vi.advanceTimersByTimeAsync(productionTiming.loadingRevealMs);
});
expect(screen.getByText("Loading configured execution targets…")).toBeVisible();
+ expect(
+ screen.getByRole("combobox", { name: "Execution target" }),
+ ).toHaveAccessibleDescription("Loading configured execution targets…");
await act(async () => {
resolvePools({ executionTargets: [poolTarget(), workerTarget()] });
@@ -1294,6 +1301,9 @@ describe("ModelDeployDialog", () => {
});
expect(screen.getByRole("combobox", { name: "Execution target" })).toBeEnabled();
expect(screen.getByText("Loading configured execution targets…")).toBeVisible();
+ expect(
+ screen.getByRole("combobox", { name: "Execution target" }),
+ ).toHaveAccessibleDescription("Loading configured execution targets…");
await act(async () => {
await vi.advanceTimersByTimeAsync(productionTiming.loadingMinimumVisibleMs);
@@ -1338,6 +1348,9 @@ describe("ModelDeployDialog", () => {
expect(
screen.getByText("Configured targets are visible, but none are deployable."),
).toBeVisible();
+ expect(
+ screen.getByRole("combobox", { name: "Execution target" }),
+ ).toHaveAccessibleDescription("Configured targets are visible, but none are deployable.");
await act(async () => {
await watch.tick();
@@ -1345,6 +1358,9 @@ describe("ModelDeployDialog", () => {
expect(
screen.queryByText("Configured targets are visible, but none are deployable."),
).not.toBeInTheDocument();
+ expect(
+ screen.getByRole("combobox", { name: "Execution target" }),
+ ).not.toHaveAccessibleDescription();
// The operator never chose, so the default target comes back with its availability.
expect(screen.getByRole("combobox", { name: "Execution target" })).toHaveValue(
"node / NVIDIA GeForce RTX 5090",
@@ -1513,7 +1529,7 @@ describe("ModelDeployDialog", () => {
expect(screen.getByRole("button", { name: "Deploy" })).toBeDisabled();
expect(route).toHaveAttribute("aria-invalid", "true");
expect(route).toHaveAccessibleDescription(
- 'An inference route named "test-model" already exists. Change the route or the deployment name.',
+ 'The model name callers send in requests. It follows the deployment name until you change it. An inference route named "test-model" already exists. Change the route or the deployment name.',
);
await user.type(screen.getByRole("textbox", { name: "Deployment name" }), " 2");
@@ -1545,7 +1561,9 @@ describe("ModelDeployDialog", () => {
await user.clear(route);
await user.type(route, "taken");
expect(screen.getByRole("button", { name: "Deploy" })).toBeDisabled();
- expect(route).toHaveAccessibleDescription('An inference route named "taken" already exists.');
+ expect(route).toHaveAccessibleDescription(
+ 'The model name callers send in requests. It follows the deployment name until you change it. An inference route named "taken" already exists.',
+ );
});
it("shows a backend route conflict on the route field until the route changes", async () => {
@@ -1557,7 +1575,7 @@ describe("ModelDeployDialog", () => {
const route = screen.getByRole("textbox", { name: "Route name" });
expect(screen.getByRole("button", { name: "Deploy" })).toBeDisabled();
expect(route).toHaveAccessibleDescription(
- 'An inference route named "test-model" already exists. Change the route or the deployment name.',
+ 'The model name callers send in requests. It follows the deployment name until you change it. An inference route named "test-model" already exists. Change the route or the deployment name.',
);
await user.clear(route);
@@ -1632,17 +1650,19 @@ describe("ModelDeployDialog", () => {
expect(route).toBeDisabled();
expect(screen.queryByRole("button", { name: "Follow the deployment name" })).toBeNull();
expect(route).toHaveAccessibleDescription(
- "Hidden: no route of its own. Reachable only through other routes.",
+ "The model name callers send in requests. It follows the deployment name until you change it. Hidden: no route of its own. Reachable only through other routes.",
);
await user.click(screen.getByRole("radio", { name: "Direct" }));
expect(screen.queryByRole("radio", { name: "Hidden" })).not.toBeInTheDocument();
- expect(route).not.toHaveAccessibleDescription();
expect(route).toBeEnabled();
expect(screen.getByRole("button", { name: "Follow the deployment name" })).toHaveAttribute(
"aria-pressed",
"true",
);
+ expect(route).toHaveAccessibleDescription(
+ "The model name callers send in requests. It follows the deployment name until you change it.",
+ );
});
it("follows the deployment name until edited, then keeps the custom value", async () => {
@@ -1721,7 +1741,7 @@ describe("ModelDeployDialog", () => {
await user.tab();
expect(route).toHaveAttribute("aria-invalid", "true");
expect(route).toHaveAccessibleDescription(
- "Route name must start with a letter and contain only lowercase letters, numbers, hyphens, and periods.",
+ "The model name callers send in requests. It follows the deployment name until you change it. Route name must start with a letter and contain only lowercase letters, numbers, hyphens, and periods.",
);
});
@@ -1899,8 +1919,7 @@ describe("ModelDeployDialog", () => {
await expandSection(user, "Other parameters (1)");
const prefixCaching = screen.getByRole("switch", { name: "Prefix caching" });
expect(prefixCaching).not.toBeChecked();
- // The schema description documents the engine and stays out of the field.
- expect(prefixCaching).not.toHaveAccessibleDescription();
+ expect(prefixCaching).toHaveAccessibleDescription("Caches reusable prompt prefixes.");
await user.click(prefixCaching);
const expectedConfiguration = {
@@ -2145,7 +2164,7 @@ describe("ModelDeployDialog", () => {
).toBeVisible();
expect(context).toHaveAttribute("aria-invalid", "true");
expect(context).toHaveAccessibleDescription(
- "Memory the model holds for all requests at once. Total context must be an integer from 1024 to 32768 tokens.",
+ "Total context must be an integer from 1024 to 32768 tokens.",
);
await user.click(screen.getByRole("button", { name: "Next" }));
@@ -2757,7 +2776,9 @@ describe("ModelDeployDialog", () => {
).toEqual(["fit"]);
await user.click(screen.getByRole("button", { name: "Next" }));
- expect(screen.getByText("Up to 16k on node / NVIDIA GeForce RTX 5090")).toBeVisible();
+ expect(await readFieldHelpFacts("Total context")).toContain(
+ "Up to 16k on node / NVIDIA GeForce RTX 5090",
+ );
expect(screen.getByRole("textbox", { name: /Total context/ })).toHaveValue("16384");
expect(screen.queryByRole("note")).not.toBeInTheDocument();
diff --git a/frontend/src/features/models/deploy/model-deploy-runtime-step.tsx b/frontend/src/features/models/deploy/model-deploy-runtime-step.tsx
index b52d5550..fb1f14f3 100644
--- a/frontend/src/features/models/deploy/model-deploy-runtime-step.tsx
+++ b/frontend/src/features/models/deploy/model-deploy-runtime-step.tsx
@@ -147,11 +147,6 @@ export function ModelDeployRuntimeStep({
className="min-w-0 rounded-lg border border-border bg-card p-4"
title="Host memory"
>
- {hostKvOffloadSharesMemory ? null : (
-
- Reserved in host memory. Accelerator memory is unchanged.
-
- )}
{parametersForm(groups.hostMemory)}
) : null}
diff --git a/frontend/src/features/models/deploy/model-deploy-runtime.test.tsx b/frontend/src/features/models/deploy/model-deploy-runtime.test.tsx
index 9be52aa0..b9279f8a 100644
--- a/frontend/src/features/models/deploy/model-deploy-runtime.test.tsx
+++ b/frontend/src/features/models/deploy/model-deploy-runtime.test.tsx
@@ -26,6 +26,7 @@ import {
ExecutionTargetWorkloadKind,
NodeObservationState,
} from "@/gen/dotcore/site/v1/service_pb";
+import { readFieldHelpFacts } from "@/test/field-help";
import { setupUser } from "@/test/user";
import { ModelDeployDialog } from "./model-deploy-dialog";
@@ -46,21 +47,26 @@ describe("ModelDeployDialog Context step", () => {
["engine-parameter-context-per-request", ""],
]);
expect(screen.getByRole("textbox", { name: "Total context" })).toHaveAccessibleDescription(
- "Memory the model holds for all requests at once.",
+ "Context memory the server holds from the start and shares among the requests it generates at once. Each active request stores its own tokens in it, even when requests start with the same prompt. When it fills, the server frees idle requests first, then fails the active ones.",
);
const perRequest = screen.getByRole("textbox", { name: "Context per request" });
expect(perRequest).toHaveAccessibleDescription(
- "Filled from the total context, up to the model maximum.",
+ "Largest context one request may use from the total context. It reserves nothing: requests take memory from the total context as they grow. A new request reuses the start of an earlier prompt from an idle request or the prompt cache, but not from requests running at the same time.",
);
+ await user.click(screen.getByRole("button", { name: "About Context per request" }));
+ expect(
+ await screen.findByRole("dialog", { name: "About Context per request" }),
+ ).toHaveTextContent("Left empty, it follows the total context.");
+ await user.keyboard("{Escape}");
// A set context per request offers to follow the total again.
await user.type(perRequest, "8192");
await user.tab();
await user.click(screen.getByRole("button", { name: "Follow the total" }));
expect(perRequest).toHaveValue("");
- expect(
- screen.getByText("Filled from the total context, up to the model maximum."),
- ).toBeVisible();
+ expect(await readFieldHelpFacts("Context per request")).toContain(
+ "Left empty, it follows the total context.",
+ );
await user.click(preset("64k"));
await user.click(screen.getByRole("checkbox", { name: "Advanced" }));
@@ -104,7 +110,7 @@ describe("ModelDeployDialog Context step", () => {
expect(await screen.findByText(error)).toBeVisible();
expect(input).toHaveAttribute("aria-invalid", "true");
expect(input).toHaveAccessibleDescription(
- `Memory the model holds for all requests at once. ${error}`,
+ `Context memory the server holds from the start and shares among the requests it generates at once. Each active request stores its own tokens in it, even when requests start with the same prompt. When it fills, the server frees idle requests first, then fails the active ones. ${error}`,
);
});
@@ -118,8 +124,10 @@ describe("ModelDeployDialog Context step", () => {
await user.click(preset("64k"));
await user.click(screen.getByRole("button", { name: "Next" }));
const hostMemory = screen.getByRole("region", { name: "Host memory" });
- expect(hostMemory).toHaveTextContent("Accelerator memory is unchanged.");
const kvOffload = within(hostMemory).getByRole("textbox", { name: "KV offloading size" });
+ expect(kvOffload).toHaveAccessibleDescription(
+ "Host memory used to offload the KV cache, in GiB.",
+ );
expect(kvOffload).toHaveValue("0");
// Parameters the schema does not expose stay out of the form, so no other parameter remains.
expect(screen.queryByRole("button", { name: /^Other parameters/ })).toBeNull();
@@ -279,7 +287,11 @@ describe("ModelDeployDialog Context step", () => {
await openAdvancedContext(user);
const perRequest = screen.getByRole("textbox", { name: "Context per request" });
expect(perRequest).toHaveAttribute("placeholder", "16384");
- expect(screen.getByText("Up to 16k (model maximum)")).toBeVisible();
+ await user.click(screen.getByRole("button", { name: "About Context per request" }));
+ expect(
+ await screen.findByRole("dialog", { name: "About Context per request" }),
+ ).toHaveTextContent("Up to 16k (model maximum)");
+ await user.keyboard("{Escape}");
await user.click(preset("8k"));
expect(perRequest).toHaveAttribute("placeholder", "8192");
});
@@ -295,12 +307,14 @@ describe("ModelDeployDialog Runtime step", () => {
expect(parallel).toHaveAccessibleName("Parallel requests");
expect(parallel).toHaveValue("4");
expect(parallel).toHaveAccessibleDescription(
- "Requests generated at once; more wait in a queue. requests",
+ "Requests the server generates at the same time; further requests wait in a queue.",
);
const hostMemory = screen.getByRole("region", { name: "Host memory" });
const hostContext = within(hostMemory).getByRole("textbox", { name: "Host context memory" });
expect(hostContext).toHaveValue("20480");
- expect(hostContext).toHaveAccessibleDescription("MiB");
+ expect(hostContext).toHaveAccessibleDescription(
+ "Host memory where idle conversations and paused requests are kept, so they resume without processing their prompt again. 0 turns it off.",
+ );
// No parameter is left for the other parameters section, and the context sizes sit on the
// Context step.
@@ -475,7 +489,12 @@ describe("ModelDeployDialog Runtime step", () => {
expect(speculativeMethod()).toHaveValue("mtp");
expect(optionLabels(speculativeMethod())).toEqual(["Off", "MTP"]);
expect(speculativeCount()).toHaveValue("3");
- expect(speculativeCount()).toHaveAccessibleDescription("Draft tokens per step. 1–5");
+ expect(speculativeCount()).toHaveAccessibleDescription("Draft tokens per step.");
+ await user.click(screen.getByRole("button", { name: "About Speculative decoding" }));
+ expect(
+ await screen.findByRole("dialog", { name: "About Speculative decoding" }),
+ ).toHaveTextContent("Range 1–5 tokens");
+ await user.keyboard("{Escape}");
await deployFromRuntime(user);
expect(screen.queryByText(/Customized/)).not.toBeInTheDocument();
diff --git a/frontend/src/features/models/deploy/model-deploy-scenario.test.tsx b/frontend/src/features/models/deploy/model-deploy-scenario.test.tsx
index 8efb03f6..acdabdcc 100644
--- a/frontend/src/features/models/deploy/model-deploy-scenario.test.tsx
+++ b/frontend/src/features/models/deploy/model-deploy-scenario.test.tsx
@@ -44,6 +44,8 @@ import {
NodeObservationState,
} from "@/gen/dotcore/site/v1/service_pb";
import { productionTiming, setTiming } from "@/lib/timing";
+import { useFakeTimersWithWaits } from "@/test/fake-timers";
+import { readFieldHelpFacts } from "@/test/field-help";
import { setupUser } from "@/test/user";
import { ModelDeployDialog } from "./model-deploy-dialog";
@@ -801,16 +803,15 @@ describe("ModelDeployDialog largest fitting total context", () => {
await user.click(screen.getByRole("button", { name: "Next" }));
expect(screen.getByText("Step 3 of 5 — Context")).toBeVisible();
const hint = "Up to 124k on node / NVIDIA GeForce RTX 5090";
- expect(screen.getByText(hint)).toBeVisible();
- expect(screen.getByRole("textbox", { name: "Total context" })).toHaveAccessibleDescription(
- new RegExp(`^${hint} `),
- );
+ expect(await readFieldHelpFacts("Total context")).toContain(hint);
await user.click(screen.getByRole("button", { name: /Target/ }));
await pickTarget(user, /^Worker \/ NVIDIA GB10/);
await user.click(screen.getByRole("button", { name: "Next" }));
- expect(screen.getByText("Up to 400k on Worker / NVIDIA GB10")).toBeVisible();
- expect(screen.queryByText(hint)).not.toBeInTheDocument();
+ expect(await readFieldHelpFacts("Total context")).toContain(
+ "Up to 400k on Worker / NVIDIA GB10",
+ );
+ expect(await readFieldHelpFacts("Total context")).not.toContain(hint);
});
});
@@ -839,13 +840,13 @@ describe("ModelDeployDialog largest fit under a new scenario", () => {
const chip = `Runs on filter: this site, ${sizedFor}`;
await screen.findByRole("button", { name: chip });
await user.click(screen.getByRole("button", { name: "Next" }));
- expect(await screen.findByText(/^Up to 124k on /)).toBeVisible();
+ expect((await readFieldHelpFacts("Total context")).join("\n")).toMatch(/^Up to 124k on /m);
await user.click(screen.getByRole("button", { name: "Back" }));
await chooseSizedFor(user, chip, "Defaults");
await user.click(screen.getByRole("button", { name: "Next" }));
- expect(await screen.findByText(/^Up to 96k on /)).toBeVisible();
+ expect((await readFieldHelpFacts("Total context")).join("\n")).toMatch(/^Up to 96k on /m);
expect(screen.getByRole("textbox", { name: "Total context" })).toHaveValue("98304");
});
});
@@ -920,7 +921,9 @@ describe("ModelDeployDialog shared context", () => {
await screen.findByRole("button", { name: "Runs on filter: this site" });
await user.click(screen.getByRole("button", { name: "Next" }));
// Only a starting value from the largest fit is hinted and offered as the last chip.
- expect(screen.queryByText(/^Up to .* on /)?.textContent).toBe(hint);
+ expect(
+ (await readFieldHelpFacts("Total context")).find((fact) => /^Up to .* on /.test(fact)),
+ ).toBe(hint);
expect(screen.queryByRole("button", { name: /, largest fit$/ })?.textContent).toBe(chip);
await user.click(screen.getByRole("button", { name: "Next" }));
await user.click(screen.getByRole("button", { name: "Deploy" }));
@@ -1027,7 +1030,9 @@ describe("ModelDeployDialog shared context", () => {
await user.click(largestFitChip("889k"));
expect(screen.getByRole("textbox", { name: "Total context" })).toHaveValue("910336");
expect(largestFitChip("889k")).toHaveAttribute("aria-pressed", "true");
- expect(screen.getByText("Up to 889k on node / NVIDIA GeForce RTX 5090")).toBeVisible();
+ expect(await readFieldHelpFacts("Total context")).toContain(
+ "Up to 889k on node / NVIDIA GeForce RTX 5090",
+ );
await user.click(screen.getByRole("button", { name: "Back" }));
expect(screen.getByRole("button", { name: "Runs on filter: this site" })).toBeVisible();
});
@@ -1290,7 +1295,9 @@ describe("ModelDeployDialog shared context", () => {
await pickTarget(user, /^node/);
await user.click(screen.getByRole("button", { name: "Next" }));
expect(screen.getByText("Step 3 of 4 — Context")).toBeVisible();
- expect(screen.getByText("Up to 889k on node / NVIDIA GeForce RTX 5090")).toBeVisible();
+ expect(await readFieldHelpFacts("Total context")).toContain(
+ "Up to 889k on node / NVIDIA GeForce RTX 5090",
+ );
const total = screen.getByRole("textbox", { name: "Total context" });
await user.clear(total);
await user.type(total, "65536");
@@ -1299,7 +1306,9 @@ describe("ModelDeployDialog shared context", () => {
await user.click(screen.getByRole("button", { name: "Back" }));
await pickTarget(user, /^Worker/);
await user.click(screen.getByRole("button", { name: "Next" }));
- expect(screen.getByText("Up to 400k on Worker / NVIDIA GB10")).toBeVisible();
+ expect(await readFieldHelpFacts("Total context")).toContain(
+ "Up to 400k on Worker / NVIDIA GB10",
+ );
expect(screen.getByRole("textbox", { name: "Total context" })).toHaveValue("65536");
await user.click(screen.getByRole("button", { name: /Profile/ }));
@@ -1460,7 +1469,7 @@ describe("ModelDeployDialog scenario polling", () => {
it("keeps the first read's starting total context when later reads report another", async () => {
setTiming(productionTiming);
- vi.useFakeTimers();
+ useFakeTimersWithWaits();
let reads = 0;
const listProfileCompatibility = vi.fn(() => {
const startingTotalContextTokens = reads++ > 0 ? 98304 : 126976;
@@ -1475,17 +1484,17 @@ describe("ModelDeployDialog scenario polling", () => {
renderDialog({ initialProfileId: "ninfer-nvfp4", listProfileCompatibility });
await advance();
fireEvent.click(screen.getByRole("button", { name: "Next" }));
- const expectFirstFit = () => {
+ const expectFirstFit = async () => {
expect(screen.getByRole("textbox", { name: "Total context" })).toHaveValue("126976");
- expect(screen.getByText(/^Up to 124k on /)).toBeVisible();
+ expect((await readFieldHelpFacts("Total context")).join("\n")).toMatch(/^Up to 124k on /m);
expect(largestFitChip("124k")).toHaveAttribute("aria-pressed", "true");
};
- expectFirstFit();
+ await expectFirstFit();
await advance(productionTiming.profileCompatibilityRefreshMs);
expect(listProfileCompatibility).toHaveBeenCalledTimes(2);
- expectFirstFit();
+ await expectFirstFit();
});
it("shows the selected profile's frozen starting total context in the table, and other rows live", async () => {
@@ -1519,7 +1528,7 @@ describe("ModelDeployDialog scenario polling", () => {
it("applies the live starting total context on choosing the largest fit chip, and keeps it from then on", async () => {
setTiming(productionTiming);
- vi.useFakeTimers();
+ useFakeTimersWithWaits();
// What every read reports from now on, as free memory changes.
let live = 126976;
const listProfileCompatibility = vi.fn(() =>
@@ -1556,19 +1565,19 @@ describe("ModelDeployDialog scenario polling", () => {
fireEvent.click(largestFitChip("96k"));
await advance();
expect(total()).toHaveValue("98304");
- expect(screen.getByText(/^Up to 96k on /)).toBeVisible();
+ expect((await readFieldHelpFacts("Total context")).join("\n")).toMatch(/^Up to 96k on /m);
expect(largestFitChip("96k")).toHaveAttribute("aria-pressed", "true");
// The corrected value is frozen in turn.
live = 65536;
await advance(productionTiming.profileCompatibilityRefreshMs);
expect(total()).toHaveValue("98304");
- expect(screen.getByText(/^Up to 96k on /)).toBeVisible();
+ expect((await readFieldHelpFacts("Total context")).join("\n")).toMatch(/^Up to 96k on /m);
});
it("stays on the engine default when the first read has no fit and a later one does", async () => {
setTiming(productionTiming);
- vi.useFakeTimers();
+ useFakeTimersWithWaits();
let reads = 0;
const listProfileCompatibility = vi.fn(() => {
const fit = reads++ > 0 ? 126976 : undefined;
@@ -1595,7 +1604,7 @@ describe("ModelDeployDialog scenario polling", () => {
expect(listProfileCompatibility).toHaveBeenCalledTimes(2);
expect(screen.getByRole("textbox", { name: "Total context" })).toHaveValue("32768");
- expect(screen.queryByText(/^Up to /)).not.toBeInTheDocument();
+ expect((await readFieldHelpFacts("Total context")).join("\n")).not.toMatch(/Up to /);
});
it("keeps the operator's Runs on chip, place and selection across a refresh", async () => {
diff --git a/frontend/src/features/models/deploy/model-deploy-target-step.tsx b/frontend/src/features/models/deploy/model-deploy-target-step.tsx
index ab4e6059..a899270a 100644
--- a/frontend/src/features/models/deploy/model-deploy-target-step.tsx
+++ b/frontend/src/features/models/deploy/model-deploy-target-step.tsx
@@ -79,7 +79,7 @@ export function ModelDeployTargetStep({
@@ -71,6 +77,10 @@ export function DeploymentExposureFields({
Route name
+
+ The model name callers send in requests. It follows the deployment name until you change
+ it.
+
{direct ? (
[] = [
diff --git a/frontend/src/features/models/runtime/context-size-field.tsx b/frontend/src/features/models/runtime/context-size-field.tsx
index 29717800..09705c5c 100644
--- a/frontend/src/features/models/runtime/context-size-field.tsx
+++ b/frontend/src/features/models/runtime/context-size-field.tsx
@@ -1,6 +1,6 @@
import type { ReactNode, RefObject } from "react";
-import { FieldError } from "@/components/ui/field-error";
+import { Field } from "@/components/ui/field";
import type { ParameterDefinition } from "@/gen/dotcore/catalog/v1/parameter_pb";
import {
@@ -14,10 +14,12 @@ interface ContextSizeFieldProps {
/** The value an empty input stands for, for an optional parameter resolution fills in. */
emptyValue?: number;
error?: string;
- /** A short line under the input, such as what the value means or an action on it. */
- help?: ReactNode;
- /** A short fact beside the name, such as the largest value that fits the selected target. */
- hint?: string;
+ /** Buttons that change the value, shown under the input. */
+ actions?: ReactNode;
+ /** Facts about the value in the help popover, such as the largest value that fits. */
+ facts?: readonly string[];
+ /** What the parameter does, shown in the help popover. */
+ help?: string;
input: string;
inputRefs: RefObject>;
/** The largest size that fits the selected target, offered as the input's last chip. */
@@ -33,15 +35,16 @@ interface ContextSizeFieldProps {
/**
* Edits a context size engine parameter with the shared context size input, bounded by the
- * parameter's schema range, under its name and above an optional help line. The number input owns
- * validation and focus.
+ * parameter's schema range, under its name and a help popover holding the description, range and
+ * unit. The number input owns validation and focus.
*/
export function ContextSizeField({
+ actions,
disabled,
emptyValue,
error,
+ facts,
help,
- hint,
input,
inputRefs,
largestFit,
@@ -54,48 +57,47 @@ export function ContextSizeField({
const { maximum, minimum, unit } = parameter.parameterType.value;
const inputId = `engine-parameter-${parameter.parameterId}`;
- const helpId = `${parameter.parameterId}-help`;
- const errorId = `${parameter.parameterId}-error`;
- const hintId = `${parameter.parameterId}-hint`;
- const describedBy = [hint ? hintId : "", help ? helpId : "", error ? errorId : ""]
- .filter(Boolean)
- .join(" ");
-
return (
-
-
- {parameter.displayName}
- {hint ? (
-
- {hint}
-
- ) : null}
-
-
{
- inputRefs.current.set(parameter.parameterId, control);
- }}
- invalid={Boolean(error)}
- largestFit={largestFit}
- label={parameter.displayName}
- maximum={Number(maximum)}
- minimum={Number(minimum)}
- onBlur={() => onBlur(parameter)}
- onChange={(value, settled) => onChange(parameter, value, settled)}
- sliderRange={sliderRange}
- unit={unit}
- value={input}
- />
- {help ? (
-
- {help}
-
- ) : null}
- {error ? {error} : null}
-
+
+
+ Range {`${minimum}–${maximum}`}
+ {unit ? ` ${unit}` : ""}
+
+ {facts?.map((fact) => (
+ {fact}
+ ))}
+ >
+ }
+ help={help}
+ label={parameter.displayName}
+ >
+ {(control) => (
+ {
+ inputRefs.current.set(parameter.parameterId, element);
+ }}
+ invalid={Boolean(error)}
+ largestFit={largestFit}
+ label={parameter.displayName}
+ maximum={Number(maximum)}
+ minimum={Number(minimum)}
+ onBlur={() => onBlur(parameter)}
+ onChange={(value, settled) => onChange(parameter, value, settled)}
+ sliderRange={sliderRange}
+ unit={unit}
+ value={input}
+ />
+ )}
+
);
}
diff --git a/frontend/src/features/models/runtime/engine-parameter-values.ts b/frontend/src/features/models/runtime/engine-parameter-values.ts
index 1bdce73f..6e13d27c 100644
--- a/frontend/src/features/models/runtime/engine-parameter-values.ts
+++ b/frontend/src/features/models/runtime/engine-parameter-values.ts
@@ -210,23 +210,9 @@ export function engineParameterError(
return `${parameter.displayName} must be enabled or disabled.`;
}
-/**
- * The short line under a parameter's field, a fixed one for the shared parameters every engine
- * declares; undefined for the others, whose unit the field shows. The schema description
- * documents the engine and stays out of the field.
- */
-export function engineParameterHelp(parameter: ParameterDefinition): string | undefined {
- return sharedParameterHelp.get(parameter.parameterId);
-}
-
/** Common context sizes, in tokens, offered as presets wherever a context size is entered. */
export const contextSizePresets: readonly number[] = [8192, 32768, 65536, 131072, 262144, 1048576];
-const sharedParameterHelp: ReadonlyMap = new Map([
- [TOTAL_CONTEXT_PARAMETER_ID, "Memory the model holds for all requests at once."],
- [PARALLEL_REQUESTS_PARAMETER_ID, "Requests generated at once; more wait in a queue."],
-]);
-
function hostMemoryParameterIds(configuration: ModelEngineConfiguration | undefined): Set {
const engine = configuration?.engineConfiguration?.configuration;
const id =
diff --git a/frontend/src/features/models/runtime/engine-parameters.tsx b/frontend/src/features/models/runtime/engine-parameters.tsx
index 6386d7eb..0d132f6a 100644
--- a/frontend/src/features/models/runtime/engine-parameters.tsx
+++ b/frontend/src/features/models/runtime/engine-parameters.tsx
@@ -5,8 +5,6 @@ import { ParametersDetails, ParametersForm } from "@/components/parameters/param
import type { EngineParameterSchema } from "@/gen/dotcore/catalog/v1/engine_release_pb";
import type { ParameterDefinition, ParameterValues } from "@/gen/dotcore/catalog/v1/parameter_pb";
-import { engineParameterHelp } from "./engine-parameter-values";
-
interface EngineParametersFormProps {
errors: ReadonlyMap;
inputs: ReadonlyMap;
@@ -66,7 +64,7 @@ export function EngineParametersDetails({
function engineParameterViews(parameters: readonly ParameterDefinition[]): ParameterView[] {
return parameters.flatMap((parameter): ParameterView[] => {
const base = {
- hint: engineParameterHelp(parameter),
+ description: parameter.description,
id: parameter.parameterId,
label: parameter.displayName,
};
diff --git a/frontend/src/features/models/runtime/speculative-decoding-field.tsx b/frontend/src/features/models/runtime/speculative-decoding-field.tsx
index 6f1f5cc4..e94f5014 100644
--- a/frontend/src/features/models/runtime/speculative-decoding-field.tsx
+++ b/frontend/src/features/models/runtime/speculative-decoding-field.tsx
@@ -1,6 +1,6 @@
import type { RefObject } from "react";
-import { FieldError } from "@/components/ui/field-error";
+import { Field } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import type { SpeculativeOption } from "@/features/models/profiles/model-row";
@@ -36,53 +36,57 @@ export function SpeculativeDecodingField({
const option = options.find((candidate) => candidate.optionId === choice.optionId);
const methodId = "speculative-decoding-method";
const countId = "speculative-decoding-count";
- const descriptionId = "speculative-decoding-count-description";
- const errorId = "speculative-decoding-count-error";
return (
-
-
- Speculative decoding
-
-
- onMethodChange(event.target.value)}
- value={choice.optionId}
- >
- Off
- {options.map((candidate) => (
-
- {candidate.displayName}
-
- ))}
-
- {option ? (
- <>
- onCountChange(event.target.value)}
- ref={countRef}
- value={choice.count}
- />
- tokens
- >
- ) : null}
-
- {option ? (
-
- {`Draft tokens per step. ${speculativeEnabledMinimum(option)}–${option.maximum}`}
-
- ) : null}
- {error ?
{error} : null}
-
+
+ Range {speculativeEnabledMinimum(option)}–{option.maximum} tokens
+
+ ) : undefined
+ }
+ help={option ? "Draft tokens per step." : undefined}
+ label="Speculative decoding"
+ >
+ {(control) => (
+
+ onMethodChange(event.target.value)}
+ value={choice.optionId}
+ >
+ Off
+ {options.map((candidate) => (
+
+ {candidate.displayName}
+
+ ))}
+
+ {option ? (
+ <>
+ onCountChange(event.target.value)}
+ ref={countRef}
+ value={choice.count}
+ />
+ tokens
+ >
+ ) : null}
+
+ )}
+
);
}
diff --git a/frontend/src/features/models/upstream/upstream-endpoint-fields.tsx b/frontend/src/features/models/upstream/upstream-endpoint-fields.tsx
index 5f6c4bdf..205eec57 100644
--- a/frontend/src/features/models/upstream/upstream-endpoint-fields.tsx
+++ b/frontend/src/features/models/upstream/upstream-endpoint-fields.tsx
@@ -1,7 +1,6 @@
-/* eslint-disable jsx-a11y/label-has-associated-control */
import type { RefObject } from "react";
-import { FieldError } from "@/components/ui/field-error";
+import { Field } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Notice } from "@/components/ui/notice";
import type { FieldErrors } from "@/lib/form-validation";
@@ -37,50 +36,53 @@ export function UpstreamEndpointFields({
{error}
) : null}
-
- Base URL
- onFieldBlur?.("baseUrl")}
- onChange={(e) => set("baseUrl", e.target.value)}
- />
- {fieldErrors.get("baseUrl") ? (
- {fieldErrors.get("baseUrl")}
- ) : null}
-
-
- Upstream model ID
- onFieldBlur?.("upstreamModelId")}
- onChange={(e) => set("upstreamModelId", e.target.value)}
- />
- {fieldErrors.get("upstreamModelId") ? (
- {fieldErrors.get("upstreamModelId")}
- ) : null}
-
-
- API key
- set("apiKey", e.target.value)}
- placeholder={editing ? "Optional; blank retains stored key" : "Optional"}
- />
-
+
+ {(control) => (
+ onFieldBlur?.("baseUrl")}
+ onChange={(e) => set("baseUrl", e.target.value)}
+ />
+ )}
+
+
+ {(control) => (
+ onFieldBlur?.("upstreamModelId")}
+ onChange={(e) => set("upstreamModelId", e.target.value)}
+ />
+ )}
+
+
+ {(control) => (
+ set("apiKey", e.target.value)}
+ placeholder={editing ? "Optional; blank retains stored key" : "Optional"}
+ />
+ )}
+
);
}
diff --git a/frontend/src/features/models/upstream/upstream-model.test.tsx b/frontend/src/features/models/upstream/upstream-model.test.tsx
index fe4adee4..065544d3 100644
--- a/frontend/src/features/models/upstream/upstream-model.test.tsx
+++ b/frontend/src/features/models/upstream/upstream-model.test.tsx
@@ -300,7 +300,9 @@ describe("generic upstream endpoint UI", () => {
expect(within(dialog).getByText("Base URL is required.")).toBeVisible();
expect(within(dialog).getByText("Upstream model ID is required.")).toBeVisible();
expect(baseUrl).toHaveAttribute("aria-invalid", "true");
- expect(baseUrl).toHaveAccessibleDescription("Base URL is required.");
+ expect(baseUrl).toHaveAccessibleDescription(
+ "Address of the OpenAI-compatible server that runs the model. Base URL is required.",
+ );
expect(baseUrl).toHaveFocus();
expect(onSubmit).not.toHaveBeenCalled();
});
@@ -316,6 +318,7 @@ describe("generic upstream endpoint UI", () => {
await user.click(baseUrl);
await user.tab();
+ await user.tab();
expect(modelId).toHaveFocus();
await user.tab();
await user.click(within(dialog).getByRole("checkbox", { name: "Advanced" }));
@@ -326,13 +329,17 @@ describe("generic upstream endpoint UI", () => {
await user.clear(baseUrl);
await user.tab();
expect(await within(dialog).findByText("Base URL is required.")).toBeVisible();
- expect(baseUrl).toHaveAccessibleDescription("Base URL is required.");
+ expect(baseUrl).toHaveAccessibleDescription(
+ "Address of the OpenAI-compatible server that runs the model. Base URL is required.",
+ );
expect(modelId).not.toHaveAttribute("aria-invalid");
expect(within(dialog).queryByText("Upstream model ID is required.")).not.toBeInTheDocument();
await user.click(within(dialog).getByRole("button", { name: "Next" }));
expect(within(dialog).getByText("Step 1 of 2 — Connection")).toBeVisible();
- expect(modelId).toHaveAccessibleDescription("Upstream model ID is required.");
+ expect(modelId).toHaveAccessibleDescription(
+ "The model name that server expects in requests. Upstream model ID is required.",
+ );
expect(baseUrl).toHaveFocus();
});
@@ -439,7 +446,7 @@ describe("generic upstream endpoint UI", () => {
expect(within(dialog).getByRole("button", { name: "Deploy" })).toBeDisabled();
expect(route).toHaveAttribute("aria-invalid", "true");
expect(route).toHaveAccessibleDescription(
- 'An inference route named "private-model" already exists. Change the route or the deployment name.',
+ 'The model name callers send in requests. It follows the deployment name until you change it. An inference route named "private-model" already exists. Change the route or the deployment name.',
);
const name = within(dialog).getByRole("textbox", { name: "Deployment name" });
@@ -495,7 +502,7 @@ describe("generic upstream endpoint UI", () => {
const route = within(dialog).getByLabelText("Route name");
expect(within(dialog).getByRole("button", { name: "Deploy" })).toBeDisabled();
expect(route).toHaveAccessibleDescription(
- 'An inference route named "private-model" already exists. Change the route or the deployment name.',
+ 'The model name callers send in requests. It follows the deployment name until you change it. An inference route named "private-model" already exists. Change the route or the deployment name.',
);
await user.clear(route);
@@ -700,7 +707,9 @@ describe("generic upstream endpoint UI", () => {
await user.tab();
expect(await within(dialog).findByText("Base URL must not include a query.")).toBeVisible();
expect(baseUrl).toHaveAttribute("aria-invalid", "true");
- expect(baseUrl).toHaveAccessibleDescription("Base URL must not include a query.");
+ expect(baseUrl).toHaveAccessibleDescription(
+ "Address of the OpenAI-compatible server that runs the model. Base URL must not include a query.",
+ );
await user.clear(baseUrl);
await user.type(baseUrl, "https://endpoint.example/v1");
expect(
@@ -866,6 +875,9 @@ describe("generic upstream endpoint UI", () => {
expect(route()).toHaveClass("text-muted-foreground");
const toggle = () => within(dialog).getByRole("button", { name: "Follow the deployment name" });
expect(toggle()).toHaveAttribute("aria-pressed", "true");
+ expect(route()).toHaveAccessibleDescription(
+ "The model name callers send in requests. It follows the deployment name until you change it.",
+ );
expect(name().compareDocumentPosition(route()) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(within(dialog).queryByRole("radio", { name: "Direct" })).not.toBeInTheDocument();
@@ -903,7 +915,9 @@ describe("generic upstream endpoint UI", () => {
await user.clear(route);
await user.type(route, "taken");
- expect(route).toHaveAccessibleDescription('An inference route named "taken" already exists.');
+ expect(route).toHaveAccessibleDescription(
+ 'The model name callers send in requests. It follows the deployment name until you change it. An inference route named "taken" already exists.',
+ );
expect(within(dialog).getByRole("button", { name: "Deploy" })).toBeDisabled();
});
@@ -935,10 +949,9 @@ describe("generic upstream endpoint UI", () => {
const endpointHeading = within(inspector).getByRole("heading", { name: "Endpoint" });
const endpointSection = endpointHeading.closest("section");
expect(endpointHeading).toBeVisible();
- expect(within(endpointSection!).getByLabelText("Base URL").closest("label")).toHaveClass(
- "grid",
- "gap-1",
- );
+ expect(
+ within(endpointSection!).getByLabelText("Base URL").closest('[data-slot="field"]'),
+ ).toHaveClass("grid", "gap-1");
expect(endpointSection?.querySelector(":scope > .mt-4 > .grid.gap-4")).toBeInTheDocument();
expect(within(endpointSection!).getByRole("button", { name: "Save endpoint" })).toHaveClass(
"mt-4",
@@ -1165,7 +1178,7 @@ describe("generic upstream endpoint UI", () => {
expect(route).toBeDisabled();
expect(route).not.toHaveAttribute("aria-invalid");
expect(route).toHaveAccessibleDescription(
- "Hidden: no route of its own. Reachable only through other routes.",
+ "The model name callers send in requests. It follows the deployment name until you change it. Hidden: no route of its own. Reachable only through other routes.",
);
expect(within(dialog).queryByText(/Route name must start/)).not.toBeInTheDocument();
diff --git a/frontend/src/test/field-help.ts b/frontend/src/test/field-help.ts
new file mode 100644
index 00000000..d42d27bb
--- /dev/null
+++ b/frontend/src/test/field-help.ts
@@ -0,0 +1,19 @@
+import { fireEvent, screen, waitFor } from "@testing-library/react";
+import { expect } from "vitest";
+
+/**
+ * Opens a field's help popover, returns the text of each of its facts, one per paragraph, and
+ * closes it again.
+ */
+export async function readFieldHelpFacts(label: string): Promise
{
+ fireEvent.click(screen.getByRole("button", { name: `About ${label}` }));
+ const popover = await screen.findByRole("dialog", { name: `About ${label}` });
+ const facts = [...popover.querySelectorAll("p")].map((fact) => fact.textContent ?? "");
+
+ fireEvent.keyDown(popover, { key: "Escape" });
+ await waitFor(() =>
+ expect(screen.queryByRole("dialog", { name: `About ${label}` })).not.toBeInTheDocument(),
+ );
+
+ return facts;
+}
From 8285d557e704c60d58a375d437b0b5a264a3970c Mon Sep 17 00:00:00 2001
From: Andre-Philippe Paquet
Date: Thu, 12 Nov 2026 17:44:22 -0500
Subject: [PATCH 13/40] unreviewed: feat(backend): let profiles and engines
make any parameter basic
---
backend/catalog/src/shared_parameters.rs | 15 +++++----------
backend/core/tests/backend/catalog_contracts.rs | 10 ++++++----
2 files changed, 11 insertions(+), 14 deletions(-)
diff --git a/backend/catalog/src/shared_parameters.rs b/backend/catalog/src/shared_parameters.rs
index 46925b43..d9337ec0 100644
--- a/backend/catalog/src/shared_parameters.rs
+++ b/backend/catalog/src/shared_parameters.rs
@@ -90,8 +90,8 @@ pub(crate) fn profile_may_default(parameter_id: &str) -> bool {
.is_none_or(|shared| shared.profile_default)
}
-/// Whether the parameter belongs to the main view: the context parameters do, and no other
-/// parameter may be shown there by an engine or a profile.
+/// Whether the parameter is fixed in the main view: the context parameters are, and may only be
+/// hidden there; an engine or a profile may show any other parameter there too.
pub(crate) fn in_main_view(parameter_id: &str) -> bool {
SHARED_PARAMETERS.iter().any(|shared| {
shared.parameter_id == parameter_id && shared.visibility == ParameterVisibility::Basic
@@ -100,8 +100,7 @@ pub(crate) fn in_main_view(parameter_id: &str) -> bool {
/// Returns why a profile may not give the parameter this visibility, or none when it may. The
/// context parameters belong to the main view: a profile may hide them but not move them to the
-/// advanced view. Every other parameter stays out of the main view, so a profile may show it as
-/// advanced or hide it.
+/// advanced view. A profile may give any other parameter any visibility.
pub(crate) fn profile_visibility_problem(
parameter_id: &str,
visibility: ParameterVisibility,
@@ -110,7 +109,6 @@ pub(crate) fn profile_visibility_problem(
match visibility {
ParameterVisibility::Unspecified => Some("requires a basic, advanced or hidden visibility"),
ParameterVisibility::Advanced if main_view => Some("may be hidden but not advanced"),
- ParameterVisibility::Basic if !main_view => Some("may be advanced or hidden but not basic"),
ParameterVisibility::Basic
| ParameterVisibility::Advanced
| ParameterVisibility::Hidden => None,
@@ -359,7 +357,7 @@ mod tests {
}
#[test]
- fn profiles_may_hide_any_parameter_but_keep_the_contexts_basic_and_the_rest_out_of_basic() {
+ fn profiles_may_hide_any_parameter_and_keep_the_contexts_basic() {
use ParameterVisibility::{Advanced, Basic, Hidden, Unspecified};
for parameter_id in [TOTAL_CONTEXT_PARAMETER_ID, CONTEXT_PER_REQUEST_PARAMETER_ID] {
@@ -371,12 +369,9 @@ mod tests {
);
}
for parameter_id in [PARALLEL_REQUESTS_PARAMETER_ID, "prompt-cache-memory-mib"] {
+ assert_eq!(profile_visibility_problem(parameter_id, Basic), None);
assert_eq!(profile_visibility_problem(parameter_id, Advanced), None);
assert_eq!(profile_visibility_problem(parameter_id, Hidden), None);
- assert_eq!(
- profile_visibility_problem(parameter_id, Basic),
- Some("may be advanced or hidden but not basic")
- );
}
assert_eq!(
profile_visibility_problem(TOTAL_CONTEXT_PARAMETER_ID, Unspecified),
diff --git a/backend/core/tests/backend/catalog_contracts.rs b/backend/core/tests/backend/catalog_contracts.rs
index 146855f1..d5b3533b 100644
--- a/backend/core/tests/backend/catalog_contracts.rs
+++ b/backend/core/tests/backend/catalog_contracts.rs
@@ -2484,10 +2484,6 @@ fn catalog_rejects_mismatched_boolean_values_and_invalid_visibility_overrides()
vec![("context-per-request", Advanced)],
"profile \"balanced\" engine parameter \"context-per-request\" may be hidden but not advanced",
),
- (
- vec![("feature-enabled", Basic)],
- "profile \"balanced\" engine parameter \"feature-enabled\" may be advanced or hidden but not basic",
- ),
] {
let mut invalid = synthetic_boolean_model();
invalid.profiles[0].engine_parameter_visibility_overrides =
@@ -2514,6 +2510,12 @@ fn catalog_rejects_mismatched_boolean_values_and_invalid_visibility_overrides()
("feature-enabled", Hidden),
]);
synthetic_boolean_catalog(valid);
+
+ // A profile may show any other parameter in the main view.
+ let mut promoted = synthetic_boolean_model();
+ promoted.profiles[0].engine_parameter_visibility_overrides =
+ visibility_overrides(&[("feature-enabled", Basic)]);
+ synthetic_boolean_catalog(promoted);
}
/// Returns a fresh target of the production runtime kind for `platform`: Docker with an NVIDIA
From 33f70ebb1f9d861f0112c354812f1a77ab12dbc3 Mon Sep 17 00:00:00 2001
From: Andre-Philippe Paquet
Date: Thu, 12 Nov 2026 18:11:45 -0500
Subject: [PATCH 14/40] unreviewed: feat(frontend): put deploy dialogs on the
generic steps
---
.../applications/app-catalog-page.test.tsx | 11 +-
.../application-deploy-dialog.test.tsx | 169 +++++++++----
.../application-deploy-dialog.tsx | 220 +++++++++--------
...tep.tsx => model-deploy-advanced-step.tsx} | 45 ++--
.../deploy/model-deploy-dialog.test.tsx | 227 ++++++++++++------
.../models/deploy/model-deploy-dialog.tsx | 71 ++----
.../models/deploy/model-deploy-launch.ts | 16 +-
.../deploy/model-deploy-scenario.test.tsx | 24 +-
...tep.tsx => model-deploy-settings-step.tsx} | 32 ++-
...e.test.tsx => model-deploy-steps.test.tsx} | 100 ++++----
.../deploy/model-deploy-summary-step.tsx | 22 +-
.../models/deploy/use-model-deploy-form.ts | 106 +++++---
.../models/runtime/engine-parameter-values.ts | 26 +-
.../local-deployment-configuration.test.tsx | 27 ++-
.../runtime/local-deployment-configuration.ts | 88 ++++---
15 files changed, 698 insertions(+), 486 deletions(-)
rename frontend/src/features/models/deploy/{model-deploy-runtime-step.tsx => model-deploy-advanced-step.tsx} (89%)
rename frontend/src/features/models/deploy/{model-deploy-context-step.tsx => model-deploy-settings-step.tsx} (82%)
rename frontend/src/features/models/deploy/{model-deploy-runtime.test.tsx => model-deploy-steps.test.tsx} (93%)
diff --git a/frontend/src/features/applications/app-catalog-page.test.tsx b/frontend/src/features/applications/app-catalog-page.test.tsx
index 6c6fb262..bf307482 100644
--- a/frontend/src/features/applications/app-catalog-page.test.tsx
+++ b/frontend/src/features/applications/app-catalog-page.test.tsx
@@ -212,7 +212,6 @@ describe("AppCatalogPage", () => {
name: "Deploy",
}),
);
- await user.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Next" }));
await user.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Deploy" }));
expect(client.application.createApplicationDeployment).toHaveBeenCalledWith({
@@ -257,7 +256,6 @@ describe("AppCatalogPage", () => {
name: "Deploy",
}),
);
- await user.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Next" }));
await user.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Deploy" }));
await waitFor(() =>
expect(currentLocation(router)).toBe("/apps?panel=apps%2Fdeployment%3Aaccepted-deployment"),
@@ -288,7 +286,6 @@ describe("AppCatalogPage", () => {
name: "Deploy",
}),
);
- await user.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Next" }));
await user.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Deploy" }));
await act(() => router.navigate("/apps"));
// Later deployment reads stay pending, so the installed list shows the accepted overlay.
@@ -345,7 +342,6 @@ describe("AppCatalogPage", () => {
name: "Deploy",
}),
);
- await user.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Next" }));
await user.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Deploy" }));
expect(await screen.findByText("accepted-deployment")).toBeVisible();
@@ -407,7 +403,6 @@ describe("AppCatalogPage", () => {
await user.click(await screen.findByRole("row", { name: "OpenWebUI" }));
await user.click(screen.getByRole("button", { name: "Deploy" }));
- await user.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Next" }));
expect(
within(screen.getByRole("dialog")).getByRole("button", { name: "Deploy" }),
).toBeDisabled();
@@ -430,7 +425,6 @@ describe("AppCatalogPage", () => {
fireEvent.click(screen.getByRole("row", { name: "OpenWebUI" }));
fireEvent.click(screen.getByRole("button", { name: "Deploy" }));
const dialog = screen.getByRole("dialog");
- fireEvent.click(within(dialog).getByRole("button", { name: "Next" }));
expect(within(dialog).getByRole("button", { name: "Deploy" })).toBeDisabled();
await act(async () => {
@@ -469,7 +463,6 @@ describe("AppCatalogPage", () => {
fireEvent.click(screen.getByRole("button", { name: "Deploy" }));
const dialog = screen.getByRole("dialog");
await act(async () => {});
- fireEvent.click(within(dialog).getByRole("button", { name: "Next" }));
expect(within(dialog).getByRole("button", { name: "Deploy" })).toBeEnabled();
await act(async () => {
@@ -663,7 +656,7 @@ describe("AppCatalogPage", () => {
expect(within(dialog).getByText("renamable-chat")).toBeVisible();
expect(
within(dialog)
- .getAllByText("Execution target")
+ .getAllByText("Target")
.map((label) => label.nextElementSibling?.textContent),
).toContain("local / Docker applications");
await user.click(within(dialog).getByRole("button", { name: "Deploy" }));
@@ -823,7 +816,6 @@ describe("AppCatalogPage", () => {
await user.click(await screen.findByRole("row", { name: "OpenWebUI" }));
await user.click(screen.getByRole("button", { name: "Deploy" }));
const dialog = screen.getByRole("dialog");
- await user.click(within(dialog).getByRole("button", { name: "Next" }));
const deployButton = within(dialog).getByRole("button", { name: "Deploy" });
await user.click(deployButton);
@@ -852,7 +844,6 @@ describe("AppCatalogPage", () => {
await user.click(await screen.findByRole("row", { name: "OpenWebUI" }));
await user.click(screen.getByRole("button", { name: "Deploy" }));
const dialog = screen.getByRole("dialog");
- await user.click(within(dialog).getByRole("button", { name: "Next" }));
await user.click(within(dialog).getByRole("button", { name: "Deploy" }));
expect(await screen.findByRole("alert")).toHaveTextContent(expectedMessage);
diff --git a/frontend/src/features/applications/application-deploy-dialog.test.tsx b/frontend/src/features/applications/application-deploy-dialog.test.tsx
index 6051f738..8b3d46da 100644
--- a/frontend/src/features/applications/application-deploy-dialog.test.tsx
+++ b/frontend/src/features/applications/application-deploy-dialog.test.tsx
@@ -60,6 +60,12 @@ async function openDeploy(user: UserEvent) {
expect(screen.getByText("Step 2 of 2 — Deploy")).toBeVisible();
}
+// A route-less application without basic parameters opens on Deploy, its only basic step.
+function expectDeployOnly() {
+ expect(screen.queryByRole("list", { name: "Deployment progress" })).not.toBeInTheDocument();
+ expect(screen.getByRole("textbox", { name: "Deployment name" })).toBeVisible();
+}
+
/** Label and value of each Deploy summary row, in display order. */
/** Renders under a resource watch, which answers no execution targets unless a test gives one. */
function render(
@@ -392,12 +398,11 @@ describe("Application deployment dialog", () => {
/>,
);
- await openDeploy(user);
+ expectDeployOnly();
await waitFor(() => expect(screen.getByRole("button", { name: "Deploy" })).toBeEnabled());
await user.click(screen.getByRole("button", { name: "Deploy" }));
const nameControl = screen.getByRole("textbox", { name: "Deployment name" });
- expect(screen.getByText("Step 2 of 2 — Deploy")).toBeVisible();
expect(screen.getByRole("checkbox", { name: "Advanced" })).not.toBeChecked();
expect(nameControl).toHaveFocus();
expect(nameControl).toHaveAccessibleDescription("Deployment name is required.");
@@ -533,7 +538,7 @@ describe("Application deployment dialog", () => {
/>,
);
- await openDeploy(user);
+ expectDeployOnly();
await waitFor(() => expect(screen.getByRole("button", { name: "Deploy" })).toBeEnabled());
await user.click(screen.getByRole("button", { name: "Deploy" }));
expect(onSubmit).toHaveBeenCalledWith(
@@ -570,7 +575,7 @@ describe("Application deployment dialog", () => {
),
},
);
- await openDeploy(user);
+ expectDeployOnly();
await waitFor(() => expect(screen.getByRole("button", { name: "Deploy" })).toBeEnabled());
await user.click(screen.getByRole("button", { name: "Deploy" }));
@@ -582,7 +587,7 @@ describe("Application deployment dialog", () => {
expect(screen.getByRole("button", { name: "Deploy" })).toBeDisabled();
await user.click(screen.getByRole("checkbox", { name: "Advanced" }));
await user.click(screen.getByRole("button", { name: "Back" }));
- expect(screen.getByText("Step 2 of 3 — Target")).toBeVisible();
+ expect(screen.getByText("Step 1 of 2 — Target")).toBeVisible();
expect(screen.getByLabelText("Execution target")).toHaveValue("");
expect(screen.getByRole("button", { name: "Next" })).toBeDisabled();
});
@@ -621,10 +626,11 @@ describe("Application deployment dialog", () => {
/>,
);
- await openAdvanced(user);
+ // Two deployable targets: basic mode opens on Target.
+ expect(stepLabels()).toEqual(["1. Target", "2. Deploy"]);
const targetControl = screen.getByLabelText("Execution target");
// The remote node sorts first, but the site manager's target stays the default.
- expect(targetControl).toHaveValue("local-app");
+ await waitFor(() => expect(targetControl).toHaveValue("local-app"));
expect(
screen.getByRole("option", {
name: "Name of alpha-worker / Docker applications — linux/arm64",
@@ -664,8 +670,7 @@ describe("Application deployment dialog", () => {
);
});
- it("names a node's targets by their display names, without their IDs", async () => {
- const user = setupUser();
+ it("names a node's targets by their display names, without their IDs", () => {
render(
{
/>,
);
- await openAdvanced(user);
const options = within(screen.getByLabelText("Execution target")).getAllByRole("option");
expect(options.map((option) => option.textContent)).toEqual([
"Select an eligible execution target",
@@ -718,7 +722,7 @@ describe("Application deployment dialog", () => {
/>,
);
- await openDeploy(user);
+ expectDeployOnly();
await waitFor(() => expect(screen.getByRole("button", { name: "Deploy" })).toBeEnabled());
await user.click(screen.getByRole("button", { name: "Deploy" }));
expect(onSubmit).toHaveBeenCalledWith(
@@ -840,7 +844,7 @@ describe("Application deployment dialog", () => {
expect(within(dialog).queryByLabelText("Execution target")).not.toBeInTheDocument();
expect(summaryRows()).toEqual([
["Inference route", "None"],
- ["Execution target", "local / Docker applications"],
+ ["Target", "local / Docker applications"],
["GPU devices", "none"],
]);
});
@@ -956,7 +960,7 @@ describe("Application deployment dialog", () => {
/>,
);
- await openDeploy(user);
+ expectDeployOnly();
view.rerender(
{
).toHaveTextContent(
"The selected execution target is no longer eligible. Turn on Advanced to select an eligible execution target.",
);
- await waitFor(() => expect(summaryRows()).toEqual([["Execution target", "Unavailable"]]));
+ await waitFor(() => expect(summaryRows()).toEqual([["Target", "Unavailable"]]));
await user.click(screen.getByRole("button", { name: "Deploy" }));
expect(screen.getByRole("checkbox", { name: "Advanced" })).toBeChecked();
- expect(screen.getByText("Step 2 of 3 — Target")).toBeVisible();
+ expect(screen.getByText("Step 1 of 2 — Target")).toBeVisible();
const machineControl = screen.getByLabelText("Execution target");
expect(machineControl).toHaveFocus();
expect(machineControl).toHaveAccessibleDescription("Select an eligible execution target.");
@@ -989,7 +993,7 @@ describe("Application deployment dialog", () => {
const onSubmit = vi.fn();
const dialog = (executionTargets: ReturnType[]) => (
{
);
const view = render(dialog([machine, applicationTarget("spare")]));
- expect(stepLabels()).toEqual(["1. Quick deploy", "2. Target", "3. Deploy"]);
+ expect(stepLabels()).toEqual(["1. Inference route", "2. Target", "3. Deploy"]);
await user.click(screen.getByRole("button", { name: "Next" }));
expect(screen.getByLabelText("Execution target")).toHaveValue("local");
await user.click(screen.getByRole("button", { name: "Next" }));
@@ -1010,14 +1014,14 @@ describe("Application deployment dialog", () => {
).toHaveTextContent(
"The selected execution target is no longer eligible. Return to Target and select an eligible execution target.",
);
- expect(stepLabels()).toEqual(["1. Quick deploy", "2. Target", "3. Deploy"]);
+ expect(stepLabels()).toEqual(["1. Inference route", "2. Target", "3. Deploy"]);
await user.click(screen.getByRole("button", { name: "Deploy" }));
expect(screen.getByText("Step 2 of 3 — Target")).toBeVisible();
expect(screen.getByRole("checkbox", { name: "Advanced" })).not.toBeChecked();
expect(onSubmit).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "Back" }));
- expect(stepLabels()).toEqual(["1. Quick deploy", "2. Deploy"]);
+ expect(stepLabels()).toEqual(["1. Inference route", "2. Deploy"]);
});
it("summarizes resolved values and keeps advanced values when Back returns to the first step", async () => {
@@ -1047,7 +1051,7 @@ describe("Application deployment dialog", () => {
const summary = screen.getByRole("region", { name: "Summary" });
expect(summaryRows()).toEqual([
["Inference route", "PrimaryAvailable"],
- ["Execution target", "remote / Docker applications"],
+ ["Target", "remote / Docker applications"],
["GPU devices", "none"],
]);
expect(within(summary).getByText("Available")).toBeVisible();
@@ -1119,7 +1123,7 @@ describe("Application deployment dialog", () => {
await openDeploy(user);
expect(summaryRows()).toEqual([
["Inference route", "None"],
- ["Execution target", "local / Docker applications"],
+ ["Target", "local / Docker applications"],
["GPU devices", "none"],
]);
});
@@ -1163,7 +1167,7 @@ describe("Application deployment dialog", () => {
await waitFor(() =>
expect(summaryRows()).toEqual([
["Inference route", "PrimaryUnavailable"],
- ["Execution target", "Unavailable"],
+ ["Target", "Unavailable"],
]),
);
@@ -1228,18 +1232,17 @@ describe("Application deployment dialog", () => {
);
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
- expect(stepLabels()).toEqual(["1. Quick deploy", "2. Deploy"]);
- const quickDeploy = screen.getByRole("region", { name: "Quick deploy" });
+ expectDeployOnly();
+ expect(screen.getByRole("textbox", { name: "Deployment name" })).toHaveValue("OpenWebUI");
await waitFor(() =>
- expect([...quickDeploy.querySelectorAll("dd")].map((value) => value.textContent)).toEqual([
- "local / Docker applications",
+ expect(summaryRows()).toEqual([
+ ["Target", "local / Docker applications"],
+ ["GPU devices", "none"],
]),
);
expect(screen.queryByText(/Turn on Advanced to choose/)).not.toBeInTheDocument();
- await openDeploy(user);
- expect(screen.getByRole("textbox", { name: "Deployment name" })).toHaveValue("OpenWebUI");
expect(summaryRows()).toEqual([
- ["Execution target", "local / Docker applications"],
+ ["Target", "local / Docker applications"],
["GPU devices", "none"],
]);
await user.click(screen.getByRole("button", { name: "Deploy" }));
@@ -1268,7 +1271,7 @@ describe("Application deployment dialog", () => {
/>,
);
- await openDeploy(user);
+ expectDeployOnly();
await user.click(screen.getByRole("textbox", { name: "Deployment name" }));
await user.keyboard("{Enter}");
@@ -1289,7 +1292,7 @@ describe("Application deployment dialog", () => {
executionTargets={[machine]}
/>,
);
- await openDeploy(user);
+ expectDeployOnly();
view.rerender(
{
);
}
+ // The default target applies once the dialog has rendered.
async function summaryRows() {
- const user = setupUser();
- await user.click(screen.getByRole("button", { name: "Next" }));
+ await act(async () => {});
return detailTexts(screen.getByRole("dialog"));
}
@@ -1334,7 +1337,7 @@ describe("Application deployment dialog GPU devices", () => {
renderDialog(target);
expect(await summaryRows()).toEqual([
- ["Execution target", "local / Docker applications"],
+ ["Target", "local / Docker applications"],
["GPU devices", "nvidia.com/gpu=0, nvidia.com/gpu=1"],
]);
});
@@ -1343,7 +1346,7 @@ describe("Application deployment dialog GPU devices", () => {
renderDialog(applicationTarget("local"));
expect(await summaryRows()).toEqual([
- ["Execution target", "local / Docker applications"],
+ ["Target", "local / Docker applications"],
["GPU devices", "none"],
]);
});
@@ -1395,10 +1398,16 @@ describe("Application deployment dialog parameters", () => {
}),
});
- function renderDialog(onSubmit = vi.fn()) {
+ // ComfyUI's catalog shape: one advanced parameter and no basic one.
+ const advancedOnly = create(ApplicationSchema, {
+ ...parameterized,
+ graph: create(ApplicationGraphSchema, { parameters: [parameterized.graph!.parameters[0]] }),
+ });
+
+ function renderDialog(onSubmit = vi.fn(), app = parameterized) {
render(
{
return onSubmit;
}
- it("shows advanced parameters only in Advanced and submits only changed values", async () => {
+ it("shows basic parameters on Settings, advanced ones on Advanced, and submits only changed values", async () => {
const user = setupUser();
const onSubmit = renderDialog();
- const parameters = screen.getByRole("region", { name: "Parameters" });
- const workers = within(parameters).getByRole("textbox", { name: "Workers" });
+ expect(stepLabels()).toEqual(["1. Settings", "2. Deploy"]);
+ expect(screen.getByRole("heading", { level: 2, name: "Settings" })).toBeInTheDocument();
+ const workers = screen.getByRole("textbox", { name: "Workers" });
expect(workers).toHaveValue("2");
- await user.click(within(parameters).getByRole("button", { name: "About Workers" }));
+ await user.click(screen.getByRole("button", { name: "About Workers" }));
expect(await screen.findByRole("dialog", { name: "About Workers" })).toHaveTextContent(
"Range 1–8",
);
await user.keyboard("{Escape}");
- expect(within(parameters).queryByRole("combobox", { name: "VRAM mode" })).toBeNull();
+ expect(screen.queryByRole("combobox", { name: "VRAM mode" })).toBeNull();
+ await user.clear(workers);
+ await user.type(workers, "4");
await user.click(screen.getByRole("checkbox", { name: "Advanced" }));
- const vramMode = within(parameters).getByRole("combobox", { name: "VRAM mode" });
+ expect(stepLabels()).toEqual(["1. Target", "2. Settings", "3. Advanced", "4. Deploy"]);
+ expect(screen.getByText("Step 2 of 4 — Settings")).toBeVisible();
+ await user.click(screen.getByRole("button", { name: "Next" }));
+ expect(screen.getByText("Step 3 of 4 — Advanced")).toBeVisible();
+ expect(screen.queryByRole("textbox", { name: "Workers" })).toBeNull();
+ const vramMode = screen.getByRole("combobox", { name: "VRAM mode" });
expect(vramMode).toHaveValue("auto");
expect(
within(vramMode)
@@ -1433,11 +1450,11 @@ describe("Application deployment dialog parameters", () => {
).toEqual(["auto", "highvram"]);
await user.selectOptions(vramMode, "highvram");
await user.click(screen.getByRole("button", { name: "Next" }));
- await user.click(screen.getByRole("button", { name: "Next" }));
expect(summaryRows()).toEqual([
- ["Execution target", "local / Docker applications"],
+ ["Target", "local / Docker applications"],
["GPU devices", "none"],
["VRAM mode", "highvram"],
+ ["Workers", "4"],
]);
await user.click(screen.getByRole("button", { name: "Deploy" }));
@@ -1452,11 +1469,71 @@ describe("Application deployment dialog parameters", () => {
parameterId: "vram-mode",
value: { case: "string", value: "highvram" },
}),
+ create(ParameterValueSchema, {
+ parameterId: "workers",
+ value: { case: "unsignedInteger", value: 4n },
+ }),
],
},
);
});
+ it("opens an application with only advanced parameters on Deploy, and on Target in Advanced", async () => {
+ const user = setupUser();
+ renderDialog(vi.fn(), advancedOnly);
+
+ expectDeployOnly();
+ expect(screen.queryByRole("combobox", { name: "VRAM mode" })).toBeNull();
+
+ await user.click(screen.getByRole("checkbox", { name: "Advanced" }));
+ expect(stepLabels()).toEqual(["1. Target", "2. Advanced", "3. Deploy"]);
+ expect(screen.getByText("Step 3 of 3 — Deploy")).toBeVisible();
+ await user.click(screen.getByRole("button", { name: "Back" }));
+ expect(screen.getByText("Step 2 of 3 — Advanced")).toBeVisible();
+ expect(screen.getByRole("combobox", { name: "VRAM mode" })).toHaveValue("auto");
+
+ // Turning Advanced off removes the step under the operator, who falls forward to Deploy.
+ await user.click(screen.getByRole("checkbox", { name: "Advanced" }));
+ expectDeployOnly();
+ });
+
+ it("turns Advanced on and focuses an invalid advanced parameter when Deploy finds it", async () => {
+ const user = setupUser();
+ const onSubmit = vi.fn();
+ const withoutDefault = create(ApplicationSchema, {
+ ...parameterized,
+ graph: create(ApplicationGraphSchema, {
+ parameters: [
+ create(ParameterDefinitionSchema, {
+ parameterId: "cache-size",
+ displayName: "Cache size",
+ visibility: ParameterVisibility.ADVANCED,
+ parameterType: {
+ case: "unsignedInteger",
+ value: { minimum: 1n, maximum: 64n, unit: "GiB" },
+ },
+ }),
+ ],
+ }),
+ });
+ renderDialog(onSubmit, withoutDefault);
+
+ expectDeployOnly();
+ await waitFor(() =>
+ expect(summaryRows()[0]).toEqual(["Target", "local / Docker applications"]),
+ );
+ await user.click(screen.getByRole("button", { name: "Deploy" }));
+
+ expect(screen.getByText("Step 2 of 3 — Advanced")).toBeVisible();
+ expect(screen.getByRole("checkbox", { name: "Advanced" })).toBeChecked();
+ expect(screen.getByText("Advanced on to fix advanced settings")).toBeInTheDocument();
+ const cacheSize = screen.getByRole("textbox", { name: "Cache size" });
+ await waitFor(() => expect(cacheSize).toHaveFocus());
+ expect(cacheSize).toHaveAttribute("aria-invalid", "true");
+ expect(screen.getByText("Cache size must be an integer from 1 to 64 GiB.")).toBeVisible();
+ expect(onSubmit).not.toHaveBeenCalled();
+ });
+
it("blocks Next on an out-of-range value and focuses its field", async () => {
const user = setupUser();
renderDialog();
@@ -1471,6 +1548,6 @@ describe("Application deployment dialog parameters", () => {
expect(screen.getByText("Workers must be an integer from 1 to 8.")).toBeVisible();
expect(workers).toHaveFocus();
expect(workers).toHaveAttribute("aria-invalid", "true");
- expect(stepLabels()[0]).toBe("1. Quick deploy");
+ expect(screen.getByText("Step 1 of 2 — Settings")).toBeVisible();
});
});
diff --git a/frontend/src/features/applications/application-deploy-dialog.tsx b/frontend/src/features/applications/application-deploy-dialog.tsx
index bfcece51..131568da 100644
--- a/frontend/src/features/applications/application-deploy-dialog.tsx
+++ b/frontend/src/features/applications/application-deploy-dialog.tsx
@@ -3,7 +3,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { parameterDisplayValue } from "@/components/parameters/parameter-view";
import { ParametersForm } from "@/components/parameters/parameters";
-import { Detail, DetailList } from "@/components/ui/detail-list";
+import { Detail } from "@/components/ui/detail-list";
import { Field } from "@/components/ui/field";
import { FieldError } from "@/components/ui/field-error";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
@@ -22,7 +22,6 @@ import { projectRouteHealth } from "@/domain/route-projection";
import { AdvancedToggle } from "@/features/deployments/advanced-toggle";
import {
DeploymentForm,
- DeploymentFormSection,
DeploymentStepHeading,
} from "@/features/deployments/deployment-form-layout";
import { DeploymentSummaryStep } from "@/features/deployments/deployment-summary-step";
@@ -46,6 +45,7 @@ import { type FieldErrors, focusFirstInvalidField, toFieldErrors } from "@/lib/f
import { formatGpuDevices } from "./application-gpu-devices";
import {
+ type ApplicationParameterField,
applicationParameterFields,
changedParameterValues,
defaultParameterInputs,
@@ -83,10 +83,11 @@ export interface ApplicationDeployDialogProps {
}
/**
- * Deploys a catalog application. Basic mode asks for the inference route link, when the application
- * takes one, and the deployment name, adding the Target step only when several execution targets
- * can take the deployment; otherwise it deploys on the default target. The Advanced checkbox always
- * adds the Target step without discarding entered values.
+ * Deploys a catalog application through the shared step shape: Inference route when the application
+ * takes one, Target, Settings with the basic parameters, Advanced with the advanced parameters, and
+ * Deploy, each shown only when it has fields. Basic mode adds the Target step only when several
+ * execution targets can take the deployment, otherwise it deploys on the default target; the
+ * Advanced checkbox always adds Target and Advanced without discarding entered values.
*/
export function ApplicationDeployDialog({
application,
@@ -133,7 +134,6 @@ export function ApplicationDeployDialog({
orderedExecutionTargets.filter((target) => executionTargetPresentation(target).deployable),
[orderedExecutionTargets],
);
- const firstStep = routeBindings.length ? "Inference route" : "Quick deploy";
const advancedMode = useAdvancedMode(open);
const { advanced } = advancedMode;
const dirtyFields = useDirtyFields(open);
@@ -141,7 +141,8 @@ export function ApplicationDeployDialog({
() => applicationParameterFields(application.graph?.parameters),
[application],
);
- const visibleParameterFields = parameterFields.filter((field) => advanced || !field.advanced);
+ const basicParameterFields = parameterFields.filter((field) => !field.advanced);
+ const advancedParameterFields = parameterFields.filter((field) => field.advanced);
const [parameterInputs, setParameterInputs] = useState(() =>
defaultParameterInputs(parameterFields),
);
@@ -152,15 +153,28 @@ export function ApplicationDeployDialog({
const [lockedTargetStep, setLockedTargetStep] = useState();
// Basic mode asks for the target only when several can take the deployment.
const liveTargetStep = deployableExecutionTargets.length > 1;
- const stepsFor = (advancedSteps: boolean) =>
- advancedSteps || (lockedTargetStep ?? liveTargetStep)
- ? [firstStep, "Target", "Deploy"]
- : [firstStep, "Deploy"];
+ const stepsFor = (advancedSteps: boolean): ApplicationDeployStep[] =>
+ APPLICATION_DEPLOY_STEPS.filter(
+ (candidate) =>
+ (candidate === "Inference route" && routeBindings.length > 0) ||
+ (candidate === "Target" && (advancedSteps || (lockedTargetStep ?? liveTargetStep))) ||
+ (candidate === "Settings" && basicParameterFields.length > 0) ||
+ (candidate === "Advanced" && advancedSteps && advancedParameterFields.length > 0) ||
+ candidate === "Deploy",
+ );
const steps = stepsFor(advanced);
- const wizard = useWizardSteps(steps, fallbackStep);
- const step = wizard.current;
+ const firstStep = steps[0];
+ // A vanished step falls forward to the next step that still shows.
+ const wizard = useWizardSteps(
+ steps,
+ (missingStep) =>
+ APPLICATION_DEPLOY_STEPS.slice(
+ APPLICATION_DEPLOY_STEPS.indexOf(missingStep as ApplicationDeployStep) + 1,
+ ).find((candidate) => steps.includes(candidate)) ?? "Deploy",
+ );
+ const step = wizard.current as ApplicationDeployStep;
const targetStepShown = steps.includes("Target");
- const goToStep = (nextStep: string) => {
+ const goToStep = (nextStep: ApplicationDeployStep) => {
const leavingFirstStep = step === firstStep && nextStep !== firstStep;
setLockedTargetStep(
nextStep === firstStep ? undefined : leavingFirstStep ? liveTargetStep : lockedTargetStep,
@@ -224,20 +238,28 @@ export function ApplicationDeployDialog({
)
? undefined
: "Select an eligible execution target.";
- const stepErrors = (activeStep: string): FieldErrors => {
- if (activeStep === firstStep)
- return toFieldErrors([
- ["inferenceRoute", routeBindings.length ? routeSelectionError : undefined],
- ...parameterFields.map((field): [string, string | undefined] => [
- parameterErrorKey(field.view.id),
- parameterInputError(field.view, parameterInputs.get(field.view.id) ?? ""),
- ]),
- ]);
- if (activeStep === "Target")
- return toFieldErrors([
- ["placement", targetIsEligible ? undefined : "Select an eligible execution target."],
- ]);
- return toFieldErrors([["name", validateDeploymentName(deploymentName)]]);
+ const parameterErrors = (
+ fields: readonly ApplicationParameterField[],
+ ): [string, string | undefined][] =>
+ fields.map((field) => [
+ parameterErrorKey(field.view.id),
+ parameterInputError(field.view, parameterInputs.get(field.view.id) ?? ""),
+ ]);
+ const stepErrors = (activeStep: ApplicationDeployStep): FieldErrors => {
+ switch (activeStep) {
+ case "Inference route":
+ return toFieldErrors([["inferenceRoute", routeSelectionError]]);
+ case "Target":
+ return toFieldErrors([
+ ["placement", targetIsEligible ? undefined : "Select an eligible execution target."],
+ ]);
+ case "Settings":
+ return toFieldErrors(parameterErrors(basicParameterFields));
+ case "Advanced":
+ return toFieldErrors(parameterErrors(advancedParameterFields));
+ case "Deploy":
+ return toFieldErrors([["name", validateDeploymentName(deploymentName)]]);
+ }
};
const fieldControls = () =>
new Map([
@@ -249,31 +271,16 @@ export function ApplicationDeployDialog({
["name", deploymentNameRef.current],
["placement", executionTargetRef.current],
]);
- // Turns Advanced on when an error belongs to a hidden advanced parameter, deferring the focus
- // until the field renders.
- const revealHiddenParameters = (errors: FieldErrors): boolean => {
- const hidden =
- !advanced &&
- parameterFields.some(
- (field) => field.advanced && errors.has(parameterErrorKey(field.view.id)),
- );
- if (hidden) {
- advancedMode.enable("Advanced on to fix parameters");
- pendingFocusRef.current = errors;
- }
- return hidden;
- };
const exposeErrors = (errors: FieldErrors): boolean => {
setFieldErrors(errors);
if (!errors.size) return true;
- if (!revealHiddenParameters(errors)) focusFirstInvalidField(errors, fieldControls());
+ focusFirstInvalidField(errors, fieldControls());
return false;
};
// Opens the step that owns the errors, turning Advanced on when that step is hidden, and focuses
// the first invalid field once the step renders.
- const revealErrors = (owner: string, errors: FieldErrors) => {
+ const revealErrors = (owner: ApplicationDeployStep, errors: FieldErrors) => {
setFieldErrors(errors);
- revealHiddenParameters(errors);
if (!steps.includes(owner))
advancedMode.enable(`Advanced on to fix ${owner.toLocaleLowerCase()} settings`);
pendingFocusRef.current = errors;
@@ -327,8 +334,9 @@ export function ApplicationDeployDialog({
const toggleAdvanced = (nextAdvanced: boolean) =>
advancedMode.toggle(nextAdvanced, stepsFor(nextAdvanced).length);
const handleStepChange = (nextStep: string) => {
- if (steps.indexOf(nextStep) > steps.indexOf(step) && !exposeErrors(stepErrors(step))) return;
- goToStep(nextStep);
+ const target = nextStep as ApplicationDeployStep;
+ if (steps.indexOf(target) > steps.indexOf(step) && !exposeErrors(stepErrors(step))) return;
+ goToStep(target);
};
const handlePrimary = () => {
if (step === "Deploy") {
@@ -345,44 +353,42 @@ export function ApplicationDeployDialog({
// ineligible target leaves Deploy enabled, so Deploy opens the Target step to choose another.
const primaryDisabled =
pending ||
- (step === firstStep
+ (step === "Inference route"
? routeUnavailable
: step === "Target"
? targetsUnavailable
- : targetValidationPending || targetsUnavailable || routeUnavailable);
- const parametersSection = visibleParameterFields.length ? (
-
- {
- const fieldError = fieldErrors.get(parameterErrorKey(field.view.id));
- return fieldError ? [[field.view.id, fieldError]] : [];
- }),
- )
- }
- idPrefix="application-parameter"
- inputRefs={parameterRefs}
- inputs={parameterInputs}
- onBlur={(parameterId) => {
- const view = parameterView(parameterId);
- const key = parameterErrorKey(parameterId);
- if (view && dirtyFields.isDirty(key))
- setTimeout(() =>
- setFieldError(key, parameterInputError(view, parameterInputs.get(parameterId) ?? "")),
- );
- }}
- onChange={(parameterId, input) => {
- const key = parameterErrorKey(parameterId);
- dirtyFields.markDirty(key);
- setParameterInputs((current) => new Map(current).set(parameterId, input));
- const view = parameterView(parameterId);
- if (view && fieldErrors.has(key)) setFieldError(key, parameterInputError(view, input));
- }}
- parameters={visibleParameterFields.map((field) => field.view)}
- />
-
- ) : null;
+ : step === "Deploy" && (targetValidationPending || targetsUnavailable || routeUnavailable));
+ const parametersForm = (fields: readonly ApplicationParameterField[]) => (
+ {
+ const fieldError = fieldErrors.get(parameterErrorKey(field.view.id));
+ return fieldError ? [[field.view.id, fieldError]] : [];
+ }),
+ )
+ }
+ idPrefix="application-parameter"
+ inputRefs={parameterRefs}
+ inputs={parameterInputs}
+ onBlur={(parameterId) => {
+ const view = parameterView(parameterId);
+ const key = parameterErrorKey(parameterId);
+ if (view && dirtyFields.isDirty(key))
+ setTimeout(() =>
+ setFieldError(key, parameterInputError(view, parameterInputs.get(parameterId) ?? "")),
+ );
+ }}
+ onChange={(parameterId, input) => {
+ const key = parameterErrorKey(parameterId);
+ dirtyFields.markDirty(key);
+ setParameterInputs((current) => new Map(current).set(parameterId, input));
+ const view = parameterView(parameterId);
+ if (view && fieldErrors.has(key)) setFieldError(key, parameterInputError(view, input));
+ }}
+ parameters={fields.map((field) => field.view)}
+ />
+ );
const targetNotice = !effectiveExecutionTargetsCurrent
? undefined
: !orderedExecutionTargets.length
@@ -425,19 +431,10 @@ export function ApplicationDeployDialog({
{routeError}
) : null}
- {step === firstStep && !routeBindings.length ? (
-
-
-
-
-
-
- {parametersSection}
-
- ) : null}
- {step === firstStep && routeBindings.length ? (
+ {step === "Inference route" ? (
-
+ Inference route
+
) : null}
-
- {parametersSection}
+
) : null}
{step === "Target" ? (
@@ -559,6 +555,18 @@ export function ApplicationDeployDialog({
) : null}
) : null}
+ {step === "Settings" ? (
+
+ Settings
+ {parametersForm(basicParameterFields)}
+
+ ) : null}
+ {step === "Advanced" ? (
+
+ Advanced
+ {parametersForm(advancedParameterFields)}
+
+ ) : null}
{step === "Deploy" ? (
) : null}
-
+
{selectedExecutionTarget ? (
;
bindingId: string;
customArgumentTokens: readonly string[];
@@ -45,6 +45,7 @@ interface ModelDeployRuntimeStepProps {
engineParameterRefs: RefObject>;
/** The engine releases the deployment may run: the target's selected one, or every approved one. */
engineReleases: readonly string[];
+ /** The advanced engine parameters outside the context. */
exposedParameters: readonly ParameterDefinition[];
fieldErrors: FieldErrors;
inputs: ReadonlyMap;
@@ -68,6 +69,8 @@ interface ModelDeployRuntimeStepProps {
profile?: ModelProfile;
/** The errors of the latest blocked navigation; a new value opens the sections it flags. */
revealedErrors: FieldErrors;
+ /** Whether the profile shows any engine parameter, on this step or the Settings step. */
+ schemaExposesParameters: boolean;
speculative: {
choice: SpeculativeChoice;
countRef: RefObject;
@@ -80,13 +83,14 @@ interface ModelDeployRuntimeStepProps {
}
/**
- * The advanced Runtime step of the model deploy dialog, driven by the selected profile's engine
- * parameter schema and options: parallel requests, a host memory group, the KV cache precision and
+ * The Advanced step of the model deploy dialog, driven by the selected profile's advanced engine
+ * parameters and options: parallel requests, a host memory group, the KV cache precision and
* speculative decoding choices, and the remaining parameters, model artifact, and custom arguments
- * in collapsed sections. The context sizes belong to the Context step. A collapsed section opens
- * when a blocked navigation flags one of its fields, so the field can take focus.
+ * in collapsed sections. The context sizes and basic parameters belong to the Settings step. A
+ * collapsed section opens when a blocked navigation flags one of its fields, so the field can take
+ * focus.
*/
-export function ModelDeployRuntimeStep({
+export function ModelDeployAdvancedStep({
artifactRef,
bindingId,
customArgumentTokens,
@@ -110,8 +114,9 @@ export function ModelDeployRuntimeStep({
previewPending,
profile,
revealedErrors,
+ schemaExposesParameters,
speculative,
-}: ModelDeployRuntimeStepProps) {
+}: ModelDeployAdvancedStepProps) {
const configuration = model.configurations.find(
(candidate) => candidate.configurationId === profile?.configurationId,
);
@@ -135,8 +140,8 @@ export function ModelDeployRuntimeStep({
return (
- Runtime
- {exposedParameters.length ? null : (
+ Advanced
+ {schemaExposesParameters ? null : (
The selected engine release schema has no exposed parameters.
@@ -177,7 +182,7 @@ export function ModelDeployRuntimeStep({
/>
) : null}
{groups.other.length ? (
- parameter.parameterId)}
@@ -185,9 +190,9 @@ export function ModelDeployRuntimeStep({
title="Other parameters"
>
{parametersForm(groups.other)}
-
+
) : null}
-
) : null}
-
-
+
{previewPending ? "Previewing…" : "Preview resolved command"}
-
+
);
}
-interface RuntimeSectionProps {
+interface AdvancedSectionProps {
children: ReactNode;
/** The number of fields inside, shown after the title. */
count?: number;
@@ -258,7 +263,7 @@ interface RuntimeSectionProps {
/**
* The model artifact choices of a configuration, each named by the profile that launches it by
- * default, since the Runtime step shows the artifact's quantization beside the choice. An artifact
+ * default, since the Advanced step shows the artifact's quantization beside the choice. An artifact
* no profile defaults to, or one whose name another choice shares, is named by its artifact ID.
*/
function artifactOptions(model: Model, configuration: ModelEngineConfiguration | undefined) {
@@ -281,18 +286,18 @@ function artifactOptions(model: Model, configuration: ModelEngineConfiguration |
}
/**
- * A Runtime section, collapsed unless one of its fields is invalid when it mounts. A new blocked
+ * An Advanced section, collapsed unless one of its fields is invalid when it mounts. A new blocked
* navigation that flags one of its fields opens it, so the field renders before the dialog moves
* focus to it; the operator can collapse it again.
*/
-function RuntimeSection({
+function AdvancedSection({
children,
count,
errors,
fields = [],
revealedErrors,
title,
-}: RuntimeSectionProps) {
+}: AdvancedSectionProps) {
const [open, setOpen] = useState(() => fields.some((field) => errors.has(field)));
const [shownReveal, setShownReveal] = useState(revealedErrors);
diff --git a/frontend/src/features/models/deploy/model-deploy-dialog.test.tsx b/frontend/src/features/models/deploy/model-deploy-dialog.test.tsx
index f0af8f5a..4abf5c08 100644
--- a/frontend/src/features/models/deploy/model-deploy-dialog.test.tsx
+++ b/frontend/src/features/models/deploy/model-deploy-dialog.test.tsx
@@ -92,7 +92,7 @@ describe("ModelDeployDialog", () => {
renderDialog();
await screen.findByText("Step 1 of 3 — Profile");
- expect(stepLabels()).toEqual(["1. Profile", "2. Context", "3. Deploy"]);
+ expect(stepLabels()).toEqual(["1. Profile", "2. Settings", "3. Deploy"]);
expect(screen.getByRole("checkbox", { name: "Advanced" })).not.toBeChecked();
expect(
within(screen.getByTestId("deployment-dialog-footer-start")).getByRole("checkbox", {
@@ -111,7 +111,7 @@ describe("ModelDeployDialog", () => {
renderDialog({ listConfiguredExecutionTargets: targetsLoader(poolTarget(), workerTarget()) });
await waitFor(() =>
- expect(stepLabels()).toEqual(["1. Profile", "2. Target", "3. Context", "4. Deploy"]),
+ expect(stepLabels()).toEqual(["1. Profile", "2. Target", "3. Settings", "4. Deploy"]),
);
expect(screen.getByText("Step 1 of 4 — Profile")).toBeVisible();
await user.click(screen.getByRole("button", { name: "Next" }));
@@ -132,7 +132,7 @@ describe("ModelDeployDialog", () => {
});
await screen.findByText("Step 1 of 3 — Profile");
- expect(stepLabels()).toEqual(["1. Profile", "2. Context", "3. Deploy"]);
+ expect(stepLabels()).toEqual(["1. Profile", "2. Settings", "3. Deploy"]);
});
it("shows every advanced step and never a review step", async () => {
@@ -145,8 +145,8 @@ describe("ModelDeployDialog", () => {
expect(stepLabels()).toEqual([
"1. Profile",
"2. Target",
- "3. Context",
- "4. Runtime",
+ "3. Settings",
+ "4. Advanced",
"5. Deploy",
]);
expect(screen.getByText("Step 1 of 5 — Profile")).toBeVisible();
@@ -170,7 +170,7 @@ describe("ModelDeployDialog", () => {
rerender(dialog({ open: true }));
expect(screen.getByRole("checkbox", { name: "Advanced" })).not.toBeChecked();
- expect(stepLabels()).toEqual(["1. Profile", "2. Context", "3. Deploy"]);
+ expect(stepLabels()).toEqual(["1. Profile", "2. Settings", "3. Deploy"]);
});
});
@@ -179,7 +179,7 @@ describe("ModelDeployDialog", () => {
const user = setupUser();
renderDialog();
- await openAdvancedRuntime(user);
+ await openAdvancedStep(user);
await user.click(screen.getByRole("button", { name: "Back" }));
const context = screen.getByRole("textbox", { name: /Total context/ });
await user.clear(context);
@@ -205,8 +205,8 @@ describe("ModelDeployDialog", () => {
await user.click(screen.getByRole("checkbox", { name: "Advanced" }));
expect(screen.getByText("Step 5 of 5 — Deploy")).toBeVisible();
expect(stepButton("Target")).toBeEnabled();
- expect(stepButton("Context")).toBeEnabled();
- expect(stepButton("Runtime")).toBeEnabled();
+ expect(stepButton("Settings")).toBeEnabled();
+ expect(stepButton("Advanced")).toBeEnabled();
expect(stepButton("Profile")).toBeEnabled();
expect(screen.getByRole("textbox", { name: "Route name" })).toHaveValue("custom-route");
@@ -215,7 +215,7 @@ describe("ModelDeployDialog", () => {
expect(screen.getByRole("textbox", { name: /Total context/ })).toHaveValue("16384");
});
- it("falls forward to Context when the target step vanishes", async () => {
+ it("falls forward to Settings when the target step vanishes", async () => {
const user = setupUser();
renderDialog();
@@ -225,14 +225,14 @@ describe("ModelDeployDialog", () => {
expect(screen.getByText("Step 2 of 5 — Target")).toBeVisible();
await user.click(screen.getByRole("checkbox", { name: "Advanced" }));
- expect(screen.getByText("Step 2 of 3 — Context")).toBeVisible();
+ expect(screen.getByText("Step 2 of 3 — Settings")).toBeVisible();
});
- it("falls back to Deploy when the runtime step vanishes", async () => {
+ it("falls back to Deploy when the Advanced step vanishes", async () => {
const user = setupUser();
renderDialog();
- await openAdvancedRuntime(user);
+ await openAdvancedStep(user);
await user.click(screen.getByRole("checkbox", { name: "Advanced" }));
expect(screen.getByText("Step 3 of 3 — Deploy")).toBeVisible();
@@ -241,7 +241,7 @@ describe("ModelDeployDialog", () => {
);
});
- it("follows the profile's visibility overrides on the Context and Runtime steps", async () => {
+ it("follows the profile's visibility overrides on the Settings and Advanced steps", async () => {
const user = setupUser();
const overridden = clone(ModelSchema, multiParameterModel);
overridden.profiles[0].engineParameterVisibilityOverrides = [
@@ -255,16 +255,84 @@ describe("ModelDeployDialog", () => {
await enableAdvanced(user);
await user.click(screen.getByRole("button", { name: "Next" }));
await user.click(screen.getByRole("button", { name: "Next" }));
- expect(screen.getByText("Step 3 of 5 — Context")).toBeVisible();
+ expect(screen.getByText("Step 3 of 5 — Settings")).toBeVisible();
expect(screen.getByText("This profile sets its context itself.")).toBeVisible();
expect(screen.queryByText("Total context")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Next" }));
- expect(screen.getByText("Step 4 of 5 — Runtime")).toBeVisible();
+ expect(screen.getByText("Step 4 of 5 — Advanced")).toBeVisible();
expect(screen.getByRole("textbox", { name: "Parallel requests" })).toHaveValue("4");
expect(screen.queryByText(/Other parameters/)).not.toBeInTheDocument();
});
+ it("shows parameters a profile makes basic on the Settings step after the context", async () => {
+ const user = setupUser();
+ const onSubmit = vi.fn();
+ const promoted = clone(ModelSchema, multiParameterModel);
+ promoted.profiles[0].engineParameterVisibilityOverrides = [
+ { parameterId: "parallel-requests", visibility: ParameterVisibility.BASIC },
+ { parameterId: "enable-prefix-caching", visibility: ParameterVisibility.BASIC },
+ ] as ParameterVisibilityOverride[];
+ renderDialog({ engineReleases: multiParameterReleases, model: promoted, onSubmit });
+
+ await openSettings(user);
+ const total = screen.getByRole("textbox", { name: /Total context/ });
+ const parallel = screen.getByRole("textbox", { name: "Parallel requests" });
+ const prefixCaching = screen.getByRole("switch", { name: "Prefix caching" });
+ expect(total.compareDocumentPosition(parallel)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
+ expect(parallel.compareDocumentPosition(prefixCaching)).toBe(
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ );
+ expect(prefixCaching).toHaveAccessibleDescription("Caches reusable prompt prefixes.");
+ await user.clear(parallel);
+ await user.type(parallel, "2");
+ await user.click(prefixCaching);
+
+ await user.click(screen.getByRole("button", { name: "Next" }));
+ expect(screen.getByText("Step 3 of 3 — Deploy")).toBeVisible();
+ expect(summaryRows()).toEqual([
+ ["Target", "node / NVIDIA GeForce RTX 5090"],
+ ["Profile", "NVFP4 text"],
+ ["Context", "32k total · 32k per request"],
+ ["Parallel requests", "2 requests"],
+ ["Prefix caching", "Enabled"],
+ ]);
+
+ // The Advanced step keeps only the advanced parameters, so none of these show there.
+ await enableAdvanced(user);
+ await user.click(stepButton("Advanced"));
+ expect(screen.getByText("Step 4 of 5 — Advanced")).toBeVisible();
+ expect(screen.queryByRole("textbox", { name: "Parallel requests" })).not.toBeInTheDocument();
+ expect(screen.queryByText(/Other parameters/)).not.toBeInTheDocument();
+
+ await goToDeploy(user);
+ await user.click(screen.getByRole("button", { name: "Deploy" }));
+ await waitFor(() =>
+ expect(onSubmit).toHaveBeenCalledWith({
+ configuration: {
+ customArgumentTokens: [],
+ engineParameterValues: {
+ values: [
+ { parameterId: "total-context", value: { case: "unsignedInteger", value: 32768n } },
+ { parameterId: "parallel-requests", value: { case: "unsignedInteger", value: 2n } },
+ create(ParameterValueSchema, {
+ parameterId: "kv-cache-memory-bytes",
+ value: { case: "unsignedInteger", value: 7400000000n },
+ }),
+ { parameterId: "enable-prefix-caching", value: { case: "boolean", value: true } },
+ ],
+ },
+ modelArtifactBindingId: "nvfp4-binding",
+ optionOverrides: [],
+ },
+ exposure: { kind: { case: "direct", value: { routeName: "wide-model" } } },
+ name: "Wide Model",
+ profileId: "text",
+ placement: create(ExactPlacementSchema, poolPlacement),
+ }),
+ );
+ });
+
it("reveals invalid parallel requests with Advanced instead of deploying", async () => {
const user = setupUser();
const onSubmit = vi.fn();
@@ -275,7 +343,7 @@ describe("ModelDeployDialog", () => {
onSubmit,
});
- await openAdvancedRuntime(user);
+ await openAdvancedStep(user);
const parallel = screen.getByRole("textbox", { name: "Parallel requests" });
await user.clear(parallel);
await user.type(parallel, "0");
@@ -287,7 +355,7 @@ describe("ModelDeployDialog", () => {
);
await user.click(screen.getByRole("button", { name: "Deploy" }));
- expect(await screen.findByText("Step 4 of 5 — Runtime")).toBeVisible();
+ expect(await screen.findByText("Step 4 of 5 — Advanced")).toBeVisible();
expect(screen.getByRole("checkbox", { name: "Advanced" })).toBeChecked();
const revealed = screen.getByRole("textbox", { name: "Parallel requests" });
await waitFor(() => expect(revealed).toHaveFocus());
@@ -304,7 +372,7 @@ describe("ModelDeployDialog", () => {
const user = setupUser();
renderDialog();
- await openContext(user);
+ await openSettings(user);
await user.click(screen.getByRole("button", { name: "Next" }));
expect(screen.getByRole("textbox", { name: "Deployment name" })).toHaveValue("Test Model");
@@ -319,7 +387,7 @@ describe("ModelDeployDialog", () => {
const user = setupUser();
renderDialog();
- await openContext(user);
+ await openSettings(user);
await user.click(screen.getByRole("button", { name: "Next" }));
const name = screen.getByRole("textbox", { name: "Deployment name" });
await user.click(name);
@@ -341,7 +409,7 @@ describe("ModelDeployDialog", () => {
const onSubmit = vi.fn();
renderDialog({ onSubmit });
- await openContext(user);
+ await openSettings(user);
await user.click(screen.getByRole("button", { name: "Next" }));
const name = screen.getByRole("textbox", { name: "Deployment name" });
await user.clear(name);
@@ -352,11 +420,11 @@ describe("ModelDeployDialog", () => {
expect(onSubmit).not.toHaveBeenCalled();
});
- it("lists only customized runtime values", async () => {
+ it("lists each changed parameter and the other customized advanced settings", async () => {
const user = setupUser();
renderDialog({ engineReleases: multiParameterReleases, model: multiParameterModel });
- await openAdvancedRuntime(user);
+ await openAdvancedStep(user);
await expandSection(user, "Other parameters (1)");
await user.click(screen.getByRole("switch", { name: "Prefix caching" }));
await expandSection(user, "Custom arguments and command");
@@ -368,7 +436,8 @@ describe("ModelDeployDialog", () => {
["Target", "node / NVIDIA GeForce RTX 5090"],
["Profile", "NVFP4 text"],
["Context", "32k total · 32k per request"],
- ["Runtime", "Customized: Prefix caching enabled · 1 custom argument"],
+ ["Prefix caching", "Enabled"],
+ ["Advanced", "Customized: 1 custom argument"],
]);
});
@@ -380,7 +449,7 @@ describe("ModelDeployDialog", () => {
model: runtimeSchemaModel,
});
- await openAdvancedRuntime(user);
+ await openAdvancedStep(user);
const parallel = screen.getByRole("textbox", { name: "Parallel requests" });
await user.clear(parallel);
await user.type(parallel, "2");
@@ -396,9 +465,9 @@ describe("ModelDeployDialog", () => {
await user.click(screen.getByRole("button", { name: "Next" }));
expect(screen.getByRole("textbox", { name: "Parallel requests" })).toHaveValue("2");
await user.click(screen.getByRole("button", { name: "Next" }));
- expect(summaryRows().find(([label]) => label === "Runtime")).toEqual([
- "Runtime",
- "Customized: Parallel requests 2 requests",
+ expect(summaryRows().find(([label]) => label === "Parallel requests")).toEqual([
+ "Parallel requests",
+ "2 requests",
]);
});
@@ -408,7 +477,7 @@ describe("ModelDeployDialog", () => {
await screen.findByText("Step 1 of 3 — Profile");
expect(screen.queryByText(profileResetHint)).not.toBeInTheDocument();
- await openAdvancedRuntime(user);
+ await openAdvancedStep(user);
await expandSection(user, "Engine and artifact");
await user.click(screen.getByRole("combobox", { name: "Model artifact" }));
await user.click((await screen.findAllByText("Large context"))[0]);
@@ -654,7 +723,7 @@ describe("ModelDeployDialog", () => {
await screen.findByRole("button", { name: "Runs on filter: this site" });
expect(screen.getByRole("radio", { name: "Large context" })).toBeChecked();
- expect(stepLabels()).toEqual(["1. Profile", "2. Context", "3. Deploy"]);
+ expect(stepLabels()).toEqual(["1. Profile", "2. Settings", "3. Deploy"]);
await user.click(screen.getByRole("button", { name: "Next" }));
await user.click(screen.getByRole("button", { name: "Next" }));
@@ -745,7 +814,7 @@ describe("ModelDeployDialog", () => {
expect(screen.getByRole("button", { name: "Runs on filter: Worker" })).toBeVisible();
expect(profileRows()).toEqual(["Fast start"]);
expect(screen.getByRole("radio", { name: "Fast start" })).toBeChecked();
- expect(stepLabels()).toEqual(["1. Profile", "2. Context", "3. Deploy"]);
+ expect(stepLabels()).toEqual(["1. Profile", "2. Settings", "3. Deploy"]);
await user.click(screen.getByRole("button", { name: "Next" }));
await user.click(screen.getByRole("button", { name: "Next" }));
@@ -793,7 +862,7 @@ describe("ModelDeployDialog", () => {
),
});
- await openAdvancedRuntime(user);
+ await openAdvancedStep(user);
await user.click(screen.getByRole("button", { name: "Back" }));
await user.click(screen.getByRole("button", { name: "Back" }));
await user.click(screen.getByRole("combobox", { name: "Execution target" }));
@@ -832,7 +901,7 @@ describe("ModelDeployDialog", () => {
listProfileCompatibility: loader,
});
await settle();
- expect(stepLabels()).toEqual(["1. Profile", "2. Context", "3. Deploy"]);
+ expect(stepLabels()).toEqual(["1. Profile", "2. Settings", "3. Deploy"]);
fireEvent.click(screen.getByRole("button", { name: "Next" }));
fireEvent.click(screen.getByRole("button", { name: "Next" }));
expect(screen.getByText("Step 3 of 3 — Deploy")).toBeVisible();
@@ -841,12 +910,12 @@ describe("ModelDeployDialog", () => {
await vi.advanceTimersByTimeAsync(productionTiming.profileCompatibilityRefreshMs);
});
expect(loader).toHaveBeenCalledTimes(2);
- expect(stepLabels()).toEqual(["1. Profile", "2. Context", "3. Deploy"]);
+ expect(stepLabels()).toEqual(["1. Profile", "2. Settings", "3. Deploy"]);
expect(screen.getByText("Step 3 of 3 — Deploy")).toBeVisible();
fireEvent.click(screen.getByRole("button", { name: "Back" }));
fireEvent.click(screen.getByRole("button", { name: "Back" }));
- expect(stepLabels()).toEqual(["1. Profile", "2. Target", "3. Context", "4. Deploy"]);
+ expect(stepLabels()).toEqual(["1. Profile", "2. Target", "3. Settings", "4. Deploy"]);
});
it("blocks a profile that runs only on machines that can't deploy right now", async () => {
@@ -1163,7 +1232,7 @@ describe("ModelDeployDialog", () => {
});
await waitFor(() =>
- expect(stepLabels()).toEqual(["1. Profile", "2. Target", "3. Context", "4. Deploy"]),
+ expect(stepLabels()).toEqual(["1. Profile", "2. Target", "3. Settings", "4. Deploy"]),
);
await user.click(screen.getByRole("button", { name: "Next" }));
const picker = screen.getByRole("combobox", { name: "Execution target" });
@@ -1523,7 +1592,7 @@ describe("ModelDeployDialog", () => {
const user = setupUser();
renderDialog({ existingRouteNames: ["test-model"] });
- await openContext(user);
+ await openSettings(user);
await user.click(screen.getByRole("button", { name: "Next" }));
const route = screen.getByRole("textbox", { name: "Route name" });
expect(screen.getByRole("button", { name: "Deploy" })).toBeDisabled();
@@ -1541,7 +1610,7 @@ describe("ModelDeployDialog", () => {
const user = setupUser();
renderDialog({ existingRouteNames: ["test-model"] });
- await openContext(user);
+ await openSettings(user);
await user.click(screen.getByRole("button", { name: "Next" }));
const route = screen.getByRole("textbox", { name: "Route name" });
await user.clear(route);
@@ -1555,7 +1624,7 @@ describe("ModelDeployDialog", () => {
const user = setupUser();
renderDialog({ existingRouteNames: ["taken"] });
- await openContext(user);
+ await openSettings(user);
await user.click(screen.getByRole("button", { name: "Next" }));
const route = screen.getByRole("textbox", { name: "Route name" });
await user.clear(route);
@@ -1570,7 +1639,7 @@ describe("ModelDeployDialog", () => {
const user = setupUser();
renderDialog({ routeConflict: { routeName: "test-model" } });
- await openContext(user);
+ await openSettings(user);
await user.click(screen.getByRole("button", { name: "Next" }));
const route = screen.getByRole("textbox", { name: "Route name" });
expect(screen.getByRole("button", { name: "Deploy" })).toBeDisabled();
@@ -1622,7 +1691,7 @@ describe("ModelDeployDialog", () => {
async function openDeploy(props: Partial> = {}) {
const user = setupUser();
renderDialog(props);
- await openContext(user);
+ await openSettings(user);
await user.click(screen.getByRole("button", { name: "Next" }));
expect(screen.getByText("Step 3 of 3 — Deploy")).toBeVisible();
return user;
@@ -1812,7 +1881,7 @@ describe("ModelDeployDialog", () => {
const user = setupUser();
renderDialog({ onSubmit });
- await openContext(user);
+ await openSettings(user);
await user.click(screen.getByRole("button", { name: "Next" }));
const name = screen.getByRole("textbox", { name: "Deployment name" });
await user.clear(name);
@@ -1830,7 +1899,7 @@ describe("ModelDeployDialog", () => {
const user = setupUser();
renderDialog();
- await openContext(user);
+ await openSettings(user);
await user.click(screen.getByRole("button", { name: "Next" }));
const name = screen.getByRole("textbox", { name: "Deployment name" });
await user.clear(name);
@@ -1907,9 +1976,9 @@ describe("ModelDeployDialog", () => {
});
await waitFor(() => expect(onEstimate).toHaveBeenCalledOnce());
- await openAdvancedRuntime(user);
+ await openAdvancedStep(user);
await user.click(screen.getByRole("button", { name: "Back" }));
- // The hidden parallel requests stay off the Context step.
+ // The hidden parallel requests stay off the Settings step.
expect(screen.queryByRole("textbox", { name: /Parallel requests/ })).not.toBeInTheDocument();
const context = screen.getByRole("textbox", { name: /Total context/ });
await user.clear(context);
@@ -1986,7 +2055,7 @@ describe("ModelDeployDialog", () => {
});
await waitFor(() => expect(onEstimate).toHaveBeenCalledOnce());
- await openAdvancedRuntime(user);
+ await openAdvancedStep(user);
// The KV offload field surfaces the schema label and the profile default (16 GiB).
const kvOffload = within(screen.getByRole("region", { name: "Host memory" })).getByRole(
@@ -2028,7 +2097,7 @@ describe("ModelDeployDialog", () => {
await waitFor(() => expect(onPreview).toHaveBeenCalledOnce());
await user.click(screen.getByRole("button", { name: "Close command preview" }));
await goToDeploy(user);
- expect(summaryRows()[3]).toEqual(["Runtime", "Customized: KV offloading size 32 GiB"]);
+ expect(summaryRows()[3]).toEqual(["KV offloading size", "32 GiB"]);
await user.click(screen.getByRole("button", { name: "Deploy" }));
const expectedRequest = {
@@ -2061,7 +2130,7 @@ describe("ModelDeployDialog", () => {
const onSubmit = vi.fn();
renderDialog({ onPreview, onSubmit });
- await openAdvancedRuntime(user);
+ await openAdvancedStep(user);
expect(screen.queryByText(/Expert use/)).not.toBeInTheDocument();
await expandSection(user, "Custom arguments and command");
await user.click(screen.getByRole("button", { name: "Add token" }));
@@ -2121,7 +2190,7 @@ describe("ModelDeployDialog", () => {
expect(onPreview).toHaveBeenCalledWith(customValues);
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(customValues));
- await user.click(stepButton("Runtime"));
+ await user.click(stepButton("Advanced"));
await expandSection(user, "Custom arguments and command");
await user.click(screen.getByRole("button", { name: "Remove custom token 1" }));
expect(screen.getByRole("textbox", { name: "Custom token 1" })).toHaveValue(" spaced ");
@@ -2132,7 +2201,7 @@ describe("ModelDeployDialog", () => {
const user = setupUser();
renderDialog();
- await openContext(user);
+ await openSettings(user);
const context = screen.getByRole("textbox", { name: /Total context/ });
await user.click(context);
await user.tab();
@@ -2149,11 +2218,11 @@ describe("ModelDeployDialog", () => {
).toBeVisible();
});
- it("holds an invalid context on the Context step with its exact range", async () => {
+ it("holds an invalid context on the Settings step with its exact range", async () => {
const user = setupUser();
renderDialog();
- await openContext(user);
+ await openSettings(user);
const context = screen.getByRole("textbox", { name: /Total context/ });
await user.clear(context);
await user.type(context, "12");
@@ -2168,7 +2237,7 @@ describe("ModelDeployDialog", () => {
);
await user.click(screen.getByRole("button", { name: "Next" }));
- expect(screen.getByText("Step 2 of 3 — Context")).toBeVisible();
+ expect(screen.getByText("Step 2 of 3 — Settings")).toBeVisible();
expect(context).toHaveFocus();
});
});
@@ -2186,7 +2255,7 @@ describe("ModelDeployDialog", () => {
renderDialog({ onEstimate, deployments: [] });
await waitFor(() => expect(onEstimate).toHaveBeenCalledOnce());
- await openContext(user);
+ await openSettings(user);
expect(onEstimate).toHaveBeenLastCalledWith(estimateRequest("default", "q4-binding", 8192n));
await user.clear(screen.getByRole("textbox", { name: /Total context/ }));
@@ -2223,7 +2292,7 @@ describe("ModelDeployDialog", () => {
renderDialog({ onEstimate, deployments: [] });
await waitFor(() => expect(onEstimate).toHaveBeenCalledOnce());
- await openAdvancedRuntime(user);
+ await openAdvancedStep(user);
await expandSection(user, "Engine and artifact");
await user.click(screen.getByRole("combobox", { name: "Model artifact" }));
await user.click((await screen.findAllByText("Large context"))[0]);
@@ -2270,7 +2339,7 @@ describe("ModelDeployDialog", () => {
render({dialog({ onEstimate, deployments: [] })} );
await waitFor(() => expect(onEstimate).toHaveBeenCalledOnce());
- await openAdvancedRuntime(user);
+ await openAdvancedStep(user);
expect(
await within(screen.getByRole("region", { name: "Predicted memory" })).findByRole(
"button",
@@ -2290,7 +2359,7 @@ describe("ModelDeployDialog", () => {
renderDialog({ onEstimate, deployments: [] });
await waitFor(() => expect(onEstimate).toHaveBeenCalledOnce());
- await openAdvancedRuntime(user);
+ await openAdvancedStep(user);
expect(
await within(screen.getByRole("region", { name: "Predicted memory" })).findByRole(
"button",
@@ -2326,7 +2395,7 @@ describe("ModelDeployDialog", () => {
deployments: [],
});
- await openAdvancedRuntime(user);
+ await openAdvancedStep(user);
expect(
await within(screen.getByRole("region", { name: "Predicted memory" })).findByText(
"Estimate unavailable.",
@@ -2350,7 +2419,7 @@ describe("ModelDeployDialog", () => {
onPreviewMemory,
});
- await openAdvancedRuntime(user);
+ await openAdvancedStep(user);
expect(await screen.findByText(unifiedKvOffloadWarning)).toBeVisible();
expect(onPreviewMemory).toHaveBeenLastCalledWith({
...estimateRequest("default", "q4-binding", 8192n),
@@ -2375,7 +2444,7 @@ describe("ModelDeployDialog", () => {
onPreviewMemory,
});
- await openAdvancedRuntime(user);
+ await openAdvancedStep(user);
await goToDeploy(user);
expect(
await screen.findByText(
@@ -2400,7 +2469,7 @@ describe("ModelDeployDialog", () => {
onPreviewMemory,
});
- await openAdvancedRuntime(user);
+ await openAdvancedStep(user);
await waitFor(() => expect(onPreviewMemory).toHaveBeenCalled());
expect(screen.queryByText(unifiedKvOffloadWarning)).not.toBeInTheDocument();
});
@@ -2419,7 +2488,7 @@ describe("ModelDeployDialog", () => {
});
renderDialog({ onPreviewMemory });
- await openAdvancedRuntime(user);
+ await openAdvancedStep(user);
expect(await screen.findByText(unifiedKvOffloadWarning)).toBeVisible();
await user.click(screen.getByRole("button", { name: "Back" }));
@@ -2447,7 +2516,7 @@ describe("ModelDeployDialog", () => {
onPreviewMemory,
});
- await openAdvancedRuntime(user);
+ await openAdvancedStep(user);
await waitFor(() => expect(onPreviewMemory).toHaveBeenCalled());
expect(screen.queryByText(unifiedKvOffloadWarning)).not.toBeInTheDocument();
// Background failures stay quiet; the explicit command preview reports them.
@@ -2468,7 +2537,7 @@ describe("ModelDeployDialog", () => {
}),
);
renderDialog({ onPreview });
- await openAdvancedRuntimeWithFakeTimers();
+ await openAdvancedStepWithFakeTimers();
fireEvent.click(screen.getByRole("button", { name: "Custom arguments and command" }));
fireEvent.click(screen.getByRole("button", { name: "Preview resolved command" }));
@@ -2509,7 +2578,7 @@ describe("ModelDeployDialog", () => {
.mockResolvedValueOnce(snapshot);
renderDialog({ onPreview });
- await openAdvancedRuntime(user);
+ await openAdvancedStep(user);
await expandSection(user, "Custom arguments and command");
await user.click(screen.getByRole("button", { name: "Preview resolved command" }));
const previewDialog = screen.getByRole("dialog", { name: "Command preview" });
@@ -2537,7 +2606,7 @@ describe("ModelDeployDialog", () => {
);
renderDialog({ onPreview });
- await openAdvancedRuntime(user);
+ await openAdvancedStep(user);
await expandSection(user, "Custom arguments and command");
await user.click(screen.getByRole("button", { name: "Preview resolved command" }));
expect(onPreview).toHaveBeenCalledOnce();
@@ -2560,7 +2629,7 @@ describe("ModelDeployDialog", () => {
const user = setupUser();
renderDialog({ onPreview: vi.fn().mockResolvedValue(snapshot) });
- await openAdvancedRuntime(user);
+ await openAdvancedStep(user);
await expandSection(user, "Custom arguments and command");
const previewButton = screen.getByRole("button", { name: "Preview resolved command" });
await user.click(previewButton);
@@ -2924,7 +2993,7 @@ describe("ModelDeployDialog", () => {
const body = within(modal).getByTestId("deployment-dialog-body");
await screen.findByText("Step 1 of 3 — Profile");
expect(within(body).getByRole("table", { name: "Model profiles" })).toBeVisible();
- await openAdvancedRuntime(user);
+ await openAdvancedStep(user);
const form = within(body).getByTestId("deployment-form");
const footer = within(modal).getByTestId("deployment-dialog-footer");
@@ -2973,7 +3042,7 @@ describe("ModelDeployDialog", () => {
}),
),
});
- await openAdvancedRuntime(user);
+ await openAdvancedStep(user);
await expandSection(user, "Engine and artifact");
const engine = () =>
detailRows(screen.getByText("Configuration").closest("dl")!).find(
@@ -3144,30 +3213,30 @@ async function enableAdvanced(user: UserEvent) {
await user.click(screen.getByRole("checkbox", { name: "Advanced" }));
}
-/** Expands one collapsed Runtime section, such as "Custom arguments and command". */
+/** Expands one collapsed Advanced section, such as "Custom arguments and command". */
async function expandSection(user: UserEvent, name: string) {
await user.click(screen.getByRole("button", { name, expanded: false }));
}
-/** Moves from the Profile step to the basic Context step. */
-async function openContext(user: UserEvent) {
+/** Moves from the Profile step to the basic Settings step. */
+async function openSettings(user: UserEvent) {
await screen.findByText("Step 1 of 3 — Profile");
await user.click(screen.getByRole("button", { name: "Next" }));
- expect(screen.getByText("Step 2 of 3 — Context")).toBeVisible();
+ expect(screen.getByText("Step 2 of 3 — Settings")).toBeVisible();
}
-async function openAdvancedRuntime(user: UserEvent) {
+async function openAdvancedStep(user: UserEvent) {
await screen.findByText("Step 1 of 3 — Profile");
await enableAdvanced(user);
await user.click(screen.getByRole("button", { name: "Next" }));
expect(screen.getByText("Step 2 of 5 — Target")).toBeVisible();
await user.click(screen.getByRole("button", { name: "Next" }));
- expect(screen.getByText("Step 3 of 5 — Context")).toBeVisible();
+ expect(screen.getByText("Step 3 of 5 — Settings")).toBeVisible();
await user.click(screen.getByRole("button", { name: "Next" }));
- expect(screen.getByText("Step 4 of 5 — Runtime")).toBeVisible();
+ expect(screen.getByText("Step 4 of 5 — Advanced")).toBeVisible();
}
-async function openAdvancedRuntimeWithFakeTimers() {
+async function openAdvancedStepWithFakeTimers() {
await act(async () => {
await Promise.resolve();
});
@@ -3176,9 +3245,9 @@ async function openAdvancedRuntimeWithFakeTimers() {
fireEvent.click(screen.getByRole("button", { name: "Next" }));
expect(screen.getByText("Step 2 of 5 — Target")).toBeVisible();
fireEvent.click(screen.getByRole("button", { name: "Next" }));
- expect(screen.getByText("Step 3 of 5 — Context")).toBeVisible();
+ expect(screen.getByText("Step 3 of 5 — Settings")).toBeVisible();
fireEvent.click(screen.getByRole("button", { name: "Next" }));
- expect(screen.getByText("Step 4 of 5 — Runtime")).toBeVisible();
+ expect(screen.getByText("Step 4 of 5 — Advanced")).toBeVisible();
}
async function goToDeploy(user: UserEvent) {
diff --git a/frontend/src/features/models/deploy/model-deploy-dialog.tsx b/frontend/src/features/models/deploy/model-deploy-dialog.tsx
index 493a77ef..8aee23a0 100644
--- a/frontend/src/features/models/deploy/model-deploy-dialog.tsx
+++ b/frontend/src/features/models/deploy/model-deploy-dialog.tsx
@@ -10,30 +10,26 @@ import { DeploymentExposureFields } from "@/features/models/deployment-exposure-
import { configurationSpeculativeOptions } from "@/features/models/profiles/model-row";
import { profileTargetVerdict } from "@/features/models/profiles/profile-compatibility";
import { profileCompatibilityStatus } from "@/features/models/profiles/profile-compatibility-copy";
-import {
- engineParameterError,
- PARALLEL_REQUESTS_PARAMETER_ID,
-} from "@/features/models/runtime/engine-parameter-values";
import {
speculativeChoiceError,
speculativeMethodChoice,
} from "@/features/models/runtime/speculative-choice";
import { CommandPreviewDialog } from "./model-command-preview-dialog";
-import { ModelDeployContextStep } from "./model-deploy-context-step";
+import { ModelDeployAdvancedStep } from "./model-deploy-advanced-step";
import { exactPlacement, moveToken } from "./model-deploy-launch";
import { ModelDeployProfileStep } from "./model-deploy-profile-step";
-import { ModelDeployRuntimeStep } from "./model-deploy-runtime-step";
+import { ModelDeploySettingsStep } from "./model-deploy-settings-step";
import { ModelDeploySummaryStep } from "./model-deploy-summary-step";
import { ModelDeployTargetStep } from "./model-deploy-target-step";
import { type ModelDeployFormOptions, useModelDeployForm } from "./use-model-deploy-form";
/**
* Deploys a catalog model to a local execution target. Basic mode asks for the profile, the
- * context, and the deployment name, plus the target when more than one can deploy the profile; the
- * Advanced checkbox adds every target, the context per request, runtime values, and routing without
- * discarding entered values. The Context step and the profile table's "Sized for" edit one shared
- * context.
+ * settings (the context and the profile's basic parameters), and the deployment name, plus the
+ * target when more than one can deploy the profile; the Advanced checkbox adds every target, the
+ * advanced parameters and options, and routing without discarding entered values. The Settings step
+ * and the profile table's "Sized for" edit one shared context.
*/
export function ModelDeployDialog(props: ModelDeployDialogProps) {
const { error, model, onOpenChange, open, openerRef, pending } = props;
@@ -213,8 +209,8 @@ export function ModelDeployDialog(props: ModelDeployDialogProps) {
}}
/>
) : null}
- {step === "Context" ? (
-
) : null}
- {step === "Runtime" ? (
- {
- if (!dirtyFields.isDirty(parameter.parameterId)) return;
- setTimeout(
- () =>
- setFieldError(
- parameter.parameterId,
- engineParameterError(
- parameter,
- form.engineParameterInputsRef.current,
- form.derivedParameterIds,
- ),
- ),
- 0,
- );
- }}
- onParameterChange={(parameter, nextInput) => {
- dirtyFields.markDirty(parameter.parameterId);
- const nextInputs = new Map(form.engineParameterInputs).set(
- parameter.parameterId,
- nextInput,
- );
- // Parallel requests belong to the shared context, which "Sized for" also edits and a
- // profile change keeps.
- if (parameter.parameterId === PARALLEL_REQUESTS_PARAMETER_ID)
- form.setContext((previous) => ({ ...previous, parallelRequests: nextInput }));
- else
- form.setEditedInputs((inputs) =>
- new Map(inputs).set(parameter.parameterId, nextInput),
- );
- if (fieldErrors.has(parameter.parameterId))
- setFieldError(
- parameter.parameterId,
- engineParameterError(parameter, nextInputs, form.derivedParameterIds),
- );
- commandPreview.invalidatePreview();
- }}
+ onParameterBlur={form.blurParameter}
+ onParameterChange={form.changeParameter}
onPreview={() => void form.runPreview()}
previewButtonRef={commandPreview.previewButtonRef}
previewDisabled={
@@ -315,6 +282,7 @@ export function ModelDeployDialog(props: ModelDeployDialogProps) {
previewPending={commandPreview.previewPending}
profile={form.profile}
revealedErrors={form.revealedErrors}
+ schemaExposesParameters={form.exposedParameters.length > 0}
kvCachePrecision={{
onChange: (precision) => {
form.setPickedKvCachePrecision(precision);
@@ -369,6 +337,7 @@ export function ModelDeployDialog(props: ModelDeployDialogProps) {
) : null}
{step === "Deploy" ? (
(step === "Target" && target) ||
- (step === "Runtime" && advanced) ||
+ (step === "Advanced" && advanced) ||
step === "Profile" ||
- step === "Context" ||
+ step === "Settings" ||
step === "Deploy",
);
}
-// A vanished step falls forward: Target to Context, which always shows, and Runtime to Deploy.
+// A vanished step falls forward: Target to Settings, which always shows, and Advanced to Deploy.
export function fallbackStep(missingStep: string): string {
- return missingStep === "Target" ? "Context" : "Deploy";
+ return missingStep === "Target" ? "Settings" : "Deploy";
}
export function exactPlacement(target: ConfiguredExecutionTarget): ExactPlacement {
diff --git a/frontend/src/features/models/deploy/model-deploy-scenario.test.tsx b/frontend/src/features/models/deploy/model-deploy-scenario.test.tsx
index acdabdcc..7a36fd36 100644
--- a/frontend/src/features/models/deploy/model-deploy-scenario.test.tsx
+++ b/frontend/src/features/models/deploy/model-deploy-scenario.test.tsx
@@ -154,7 +154,7 @@ describe("ModelDeployDialog scenario prefill", () => {
expect(screen.queryByText(/Estimate unavailable/)).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Next" }));
- expect(screen.getByText("Step 2 of 3 — Context")).toBeVisible();
+ expect(screen.getByText("Step 2 of 3 — Settings")).toBeVisible();
expect(screen.getByRole("textbox", { name: /Context per request/ })).toHaveFocus();
await user.clear(perRequest);
@@ -182,7 +182,7 @@ describe("ModelDeployDialog scenario prefill", () => {
await user.click(screen.getByRole("checkbox", { name: "Advanced" }));
await user.click(screen.getByRole("button", { name: "Next" }));
await user.click(screen.getByRole("button", { name: "Next" }));
- expect(screen.getByText("Step 3 of 5 — Context")).toBeVisible();
+ expect(screen.getByText("Step 3 of 5 — Settings")).toBeVisible();
expect(screen.getByRole("textbox", { name: /Context per request/ })).toHaveValue("");
while (!screen.queryByRole("button", { name: "Deploy" }))
await user.click(screen.getByRole("button", { name: "Next" }));
@@ -801,7 +801,7 @@ describe("ModelDeployDialog largest fitting total context", () => {
await user.click(screen.getByRole("checkbox", { name: "Advanced" }));
await pickTarget(user, /^node \/ NVIDIA GeForce RTX 5090/);
await user.click(screen.getByRole("button", { name: "Next" }));
- expect(screen.getByText("Step 3 of 5 — Context")).toBeVisible();
+ expect(screen.getByText("Step 3 of 5 — Settings")).toBeVisible();
const hint = "Up to 124k on node / NVIDIA GeForce RTX 5090";
expect(await readFieldHelpFacts("Total context")).toContain(hint);
@@ -949,7 +949,7 @@ describe("ModelDeployDialog shared context", () => {
expect(screen.getByRole("textbox", { name: "Total context" })).toHaveValue("32768");
});
- it("keeps the default total context when parallel requests change on the Runtime step", async () => {
+ it("keeps the default total context when parallel requests change on the Advanced step", async () => {
const user = setupUser();
const onSubmit = vi.fn["onSubmit"]>();
renderDialog({
@@ -967,7 +967,7 @@ describe("ModelDeployDialog shared context", () => {
await user.click(screen.getByRole("checkbox", { name: "Advanced" }));
await user.click(screen.getByRole("button", { name: "Next" }));
await user.click(screen.getByRole("button", { name: "Next" }));
- expect(screen.getByText("Step 3 of 5 — Context")).toBeVisible();
+ expect(screen.getByText("Step 3 of 5 — Settings")).toBeVisible();
await waitFor(() =>
expect(screen.getByRole("textbox", { name: "Total context" })).toHaveValue("910336"),
);
@@ -1178,7 +1178,7 @@ describe("ModelDeployDialog shared context", () => {
expect(screen.getByRole("textbox", { name: "Parallel requests" })).toHaveValue("0");
await user.click(screen.getByRole("button", { name: "Next" }));
expect(await screen.findByText(parallelError)).toBeVisible();
- expect(screen.getByText("Step 4 of 5 — Runtime")).toBeVisible();
+ expect(screen.getByText("Step 4 of 5 — Advanced")).toBeVisible();
});
it('keeps the operator\'s parallel requests and their error when "Sized for" returns to defaults', async () => {
@@ -1221,10 +1221,10 @@ describe("ModelDeployDialog shared context", () => {
expect(screen.getByRole("textbox", { name: "Parallel requests" })).toHaveValue("0");
await user.click(screen.getByRole("button", { name: "Next" }));
expect(await screen.findByText(parallelError)).toBeVisible();
- expect(screen.getByText("Step 4 of 5 — Runtime")).toBeVisible();
+ expect(screen.getByText("Step 4 of 5 — Advanced")).toBeVisible();
});
- it('shares the context between the Context step and "Sized for", with the fit verdicts following', async () => {
+ it('shares the context between the Settings step and "Sized for", with the fit verdicts following', async () => {
const user = setupUser();
const listProfileCompatibility = vi.fn((_id: string, requested?: SizingScenario) =>
Promise.resolve(
@@ -1258,12 +1258,12 @@ describe("ModelDeployDialog shared context", () => {
await waitFor(() => expect(profileRows()).toEqual(["Q4_K_XL"]));
expect(totalContextCell("Q4_K_XL")).toBe("64k");
- // "Sized for" back to defaults returns the Context step to its default.
+ // "Sized for" back to defaults returns the Settings step to its default.
await chooseSizedFor(user, "Runs on filter: this site, 64k", "Defaults");
await user.click(screen.getByRole("button", { name: "Next" }));
expect(screen.getByRole("textbox", { name: "Total context" })).toHaveValue("910336");
- // A custom "Sized for" shows on the Context step.
+ // A custom "Sized for" shows on the Settings step.
await user.click(screen.getByRole("button", { name: "Back" }));
await chooseSizedFor(user, "Runs on filter: this site", "Custom");
await user.click(screen.getByRole("button", { name: "Next" }));
@@ -1294,7 +1294,7 @@ describe("ModelDeployDialog shared context", () => {
await screen.findByText("Step 1 of 4 — Profile");
await pickTarget(user, /^node/);
await user.click(screen.getByRole("button", { name: "Next" }));
- expect(screen.getByText("Step 3 of 4 — Context")).toBeVisible();
+ expect(screen.getByText("Step 3 of 4 — Settings")).toBeVisible();
expect(await readFieldHelpFacts("Total context")).toContain(
"Up to 889k on node / NVIDIA GeForce RTX 5090",
);
@@ -1326,7 +1326,7 @@ describe("ModelDeployDialog shared context", () => {
).toEqual(["total-context", 65536n]);
});
- it("opens a catalog 128k scenario on the Context step and submits it", async () => {
+ it("opens a catalog 128k scenario on the Settings step and submits it", async () => {
const user = setupUser();
const onSubmit = vi.fn["onSubmit"]>();
const catalogScenario: SizingScenario = { totalContextTokens: 131072 };
diff --git a/frontend/src/features/models/deploy/model-deploy-context-step.tsx b/frontend/src/features/models/deploy/model-deploy-settings-step.tsx
similarity index 82%
rename from frontend/src/features/models/deploy/model-deploy-context-step.tsx
rename to frontend/src/features/models/deploy/model-deploy-settings-step.tsx
index 0205c1f2..ae1ea0ba 100644
--- a/frontend/src/features/models/deploy/model-deploy-context-step.tsx
+++ b/frontend/src/features/models/deploy/model-deploy-settings-step.tsx
@@ -14,12 +14,13 @@ import {
maximumContext,
runtimeParameterGroups,
} from "@/features/models/runtime/engine-parameter-values";
+import { EngineParametersForm } from "@/features/models/runtime/engine-parameters";
import type { Model, ModelProfile } from "@/gen/dotcore/catalog/v1/model_pb";
import type { ParameterDefinition } from "@/gen/dotcore/catalog/v1/parameter_pb";
import type { FieldErrors } from "@/lib/form-validation";
import { formatCompactCount } from "@/lib/format";
-interface ModelDeployContextStepProps {
+interface ModelDeploySettingsStepProps {
/** The largest total context that fits the selected target, shown in the total context's help. */
contextHint?: string;
disabled: boolean;
@@ -35,15 +36,21 @@ interface ModelDeployContextStepProps {
onParameterBlur: (parameter: ParameterDefinition) => void;
/** Receives each change, and whether it is settled, as a slider or preset is, or typed. */
onParameterChange: (parameter: ParameterDefinition, input: string, settled?: boolean) => void;
+ /** The basic engine parameters outside the context, which follow the context fields. */
+ parameters: {
+ onBlur: (parameter: ParameterDefinition) => void;
+ onChange: (parameter: ParameterDefinition, input: string) => void;
+ shown: readonly ParameterDefinition[];
+ };
profile?: ModelProfile;
}
/**
- * The Context step of the model deploy dialog: the total context the deployment holds and the
- * context one request may use, which follows the total until set. Each field shows only when the
- * selected profile exposes its engine parameter.
+ * The Settings step of the model deploy dialog: the total context the deployment holds and the
+ * context one request may use, which follows the total until set, then the profile's other basic
+ * engine parameters. Each field shows only when the selected profile exposes its engine parameter.
*/
-export function ModelDeployContextStep({
+export function ModelDeploySettingsStep({
contextHint,
disabled,
engineParameterRefs,
@@ -55,8 +62,9 @@ export function ModelDeployContextStep({
model,
onParameterBlur,
onParameterChange,
+ parameters,
profile,
-}: ModelDeployContextStepProps) {
+}: ModelDeploySettingsStepProps) {
const configuration = model.configurations.find(
(candidate) => candidate.configurationId === profile?.configurationId,
);
@@ -92,7 +100,7 @@ export function ModelDeployContextStep({
return (
- Context
+ Settings
{totalContext || contextPerRequest ? null : (
This profile sets its context itself.
@@ -130,6 +138,16 @@ export function ModelDeployContextStep({
help: contextPerRequest.description,
})
: null}
+ {parameters.shown.length ? (
+
+ ) : null}
);
}
diff --git a/frontend/src/features/models/deploy/model-deploy-runtime.test.tsx b/frontend/src/features/models/deploy/model-deploy-steps.test.tsx
similarity index 93%
rename from frontend/src/features/models/deploy/model-deploy-runtime.test.tsx
rename to frontend/src/features/models/deploy/model-deploy-steps.test.tsx
index b9279f8a..be1c4d87 100644
--- a/frontend/src/features/models/deploy/model-deploy-runtime.test.tsx
+++ b/frontend/src/features/models/deploy/model-deploy-steps.test.tsx
@@ -32,14 +32,14 @@ import { setupUser } from "@/test/user";
import { ModelDeployDialog } from "./model-deploy-dialog";
import { sharedContextEstimate } from "./resource-estimate-test-support";
-describe("ModelDeployDialog Context step", () => {
+describe("ModelDeployDialog Settings step", () => {
it("shows total context and context per request in both modes, with short help", async () => {
const user = setupUser();
renderDialog({ initialProfileId: "q4-xl" });
await screen.findByText("Step 1 of 3 — Profile");
await user.click(screen.getByRole("button", { name: "Next" }));
- expect(screen.getByText("Step 2 of 3 — Context")).toBeVisible();
+ expect(screen.getByText("Step 2 of 3 — Settings")).toBeVisible();
expect(
screen.getAllByRole("textbox").map((input) => [input.id, input.getAttribute("value")]),
).toEqual([
@@ -70,7 +70,7 @@ describe("ModelDeployDialog Context step", () => {
await user.click(preset("64k"));
await user.click(screen.getByRole("checkbox", { name: "Advanced" }));
- expect(screen.getByText("Step 3 of 5 — Context")).toBeVisible();
+ expect(screen.getByText("Step 3 of 5 — Settings")).toBeVisible();
expect(
screen.getAllByRole("textbox").map((input) => [input.id, input.getAttribute("value")]),
).toEqual([
@@ -83,7 +83,7 @@ describe("ModelDeployDialog Context step", () => {
const user = setupUser();
renderDialog({ initialProfileId: "q4-xl" });
- await openAdvancedContext(user);
+ await openAdvancedSettings(user);
const slider = screen.getByRole("slider", { name: "Total context" });
const input = screen.getByRole("textbox", { name: "Total context" });
expect(input).toHaveValue("32768");
@@ -119,7 +119,7 @@ describe("ModelDeployDialog Context step", () => {
const onSubmit = vi.fn();
renderDialog({ initialProfileId: "nvfp4-text", onSubmit });
- await openAdvancedContext(user);
+ await openAdvancedSettings(user);
expect(presetLabels()).toEqual(["8k", "32k", "64k", "128k", "256k", "1M"]);
await user.click(preset("64k"));
await user.click(screen.getByRole("button", { name: "Next" }));
@@ -196,7 +196,7 @@ describe("ModelDeployDialog Context step", () => {
const onSubmit = vi.fn();
renderDialog({ initialProfileId: profileId, onSubmit });
- await openAdvancedContext(user);
+ await openAdvancedSettings(user);
expect(
screen.getAllByRole("textbox").map((input) => [input.id, input.getAttribute("value")]),
).toEqual([
@@ -211,7 +211,7 @@ describe("ModelDeployDialog Context step", () => {
"32,768 tokens",
);
- await deployFromContext(user);
+ await deployFromSettings(user);
expect(screen.queryByText(/Customized/)).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Deploy" }));
@@ -225,7 +225,7 @@ describe("ModelDeployDialog Context step", () => {
const onSubmit = vi.fn();
renderDialog({ initialProfileId: profileId, onSubmit });
- await openAdvancedContext(user);
+ await openAdvancedSettings(user);
const total = screen.getByRole("textbox", { name: "Total context" });
const perRequest = screen.getByRole("textbox", { name: "Context per request" });
@@ -244,7 +244,7 @@ describe("ModelDeployDialog Context step", () => {
expect(perRequest).toHaveValue("8192");
expect(screen.queryByText(/must be at most the total context/)).not.toBeInTheDocument();
- await deployFromContext(user);
+ await deployFromSettings(user);
await user.click(screen.getByRole("button", { name: "Deploy" }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce());
expect(submittedValues(onSubmit)).toEqual([
@@ -260,7 +260,7 @@ describe("ModelDeployDialog Context step", () => {
const user = setupUser();
renderDialog({ initialProfileId: "ninfer-nvfp4" });
- await openAdvancedContext(user);
+ await openAdvancedSettings(user);
const totalSlider = screen.getByRole("slider", { name: "Total context" });
const perRequestSlider = screen.getByRole("slider", { name: "Context per request" });
// 32768 tokens sits five elevenths along the log scale from 1024 to 2097152 tokens on both.
@@ -284,7 +284,7 @@ describe("ModelDeployDialog Context step", () => {
model.configurations[0].maximumContextTokens = 16384;
renderDialog({ initialProfileId: "q4-xl", model });
- await openAdvancedContext(user);
+ await openAdvancedSettings(user);
const perRequest = screen.getByRole("textbox", { name: "Context per request" });
expect(perRequest).toHaveAttribute("placeholder", "16384");
await user.click(screen.getByRole("button", { name: "About Context per request" }));
@@ -297,12 +297,12 @@ describe("ModelDeployDialog Context step", () => {
});
});
-describe("ModelDeployDialog Runtime step", () => {
+describe("ModelDeployDialog Advanced step", () => {
it("leads with parallel requests and groups the ninfer host context as host memory", async () => {
const user = setupUser();
renderDialog({ initialProfileId: "ninfer-nvfp4" });
- await openAdvancedRuntime(user);
+ await openAdvancedStep(user);
const parallel = screen.getAllByRole("textbox")[0];
expect(parallel).toHaveAccessibleName("Parallel requests");
expect(parallel).toHaveValue("4");
@@ -317,7 +317,7 @@ describe("ModelDeployDialog Runtime step", () => {
);
// No parameter is left for the other parameters section, and the context sizes sit on the
- // Context step.
+ // Settings step.
expect(screen.queryByRole("button", { name: /^Other parameters/ })).toBeNull();
for (const name of ["Total context", "Context per request"])
expect(screen.queryByRole("textbox", { name })).not.toBeInTheDocument();
@@ -327,7 +327,7 @@ describe("ModelDeployDialog Runtime step", () => {
const user = setupUser();
renderDialog({ initialProfileId: "q4-xl" });
- await openAdvancedRuntime(user);
+ await openAdvancedStep(user);
// llama.cpp declares no host-memory parameter; the prompt cache is its other parameter.
expect(screen.queryByRole("region", { name: "Host memory" })).not.toBeInTheDocument();
expect(sectionToggle("Other parameters (1)")).toHaveAttribute("aria-expanded", "false");
@@ -349,7 +349,7 @@ describe("ModelDeployDialog Runtime step", () => {
renderDialog({ initialProfileId: "q4-xl", onSubmit });
const error = "Prompt cache memory must be an integer from 0 to 65536 MiB.";
- await openAdvancedRuntime(user);
+ await openAdvancedStep(user);
await user.click(sectionToggle("Other parameters (1)"));
const promptCache = screen.getByRole("textbox", { name: "Prompt cache memory" });
await user.clear(promptCache);
@@ -361,9 +361,9 @@ describe("ModelDeployDialog Runtime step", () => {
).not.toBeInTheDocument(),
);
- // Leaving Runtime re-opens the section and focuses the field.
+ // Leaving Advanced re-opens the section and focuses the field.
await user.click(screen.getByRole("button", { name: "Next" }));
- expect(screen.getByText("Step 4 of 5 — Runtime")).toBeVisible();
+ expect(screen.getByText("Step 4 of 5 — Advanced")).toBeVisible();
expect(sectionToggle("Other parameters (1)")).toHaveAttribute("aria-expanded", "true");
await waitFor(() =>
expect(screen.getByRole("textbox", { name: "Prompt cache memory" })).toHaveFocus(),
@@ -380,7 +380,7 @@ describe("ModelDeployDialog Runtime step", () => {
);
await user.click(screen.getByRole("button", { name: "Deploy" }));
- expect(await screen.findByText("Step 4 of 5 — Runtime")).toBeVisible();
+ expect(await screen.findByText("Step 4 of 5 — Advanced")).toBeVisible();
expect(screen.getByRole("checkbox", { name: "Advanced" })).toBeChecked();
expect(sectionToggle("Other parameters (1)")).toHaveAttribute("aria-expanded", "true");
const revealed = screen.getByRole("textbox", { name: "Prompt cache memory" });
@@ -395,7 +395,7 @@ describe("ModelDeployDialog Runtime step", () => {
const user = setupUser();
renderDialog({ initialProfileId: "q4-xl" });
- await openAdvancedRuntime(user);
+ await openAdvancedStep(user);
expect(screen.queryByRole("combobox", { name: "KV cache precision" })).toBeNull();
});
@@ -404,11 +404,11 @@ describe("ModelDeployDialog Runtime step", () => {
const onSubmit = vi.fn();
renderDialog({ initialProfileId: "q4-xl", model: modelWithKvCachePrecision(), onSubmit });
- await openAdvancedRuntime(user);
+ await openAdvancedStep(user);
expect(kvCachePrecision()).toHaveValue("f16");
expect(optionLabels(kvCachePrecision())).toEqual(["f16", "q8_0"]);
- await deployFromRuntime(user);
+ await deployFromAdvanced(user);
expect(screen.queryByText(/Customized/)).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Deploy" }));
@@ -425,11 +425,11 @@ describe("ModelDeployDialog Runtime step", () => {
onSubmit,
});
- await openAdvancedRuntime(user);
+ await openAdvancedStep(user);
expect(kvCachePrecision()).toHaveValue("q8_0");
await user.selectOptions(kvCachePrecision(), "f16");
- await deployFromRuntime(user);
+ await deployFromAdvanced(user);
expect(screen.getByText("Customized: KV cache precision f16")).toBeVisible();
await user.click(screen.getByRole("button", { name: "Deploy" }));
@@ -446,7 +446,7 @@ describe("ModelDeployDialog Runtime step", () => {
onEstimate,
});
- await openAdvancedRuntime(user);
+ await openAdvancedStep(user);
await waitFor(() => expect(onEstimate).toHaveBeenCalledOnce());
expect(estimatedOverrides(onEstimate, 0)).toEqual([]);
@@ -467,7 +467,7 @@ describe("ModelDeployDialog Runtime step", () => {
const onPreview = vi.fn(() => new Promise(() => undefined));
renderDialog({ initialProfileId: "q4-xl", model: modelWithKvCachePrecision(), onPreview });
- await openAdvancedRuntime(user);
+ await openAdvancedStep(user);
await user.selectOptions(kvCachePrecision(), "q8_0");
await user.click(screen.getByRole("button", { name: "Custom arguments and command" }));
await user.click(screen.getByRole("button", { name: "Preview resolved command" }));
@@ -485,7 +485,7 @@ describe("ModelDeployDialog Runtime step", () => {
const onSubmit = vi.fn();
renderDialog({ initialProfileId: "q4-xl", onSubmit });
- await openAdvancedRuntime(user);
+ await openAdvancedStep(user);
expect(speculativeMethod()).toHaveValue("mtp");
expect(optionLabels(speculativeMethod())).toEqual(["Off", "MTP"]);
expect(speculativeCount()).toHaveValue("3");
@@ -496,7 +496,7 @@ describe("ModelDeployDialog Runtime step", () => {
).toHaveTextContent("Range 1–5 tokens");
await user.keyboard("{Escape}");
- await deployFromRuntime(user);
+ await deployFromAdvanced(user);
expect(screen.queryByText(/Customized/)).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Deploy" }));
@@ -525,9 +525,9 @@ describe("ModelDeployDialog Runtime step", () => {
const onSubmit = vi.fn();
renderDialog({ initialProfileId: "q4-xl", onSubmit });
- await openAdvancedRuntime(user);
+ await openAdvancedStep(user);
await choose(user);
- await deployFromRuntime(user);
+ await deployFromAdvanced(user);
expect(screen.getByText(customized)).toBeVisible();
await user.click(screen.getByRole("button", { name: "Deploy" }));
@@ -540,7 +540,7 @@ describe("ModelDeployDialog Runtime step", () => {
const onEstimate = vi.fn(() => Promise.resolve({}));
renderDialog({ initialProfileId: "q4-xl", onEstimate });
- await openAdvancedRuntime(user);
+ await openAdvancedStep(user);
await waitFor(() => expect(onEstimate).toHaveBeenCalledOnce());
expect(estimatedOverrides(onEstimate, 0)).toEqual([]);
@@ -562,7 +562,7 @@ describe("ModelDeployDialog Runtime step", () => {
const onEstimate = vi.fn(() => Promise.resolve({}));
renderDialog({ initialProfileId: "q4-xl", onEstimate });
- await openAdvancedRuntime(user);
+ await openAdvancedStep(user);
await waitFor(() => expect(onEstimate).toHaveBeenCalled());
const [values] = onEstimate.mock.lastCall as unknown as [
{ placement?: { executionTargetId: string; siteNodeId: string } },
@@ -575,14 +575,14 @@ describe("ModelDeployDialog Runtime step", () => {
const onSubmit = vi.fn();
renderDialog({ initialProfileId: "q4-xl-no-mtp", onSubmit });
- await openAdvancedRuntime(user);
+ await openAdvancedStep(user);
expect(speculativeMethod()).toHaveValue("");
expect(screen.queryByRole("textbox", { name: "Speculative token count" })).toBeNull();
await user.selectOptions(speculativeMethod(), "MTP");
expect(speculativeCount()).toHaveValue("3");
expect(speculativeCount()).not.toHaveAttribute("aria-invalid");
- await deployFromRuntime(user);
+ await deployFromAdvanced(user);
expect(screen.getByText("Customized: Speculative decoding MTP, 3 tokens")).toBeVisible();
await user.click(screen.getByRole("button", { name: "Deploy" }));
@@ -595,14 +595,14 @@ describe("ModelDeployDialog Runtime step", () => {
const onSubmit = vi.fn();
renderDialog({ initialProfileId: "q4-xl", model: modelWithProfileMtpCount(), onSubmit });
- await openAdvancedRuntime(user);
+ await openAdvancedStep(user);
expect(speculativeCount()).toHaveValue("4");
await user.clear(speculativeCount());
await user.selectOptions(speculativeMethod(), "Off");
await user.selectOptions(speculativeMethod(), "MTP");
expect(speculativeCount()).toHaveValue("4");
- await deployFromRuntime(user);
+ await deployFromAdvanced(user);
expect(screen.queryByText(/Customized/)).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Deploy" }));
@@ -615,7 +615,7 @@ describe("ModelDeployDialog Runtime step", () => {
renderDialog({ initialProfileId: "q4-xl", model: modelWithDflash() });
const mtpError = "Speculative token count must be an integer from 1 to 5 tokens.";
- await openAdvancedRuntime(user);
+ await openAdvancedStep(user);
expect(optionLabels(speculativeMethod())).toEqual(["Off", "MTP", "DFlash"]);
await user.selectOptions(speculativeMethod(), "DFlash");
expect(speculativeCount()).toHaveValue("3");
@@ -640,13 +640,13 @@ describe("ModelDeployDialog Runtime step", () => {
onSubmit,
});
- await openAdvancedRuntime(user);
+ await openAdvancedStep(user);
await user.selectOptions(speculativeMethod(), "DFlash");
rerender(dialog({ initialProfileId: "q4-xl", onSubmit }));
expect(speculativeMethod()).toHaveValue("mtp");
expect(speculativeCount()).toHaveValue("3");
- await deployFromRuntime(user);
+ await deployFromAdvanced(user);
expect(screen.queryByText(/Customized/)).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Deploy" }));
@@ -660,7 +660,7 @@ describe("ModelDeployDialog Runtime step", () => {
renderDialog({ initialProfileId: "q4-xl", onSubmit });
const error = "Speculative token count must be an integer from 1 to 5 tokens.";
- await openAdvancedRuntime(user);
+ await openAdvancedStep(user);
// Passing through the untouched field flags nothing.
await user.click(speculativeCount());
await user.tab();
@@ -674,7 +674,7 @@ describe("ModelDeployDialog Runtime step", () => {
expect(speculativeCount()).toHaveAttribute("aria-invalid", "true");
await user.click(screen.getByRole("button", { name: "Next" }));
- expect(screen.getByText("Step 4 of 5 — Runtime")).toBeVisible();
+ expect(screen.getByText("Step 4 of 5 — Advanced")).toBeVisible();
expect(speculativeCount()).toHaveFocus();
await user.clear(speculativeCount());
@@ -687,7 +687,7 @@ describe("ModelDeployDialog Runtime step", () => {
await user.click(screen.getByRole("button", { name: "Next" }));
await user.click(screen.getByRole("button", { name: "Next" }));
await user.click(screen.getByRole("button", { name: "Next" }));
- expect(screen.getByText("Step 4 of 5 — Runtime")).toBeVisible();
+ expect(screen.getByText("Step 4 of 5 — Advanced")).toBeVisible();
expect(screen.queryByRole("combobox", { name: "Speculative decoding" })).toBeNull();
expect(onSubmit).not.toHaveBeenCalled();
});
@@ -778,19 +778,19 @@ function poolTarget() {
});
}
-async function openAdvancedContext(user: UserEvent) {
+async function openAdvancedSettings(user: UserEvent) {
await screen.findByText("Step 1 of 3 — Profile");
await user.click(screen.getByRole("checkbox", { name: "Advanced" }));
await user.click(screen.getByRole("button", { name: "Next" }));
expect(screen.getByText("Step 2 of 5 — Target")).toBeVisible();
await user.click(screen.getByRole("button", { name: "Next" }));
- expect(screen.getByText("Step 3 of 5 — Context")).toBeVisible();
+ expect(screen.getByText("Step 3 of 5 — Settings")).toBeVisible();
}
-async function openAdvancedRuntime(user: UserEvent) {
- await openAdvancedContext(user);
+async function openAdvancedStep(user: UserEvent) {
+ await openAdvancedSettings(user);
await user.click(screen.getByRole("button", { name: "Next" }));
- expect(screen.getByText("Step 4 of 5 — Runtime")).toBeVisible();
+ expect(screen.getByText("Step 4 of 5 — Advanced")).toBeVisible();
}
/** The engine parameter values of the first submitted deployment, as `[parameterId, value]`. */
@@ -820,12 +820,12 @@ function sectionToggle(name: string) {
return screen.getByRole("button", { name });
}
-async function deployFromContext(user: UserEvent) {
+async function deployFromSettings(user: UserEvent) {
await user.click(screen.getByRole("button", { name: "Next" }));
- await deployFromRuntime(user);
+ await deployFromAdvanced(user);
}
-async function deployFromRuntime(user: UserEvent) {
+async function deployFromAdvanced(user: UserEvent) {
await user.click(screen.getByRole("button", { name: "Next" }));
expect(screen.getByText("Step 5 of 5 — Deploy")).toBeVisible();
}
diff --git a/frontend/src/features/models/deploy/model-deploy-summary-step.tsx b/frontend/src/features/models/deploy/model-deploy-summary-step.tsx
index 1bd53a7d..3b43a2db 100644
--- a/frontend/src/features/models/deploy/model-deploy-summary-step.tsx
+++ b/frontend/src/features/models/deploy/model-deploy-summary-step.tsx
@@ -10,6 +10,7 @@ import {
CONTEXT_PER_REQUEST_PARAMETER_ID,
TOTAL_CONTEXT_PARAMETER_ID,
} from "@/features/models/runtime/engine-parameter-values";
+import type { ChangedEngineParameter } from "@/features/models/runtime/local-deployment-configuration";
import type { ConfiguredExecutionTarget } from "@/gen/dotcore/site/v1/service_pb";
import { formatBytes, formatCompactCount } from "@/lib/format";
@@ -17,9 +18,11 @@ import type { LocalDeploymentMemoryPreview } from "./use-model-deploy-memory-pre
/**
* The deploy dialog's Deploy step: the deployment name and route, notices about the site and the
- * launch's memory, and a summary of the target, profile, context and runtime customizations.
+ * launch's memory, and a summary of the target, profile, context, each changed parameter and the
+ * other customized advanced settings.
*/
export function ModelDeploySummaryStep({
+ changedParameters,
customizations,
disabled,
exposureFields,
@@ -66,15 +69,24 @@ export function ModelDeploySummaryStep({
+ {changedParameters.map(({ parameter, value }) => (
+
+ ))}
{customizations.length ? (
-
+
) : null}
);
}
export interface ModelDeploySummaryStepProps {
- /** Runtime settings that differ from the defaults, in a few words each. */
+ /** The engine parameters outside the context whose values differ from the defaults. */
+ changedParameters: readonly ChangedEngineParameter[];
+ /** The other advanced settings that differ from the defaults, in a few words each. */
customizations: readonly string[];
disabled: boolean;
/** The route fields shown under the name. */
@@ -118,6 +130,10 @@ function contextSummary(
.join(" · ");
}
+function capitalized(text: string): string {
+ return `${text.charAt(0).toUpperCase()}${text.slice(1)}`;
+}
+
/** The warning for system memory below the profile's minimum, with sizes when known. */
function poolBelowDeviceMinimumMessage({
memoryPoolTotalBytes,
diff --git a/frontend/src/features/models/deploy/use-model-deploy-form.ts b/frontend/src/features/models/deploy/use-model-deploy-form.ts
index 36271e99..09e97153 100644
--- a/frontend/src/features/models/deploy/use-model-deploy-form.ts
+++ b/frontend/src/features/models/deploy/use-model-deploy-form.ts
@@ -51,8 +51,10 @@ import {
CONTEXT_PARAMETER_IDS,
CONTEXT_PER_REQUEST_PARAMETER_ID,
effectiveEngineParameters,
+ effectiveVisibility,
engineParameterError,
engineParameterValues,
+ PARALLEL_REQUESTS_PARAMETER_ID,
TOTAL_CONTEXT_PARAMETER_ID,
} from "@/features/models/runtime/engine-parameter-values";
import {
@@ -62,7 +64,8 @@ import {
kvCachePrecisionOverrides,
} from "@/features/models/runtime/kv-cache-precision";
import {
- parallelRequestsCustomization,
+ artifactCustomization,
+ changedEngineParameters,
profileEngineRelease,
runtimeCustomizations,
} from "@/features/models/runtime/local-deployment-configuration";
@@ -77,7 +80,11 @@ import type { ApplicationDeployment } from "@/gen/dotcore/application/v1/deploym
import { type EngineRelease } from "@/gen/dotcore/catalog/v1/engine_release_pb";
import type { HardwareClass } from "@/gen/dotcore/catalog/v1/hardware_class_pb";
import type { Model } from "@/gen/dotcore/catalog/v1/model_pb";
-import type { ParameterDefinition, ParameterValues } from "@/gen/dotcore/catalog/v1/parameter_pb";
+import {
+ type ParameterDefinition,
+ type ParameterValues,
+ ParameterVisibility,
+} from "@/gen/dotcore/catalog/v1/parameter_pb";
import type { ExactPlacement } from "@/gen/dotcore/deployment/v1/placement_pb";
import {
type ModelDeployment,
@@ -119,7 +126,7 @@ import type { ResourceEstimateLoader } from "./use-resource-estimate";
/**
* The deploy dialog's form: the launch the operator builds across the steps, its validation, the
* command preview and the submit. Profile, target and memory facts come from their own hooks; the
- * launch's context is shared by the Context step and the profile table's "Sized for".
+ * launch's context is shared by the Settings step and the profile table's "Sized for".
*/
export function useModelDeployForm({
applications,
@@ -234,7 +241,7 @@ export function useModelDeployForm({
const [name, setName] = useState(model.displayName);
const [fieldErrors, setFieldErrors] = useState(new Map());
const pendingFocusRef = useRef(undefined);
- // The errors of the latest blocked step change or Deploy, which open collapsed Runtime sections.
+ // The errors of the latest blocked step change or Deploy, which open collapsed Advanced sections.
const [revealedErrors, setRevealedErrors] = useState(new Map());
const artifactRef = useRef(null);
const engineParameterRefs = useRef>(new Map());
@@ -373,7 +380,7 @@ export function useModelDeployForm({
sizingRead: profileCompatibility.sizing,
});
- // The selected profile's row shows the value the Context step starts from, frozen with it; the
+ // The selected profile's row shows the value the Settings step starts from, frozen with it; the
// other rows stay live, so their changes still show.
const shownProfileRows =
scenario || fitStart === undefined
@@ -529,6 +536,17 @@ export function useModelDeployForm({
const contextParameters = exposedParameters.filter((parameter) =>
CONTEXT_PARAMETER_IDS.has(parameter.parameterId),
);
+ // The other parameters split by the profile's visibility: basic ones follow the context on the
+ // Settings step, advanced ones sit on the Advanced step.
+ const settingsParameters = exposedParameters.filter(
+ (parameter) =>
+ !CONTEXT_PARAMETER_IDS.has(parameter.parameterId) &&
+ effectiveVisibility(parameter, profile) === ParameterVisibility.BASIC,
+ );
+ const advancedParameters = exposedParameters.filter(
+ (parameter) =>
+ !CONTEXT_PARAMETER_IDS.has(parameter.parameterId) && !settingsParameters.includes(parameter),
+ );
// A context change edits the shared context: total context and context per request become the
// chosen ones, starting from what the step shows. A settled change, from a slider or preset, also
// moves the other of the two to keep the edited one, and sizes "Sized for" when valid; typing
@@ -593,6 +611,33 @@ export function useModelDeployForm({
}),
);
};
+ // A change to a parameter outside the context, on the Settings or the Advanced step.
+ const changeParameter = (parameter: ParameterDefinition, nextInput: string) => {
+ dirtyFields.markDirty(parameter.parameterId);
+ const nextInputs = new Map(engineParameterInputs).set(parameter.parameterId, nextInput);
+ // Parallel requests belong to the shared context, which "Sized for" also edits and a profile
+ // change keeps.
+ if (parameter.parameterId === PARALLEL_REQUESTS_PARAMETER_ID)
+ setContext((previous) => ({ ...previous, parallelRequests: nextInput }));
+ else setEditedInputs((inputs) => new Map(inputs).set(parameter.parameterId, nextInput));
+ if (fieldErrors.has(parameter.parameterId))
+ setFieldError(
+ parameter.parameterId,
+ engineParameterError(parameter, nextInputs, derivedParameterIds),
+ );
+ invalidatePreview();
+ };
+ const blurParameter = (parameter: ParameterDefinition) => {
+ if (!dirtyFields.isDirty(parameter.parameterId)) return;
+ setTimeout(
+ () =>
+ setFieldError(
+ parameter.parameterId,
+ engineParameterError(parameter, engineParameterInputsRef.current, derivedParameterIds),
+ ),
+ 0,
+ );
+ };
const validateRoute = (values = exposureValues, deploymentName = name): string | undefined =>
validateDeploymentExposure(values, deploymentName, existingRouteNames);
const parameterErrors = (
@@ -618,20 +663,17 @@ export function useModelDeployForm({
]);
case "Profile":
return toFieldErrors([["profileId", profileError]]);
- case "Context":
+ case "Settings":
return toFieldErrors([
...parameterErrors(contextParameters),
+ ...parameterErrors(settingsParameters),
// Held while the next estimate loads: leaving a field restarts it, so Next would pass.
[launchRejectionField, launchRejectionMessage],
]);
- case "Runtime":
+ case "Advanced":
return toFieldErrors([
["artifact", binding ? undefined : "Select a model artifact."],
- ...parameterErrors(
- exposedParameters.filter(
- (parameter) => !CONTEXT_PARAMETER_IDS.has(parameter.parameterId),
- ),
- ),
+ ...parameterErrors(advancedParameters),
["speculativeCount", speculativeChoiceError(speculativeOptions, speculativeChoice)],
]);
case "Deploy": {
@@ -664,7 +706,7 @@ export function useModelDeployForm({
["name", nameRef.current],
["routeName", routeNameRef.current],
]);
- // Focus waits for the render that shows the errors, which also opens a collapsed Runtime section
+ // Focus waits for the render that shows the errors, which also opens a collapsed Advanced section
// holding an invalid field.
const exposeErrors = (errors: FieldErrors): boolean => {
setFieldErrors(errors);
@@ -743,31 +785,30 @@ export function useModelDeployForm({
existingRouteNames,
values: exposureValues,
});
- const customizations = [
- ...runtimeCustomizations(model, profile, engineRelease, {
- bindingId,
- declaredValues,
- inputs: engineParameterInputs,
- }),
+ const launchEdits = { bindingId, declaredValues, inputs: engineParameterInputs };
+ const optionCustomizations = [
...speculativeCustomizations(speculativeOptions, speculativeChoice),
...kvCachePrecisionCustomizations(kvCachePrecisionOption, kvCachePrecision),
];
- // Runtime settings that differ from the defaults, as the Deploy summary lists them, including
- // the launch's own parallel requests and custom arguments that a profile change keeps.
+ // The settings a profile change resets.
+ const customizations = [
+ ...runtimeCustomizations(model, profile, engineRelease, launchEdits),
+ ...optionCustomizations,
+ ];
+ // What the Deploy summary lists apart from the context: each changed engine parameter, then the
+ // other advanced settings that differ from the defaults, including the custom arguments that a
+ // profile change keeps.
+ const changedParameters = changedEngineParameters(profile, engineRelease, launchEdits);
const deployCustomizations = [
- ...customizations,
- ...parallelRequestsCustomization(profile, engineRelease, {
- bindingId,
- declaredValues,
- inputs: engineParameterInputs,
- }),
+ ...artifactCustomization(model, profile, bindingId),
+ ...optionCustomizations,
...(customArgumentTokens.length
? [formatCount(customArgumentTokens.length, "custom argument")]
: []),
];
const runPreview = async () => {
- for (const owner of ["Profile", "Context", "Runtime"] as const) {
+ for (const owner of ["Profile", "Settings", "Advanced"] as const) {
const errors = stepErrors(owner);
if (errors.size) {
revealErrors(owner, errors);
@@ -868,12 +909,16 @@ export function useModelDeployForm({
return {
advanced,
+ advancedParameters,
announcement,
artifactRef,
binding,
bindingId,
blurContext,
+ blurParameter,
+ changedParameters,
changeContext,
+ changeParameter,
chooseProfile,
commandPreview,
compatibility,
@@ -891,8 +936,6 @@ export function useModelDeployForm({
dirtyFields,
effectiveTargetsError,
engineParameterInputs,
- derivedParameterIds,
- engineParameterInputsRef,
engineParameterRefs,
executionTargetRef,
executionTargets,
@@ -927,8 +970,6 @@ export function useModelDeployForm({
selectedExecutionTarget,
selectedProfileRow,
setBindingId,
- setContext,
- setEditedInputs,
setExposureValues,
setFieldError,
setIneligibleTarget,
@@ -936,6 +977,7 @@ export function useModelDeployForm({
setPickedKvCachePrecision,
setPickedPlacement,
setSpeculativeChoice,
+ settingsParameters,
shownProfileRows,
sizing,
speculativeChoice,
diff --git a/frontend/src/features/models/runtime/engine-parameter-values.ts b/frontend/src/features/models/runtime/engine-parameter-values.ts
index 6e13d27c..98acff9b 100644
--- a/frontend/src/features/models/runtime/engine-parameter-values.ts
+++ b/frontend/src/features/models/runtime/engine-parameter-values.ts
@@ -23,7 +23,7 @@ export const CONTEXT_PER_REQUEST_PARAMETER_ID = "context-per-request";
/** The shared engine parameter setting how many requests an engine generates at once. */
export const PARALLEL_REQUESTS_PARAMETER_ID = "parallel-requests";
-/** The context size parameters, which a launch sets on its own step apart from the runtime. */
+/** The context size parameters, which lead the deploy dialog's Settings step. */
export const CONTEXT_PARAMETER_IDS: ReadonlySet = new Set([
TOTAL_CONTEXT_PARAMETER_ID,
CONTEXT_PER_REQUEST_PARAMETER_ID,
@@ -31,7 +31,7 @@ export const CONTEXT_PARAMETER_IDS: ReadonlySet = new Set([
/**
* A profile's exposed engine parameters, grouped as the deploy dialog presents them: the context
- * sizes form the context group of the Context step. On the Runtime step, parallel requests come
+ * sizes form the context group of the Settings step. On the Advanced step, parallel requests come
* first, host-memory parameters form their own group, and every other parameter stays in the
* generic form.
*/
@@ -49,7 +49,7 @@ export interface ContextParameterGroup {
}
/**
- * Groups exposed engine parameters for the Context and Runtime steps. Host-memory parameters are
+ * Groups exposed engine parameters for the Settings and Advanced steps. Host-memory parameters are
* the ones the model configuration's engine settings declare as host budgets (vLLM KV offload,
* ninfer host context), so an engine without such a declaration keeps every parameter in the
* generic group.
@@ -101,16 +101,20 @@ export function effectiveEngineParameters(
release: EngineRelease | undefined,
profile: ModelProfile | undefined,
): ParameterDefinition[] {
- const overrides = new Map(
- (profile?.engineParameterVisibilityOverrides ?? []).map((override) => [
- override.parameterId,
- override.visibility,
- ]),
+ return (release?.parameterSchema?.parameters ?? []).filter(
+ (parameter) => effectiveVisibility(parameter, profile) !== ParameterVisibility.HIDDEN,
);
+}
- return (release?.parameterSchema?.parameters ?? []).filter(
- (parameter) =>
- (overrides.get(parameter.parameterId) ?? parameter.visibility) !== ParameterVisibility.HIDDEN,
+/** A parameter's visibility for `profile`: the profile's override, else the engine's. */
+export function effectiveVisibility(
+ parameter: ParameterDefinition,
+ profile: ModelProfile | undefined,
+): ParameterVisibility {
+ return (
+ profile?.engineParameterVisibilityOverrides.find(
+ (override) => override.parameterId === parameter.parameterId,
+ )?.visibility ?? parameter.visibility
);
}
diff --git a/frontend/src/features/models/runtime/local-deployment-configuration.test.tsx b/frontend/src/features/models/runtime/local-deployment-configuration.test.tsx
index 70010389..9b1e2575 100644
--- a/frontend/src/features/models/runtime/local-deployment-configuration.test.tsx
+++ b/frontend/src/features/models/runtime/local-deployment-configuration.test.tsx
@@ -9,10 +9,10 @@ import { syntheticCatalog } from "@/features/models/catalog/catalog-table-test-s
import { ModelDeployDialog } from "@/features/models/deploy/model-deploy-dialog";
import {
+ changedEngineParameters,
defaultLocalDeploymentConfiguration,
localDeploymentConfiguration,
type LocalDeploymentConfigurationEdits,
- parallelRequestsCustomization,
runtimeCustomizations,
} from "./local-deployment-configuration";
import {
@@ -187,20 +187,29 @@ describe("local deployment configuration", () => {
).toEqual([]);
});
- it("names parallel requests apart from the runtime settings when they differ from the profile default", () => {
+ it("lists the changed engine parameters with parallel requests, leaving out context sizes", () => {
const profile = runtimeSchemaModel.profiles.find(({ profileId }) => profileId === "q4-xl")!;
- const customization = (parallel: string) =>
- parallelRequestsCustomization(profile, llamaCppRelease, {
+ const changed = (inputs: [string, string][]) =>
+ changedEngineParameters(profile, llamaCppRelease, {
bindingId: "q4-xl",
inputs: new Map([
- ["prompt-cache-memory-mib", "4096"],
+ ["prompt-cache-memory-mib", "8192"],
["total-context", "32768"],
- ["parallel-requests", parallel],
+ ["parallel-requests", "4"],
+ ...inputs,
]),
- });
+ }).map(({ parameter, value }) => [parameter.parameterId, value]);
- expect(customization("4")).toEqual([]);
- expect(customization("2")).toEqual(["Parallel requests 2 requests"]);
+ expect(changed([["total-context", "16384"]])).toEqual([]);
+ expect(
+ changed([
+ ["parallel-requests", "2"],
+ ["prompt-cache-memory-mib", "4096"],
+ ]),
+ ).toEqual([
+ ["parallel-requests", "2 requests"],
+ ["prompt-cache-memory-mib", "4 GiB"],
+ ]);
});
});
diff --git a/frontend/src/features/models/runtime/local-deployment-configuration.ts b/frontend/src/features/models/runtime/local-deployment-configuration.ts
index b5291412..da346aa6 100644
--- a/frontend/src/features/models/runtime/local-deployment-configuration.ts
+++ b/frontend/src/features/models/runtime/local-deployment-configuration.ts
@@ -77,10 +77,9 @@ export function defaultLocalDeploymentConfiguration(
}
/**
- * Describes the runtime settings of a deployment draft that differ from its profile's defaults:
- * edited engine parameters in schema order, then a changed model artifact. The context sizes,
- * parallel requests and custom arguments belong to the launch rather than the profile, so a profile
- * change keeps them and callers describe them separately.
+ * Describes the settings of a deployment draft that a profile change resets: edited engine
+ * parameters in schema order, then a changed model artifact. The context sizes, parallel requests
+ * and custom arguments belong to the launch rather than the profile, so a profile change keeps them.
*/
export function runtimeCustomizations(
model: Model,
@@ -88,38 +87,55 @@ export function runtimeCustomizations(
engineRelease: EngineRelease | undefined,
edits: LocalDeploymentConfigurationEdits,
): string[] {
- if (!profile) return [];
+ return [
+ ...changedEngineParameters(profile, engineRelease, edits)
+ .filter(({ parameter }) => parameter.parameterId !== PARALLEL_REQUESTS_PARAMETER_ID)
+ .map(({ parameter, value }) => `${parameter.displayName} ${value}`),
+ ...artifactCustomization(model, profile, edits.bindingId),
+ ];
+}
- const customizations = editedParameterLabels(
- profile,
- engineRelease,
- edits,
- (parameterId) =>
- !CONTEXT_PARAMETER_IDS.has(parameterId) && parameterId !== PARALLEL_REQUESTS_PARAMETER_ID,
- );
- if (edits.bindingId !== profile.defaultModelArtifactBindingId) {
- const binding = model.configurations
- .find((candidate) => candidate.configurationId === profile.configurationId)
- ?.artifactBindings.find((candidate) => candidate.bindingId === edits.bindingId);
- customizations.push(`Artifact ${binding?.modelArtifactId ?? "not selected"}`);
- }
- return customizations;
+/** One engine parameter whose input differs from its profile's default, with the input as text. */
+export interface ChangedEngineParameter {
+ parameter: ParameterDefinition;
+ value: string;
}
-/** Describes a deployment draft's parallel requests when they differ from its profile's default. */
-export function parallelRequestsCustomization(
+/**
+ * The engine parameters of a deployment draft whose input differs from the profile's default, in
+ * schema order, leaving out the context sizes, which a launch describes on their own.
+ */
+export function changedEngineParameters(
profile: ModelProfile | undefined,
engineRelease: EngineRelease | undefined,
edits: LocalDeploymentConfigurationEdits,
-): string[] {
+): ChangedEngineParameter[] {
if (!profile) return [];
- return editedParameterLabels(
- profile,
- engineRelease,
- edits,
- (parameterId) => parameterId === PARALLEL_REQUESTS_PARAMETER_ID,
+ const defaults = engineParameterInputs(
+ edits.declaredValues ?? profileStartingValues(engineRelease, profile),
);
+ return effectiveEngineParameters(engineRelease, profile).flatMap((parameter) => {
+ const input = edits.inputs.get(parameter.parameterId) ?? "";
+ return CONTEXT_PARAMETER_IDS.has(parameter.parameterId) ||
+ input === (defaults.get(parameter.parameterId) ?? "")
+ ? []
+ : [{ parameter, value: engineParameterInputLabel(parameter, edits.inputs) }];
+ });
+}
+
+/** Describes a model artifact other than the profile's default, or nothing. */
+export function artifactCustomization(
+ model: Model,
+ profile: ModelProfile | undefined,
+ bindingId: string,
+): string[] {
+ if (!profile || bindingId === profile.defaultModelArtifactBindingId) return [];
+
+ const binding = model.configurations
+ .find((candidate) => candidate.configurationId === profile.configurationId)
+ ?.artifactBindings.find((candidate) => candidate.bindingId === bindingId);
+ return [`Artifact ${binding?.modelArtifactId ?? "not selected"}`];
}
/**
@@ -173,24 +189,6 @@ export interface EngineReleaseRef {
version: string;
}
-/** Labels the profile's engine parameters the predicate selects whose input differs from its default. */
-function editedParameterLabels(
- profile: ModelProfile,
- engineRelease: EngineRelease | undefined,
- edits: LocalDeploymentConfigurationEdits,
- selected: (parameterId: string) => boolean,
-): string[] {
- const defaults = engineParameterInputs(
- edits.declaredValues ?? profileStartingValues(engineRelease, profile),
- );
- return effectiveEngineParameters(engineRelease, profile).flatMap((parameter) => {
- const input = edits.inputs.get(parameter.parameterId) ?? "";
- return !selected(parameter.parameterId) || input === (defaults.get(parameter.parameterId) ?? "")
- ? []
- : [`${parameter.displayName} ${engineParameterInputLabel(parameter, edits.inputs)}`];
- });
-}
-
// Formats valid values and keeps an invalid input as typed, so a summary never rounds a value the
// form still rejects into a plausible one.
function engineParameterInputLabel(
From 02427e0977cd076dc91b8a258b0811de62ed40d5 Mon Sep 17 00:00:00 2001
From: Andre-Philippe Paquet
Date: Thu, 12 Nov 2026 18:11:45 -0500
Subject: [PATCH 15/40] unreviewed: feat(proto): add text and secret parameters
and secret attachments
---
.../dotcore/application/v1/deployment_pb.ts | 61 ++++++++++++-
.../gen/dotcore/catalog/v1/application_pb.ts | 59 ++++++++++--
.../gen/dotcore/catalog/v1/parameter_pb.ts | 89 ++++++++++++++++++-
proto/dotcore/application/v1/deployment.proto | 24 ++++-
proto/dotcore/catalog/v1/application.proto | 27 ++++--
proto/dotcore/catalog/v1/parameter.proto | 31 ++++++-
6 files changed, 268 insertions(+), 23 deletions(-)
diff --git a/frontend/src/gen/dotcore/application/v1/deployment_pb.ts b/frontend/src/gen/dotcore/application/v1/deployment_pb.ts
index cbf4ca05..ea750f8a 100644
--- a/frontend/src/gen/dotcore/application/v1/deployment_pb.ts
+++ b/frontend/src/gen/dotcore/application/v1/deployment_pb.ts
@@ -24,7 +24,7 @@ import type { Message } from "@bufbuild/protobuf";
* Describes the file dotcore/application/v1/deployment.proto.
*/
export const file_dotcore_application_v1_deployment: GenFile = /*@__PURE__*/
- fileDesc("Cidkb3Rjb3JlL2FwcGxpY2F0aW9uL3YxL2RlcGxveW1lbnQucHJvdG8SFmRvdGNvcmUuYXBwbGljYXRpb24udjEiwwUKIEFwcGxpY2F0aW9uRGVwbG95bWVudERlY2xhcmF0aW9uEiEKGWFwcGxpY2F0aW9uX2RlcGxveW1lbnRfaWQYASABKAkSIwobYXBwbGljYXRpb25fZGVwbG95bWVudF9uYW1lGAIgASgJEjgKCXBsYWNlbWVudBgDIAEoCzIlLmRvdGNvcmUuZGVwbG95bWVudC52MS5FeGFjdFBsYWNlbWVudBJPCghzbmFwc2hvdBgEIAEoCzI9LmRvdGNvcmUuYXBwbGljYXRpb24udjEuUmVzb2x2ZWRBcHBsaWNhdGlvbkRlcGxveW1lbnRTbmFwc2hvdBJQCg1kZXNpcmVkX3N0YXRlGAUgASgOMjkuZG90Y29yZS5hcHBsaWNhdGlvbi52MS5BcHBsaWNhdGlvbkRlcGxveW1lbnREZXNpcmVkU3RhdGUSUQoTc3RvcmFnZV9hdHRhY2htZW50cxgGIAMoCzI0LmRvdGNvcmUuYXBwbGljYXRpb24udjEuQXBwbGljYXRpb25TdG9yYWdlQXR0YWNobWVudBJdChljb25maWd1cmF0aW9uX2F0dGFjaG1lbnRzGAcgAygLMjouZG90Y29yZS5hcHBsaWNhdGlvbi52MS5BcHBsaWNhdGlvbkNvbmZpZ3VyYXRpb25BdHRhY2htZW50Eh8KEmluZmVyZW5jZV9yb3V0ZV9pZBgIIAEoCUgAiAEBEhwKFGxpZmVjeWNsZV9nZW5lcmF0aW9uGAkgASgEEloKH25vZGVfcmVzb2x2ZWRfbGF1bmNoX2lkZW50aXRpZXMYCiADKAsyMS5kb3Rjb3JlLmRlcGxveW1lbnQudjEuTm9kZVJlc29sdmVkTGF1bmNoSWRlbnRpdHkSFgoOcHVibGlzaGVkX3BvcnQYCyABKA1CFQoTX2luZmVyZW5jZV9yb3V0ZV9pZCLsBQoVQXBwbGljYXRpb25EZXBsb3ltZW50EiEKGWFwcGxpY2F0aW9uX2RlcGxveW1lbnRfaWQYASABKAkSIwobYXBwbGljYXRpb25fZGVwbG95bWVudF9uYW1lGAIgASgJEjgKCXBsYWNlbWVudBgDIAEoCzIlLmRvdGNvcmUuZGVwbG95bWVudC52MS5FeGFjdFBsYWNlbWVudBJPCghzbmFwc2hvdBgEIAEoCzI9LmRvdGNvcmUuYXBwbGljYXRpb24udjEuUmVzb2x2ZWRBcHBsaWNhdGlvbkRlcGxveW1lbnRTbmFwc2hvdBJQCg1kZXNpcmVkX3N0YXRlGAUgASgOMjkuZG90Y29yZS5hcHBsaWNhdGlvbi52MS5BcHBsaWNhdGlvbkRlcGxveW1lbnREZXNpcmVkU3RhdGUSQQoFc3RhdGUYBiABKAsyMi5kb3Rjb3JlLmFwcGxpY2F0aW9uLnYxLkFwcGxpY2F0aW9uRGVwbG95bWVudFN0YXRlElEKE3N0b3JhZ2VfYXR0YWNobWVudHMYByADKAsyNC5kb3Rjb3JlLmFwcGxpY2F0aW9uLnYxLkFwcGxpY2F0aW9uU3RvcmFnZUF0dGFjaG1lbnQSWwoYc2VsZWN0ZWRfaW5mZXJlbmNlX3JvdXRlGAggASgLMjkuZG90Y29yZS5hcHBsaWNhdGlvbi52MS5BcHBsaWNhdGlvblNlbGVjdGVkSW5mZXJlbmNlUm91dGUSWgofbm9kZV9yZXNvbHZlZF9sYXVuY2hfaWRlbnRpdGllcxgJIAMoCzIxLmRvdGNvcmUuZGVwbG95bWVudC52MS5Ob2RlUmVzb2x2ZWRMYXVuY2hJZGVudGl0eRJfChpzZXJ2aWNlX2FjY2VsZXJhdG9yX21lbW9yeRgKIAMoCzI7LmRvdGNvcmUuYXBwbGljYXRpb24udjEuQXBwbGljYXRpb25TZXJ2aWNlQWNjZWxlcmF0b3JNZW1vcnkijAEKI0FwcGxpY2F0aW9uU2VydmljZUFjY2VsZXJhdG9yTWVtb3J5EhQKDHNlcnZpY2VfbmFtZRgBIAEoCRJPChJhY2NlbGVyYXRvcl9tZW1vcnkYAiABKAsyMy5kb3Rjb3JlLmRlcGxveW1lbnQudjEuQWNjZWxlcmF0b3JNZW1vcnlPYnNlcnZhdGlvbiLOAwolUmVzb2x2ZWRBcHBsaWNhdGlvbkRlcGxveW1lbnRTbmFwc2hvdBIWCg5hcHBsaWNhdGlvbl9pZBgBIAEoCRIUCgxkaXNwbGF5X25hbWUYAiABKAkSDwoHdmVyc2lvbhgDIAEoCRINCgVub3RlcxgIIAEoCRIzCgVncmFwaBgEIAEoCzIkLmRvdGNvcmUuY2F0YWxvZy52MS5BcHBsaWNhdGlvbkdyYXBoEjwKEHBhcmFtZXRlcl92YWx1ZXMYCiADKAsyIi5kb3Rjb3JlLmNhdGFsb2cudjEuUGFyYW1ldGVyVmFsdWUSOAoJcGxhY2VtZW50GAUgASgLMiUuZG90Y29yZS5kZXBsb3ltZW50LnYxLkV4YWN0UGxhY2VtZW50Eh4KFmFjY2VsZXJhdG9yX2RldmljZV9pZHMYCSADKAkSMQoIcGxhdGZvcm0YBiABKAsyHy5kb3Rjb3JlLmNhdGFsb2cudjEuT2NpUGxhdGZvcm0SVwoSc2VydmljZV9zZWxlY3Rpb25zGAcgAygLMjsuZG90Y29yZS5hcHBsaWNhdGlvbi52MS5SZXNvbHZlZEFwcGxpY2F0aW9uU2VydmljZVNlbGVjdGlvbiKiAQojUmVzb2x2ZWRBcHBsaWNhdGlvblNlcnZpY2VTZWxlY3Rpb24SFAoMc2VydmljZV9uYW1lGAEgASgJEkwKEnByZXBhcmF0aW9uX3BvbGljeRgDIAEoDjIwLmRvdGNvcmUuZGVwbG95bWVudC52MS5BcnRpZmFjdFByZXBhcmF0aW9uUG9saWN5EhcKD2xhdW5jaF9pZGVudGl0eRgEIAEoCSJTCiFBcHBsaWNhdGlvblNlbGVjdGVkSW5mZXJlbmNlUm91dGUSGgoSaW5mZXJlbmNlX3JvdXRlX2lkGAEgASgJEhIKCnJvdXRlX25hbWUYAiABKAkiYAocQXBwbGljYXRpb25TdG9yYWdlQXR0YWNobWVudBIUCgxzZXJ2aWNlX25hbWUYASABKAkSFAoMYmluZGluZ19uYW1lGAIgASgJEhQKDHN0b3JhZ2VfbmFtZRgDIAEoCSJ9CiJBcHBsaWNhdGlvbkNvbmZpZ3VyYXRpb25BdHRhY2htZW50EhQKDHNlcnZpY2VfbmFtZRgBIAEoCRIUCgxiaW5kaW5nX25hbWUYAiABKAkSHAoUZW52aXJvbm1lbnRfdmFyaWFibGUYAyABKAkSDQoFdmFsdWUYBCABKAkq7wEKIUFwcGxpY2F0aW9uRGVwbG95bWVudERlc2lyZWRTdGF0ZRI0CjBBUFBMSUNBVElPTl9ERVBMT1lNRU5UX0RFU0lSRURfU1RBVEVfVU5TUEVDSUZJRUQQABIwCixBUFBMSUNBVElPTl9ERVBMT1lNRU5UX0RFU0lSRURfU1RBVEVfUFJFU0VOVBABEjEKLUFQUExJQ0FUSU9OX0RFUExPWU1FTlRfREVTSVJFRF9TVEFURV9URUFSRE9XThACEi8KK0FQUExJQ0FUSU9OX0RFUExPWU1FTlRfREVTSVJFRF9TVEFURV9QQVVTRUQQA2IGcHJvdG8z", [file_dotcore_application_v1_state, file_dotcore_catalog_v1_application, file_dotcore_catalog_v1_oci, file_dotcore_catalog_v1_parameter, file_dotcore_deployment_v1_accelerator_memory, file_dotcore_deployment_v1_artifact, file_dotcore_deployment_v1_placement]);
+ fileDesc("Cidkb3Rjb3JlL2FwcGxpY2F0aW9uL3YxL2RlcGxveW1lbnQucHJvdG8SFmRvdGNvcmUuYXBwbGljYXRpb24udjEilAYKIEFwcGxpY2F0aW9uRGVwbG95bWVudERlY2xhcmF0aW9uEiEKGWFwcGxpY2F0aW9uX2RlcGxveW1lbnRfaWQYASABKAkSIwobYXBwbGljYXRpb25fZGVwbG95bWVudF9uYW1lGAIgASgJEjgKCXBsYWNlbWVudBgDIAEoCzIlLmRvdGNvcmUuZGVwbG95bWVudC52MS5FeGFjdFBsYWNlbWVudBJPCghzbmFwc2hvdBgEIAEoCzI9LmRvdGNvcmUuYXBwbGljYXRpb24udjEuUmVzb2x2ZWRBcHBsaWNhdGlvbkRlcGxveW1lbnRTbmFwc2hvdBJQCg1kZXNpcmVkX3N0YXRlGAUgASgOMjkuZG90Y29yZS5hcHBsaWNhdGlvbi52MS5BcHBsaWNhdGlvbkRlcGxveW1lbnREZXNpcmVkU3RhdGUSUQoTc3RvcmFnZV9hdHRhY2htZW50cxgGIAMoCzI0LmRvdGNvcmUuYXBwbGljYXRpb24udjEuQXBwbGljYXRpb25TdG9yYWdlQXR0YWNobWVudBJdChljb25maWd1cmF0aW9uX2F0dGFjaG1lbnRzGAcgAygLMjouZG90Y29yZS5hcHBsaWNhdGlvbi52MS5BcHBsaWNhdGlvbkNvbmZpZ3VyYXRpb25BdHRhY2htZW50Ek8KEnNlY3JldF9hdHRhY2htZW50cxgMIAMoCzIzLmRvdGNvcmUuYXBwbGljYXRpb24udjEuQXBwbGljYXRpb25TZWNyZXRBdHRhY2htZW50Eh8KEmluZmVyZW5jZV9yb3V0ZV9pZBgIIAEoCUgAiAEBEhwKFGxpZmVjeWNsZV9nZW5lcmF0aW9uGAkgASgEEloKH25vZGVfcmVzb2x2ZWRfbGF1bmNoX2lkZW50aXRpZXMYCiADKAsyMS5kb3Rjb3JlLmRlcGxveW1lbnQudjEuTm9kZVJlc29sdmVkTGF1bmNoSWRlbnRpdHkSFgoOcHVibGlzaGVkX3BvcnQYCyABKA1CFQoTX2luZmVyZW5jZV9yb3V0ZV9pZCLsBQoVQXBwbGljYXRpb25EZXBsb3ltZW50EiEKGWFwcGxpY2F0aW9uX2RlcGxveW1lbnRfaWQYASABKAkSIwobYXBwbGljYXRpb25fZGVwbG95bWVudF9uYW1lGAIgASgJEjgKCXBsYWNlbWVudBgDIAEoCzIlLmRvdGNvcmUuZGVwbG95bWVudC52MS5FeGFjdFBsYWNlbWVudBJPCghzbmFwc2hvdBgEIAEoCzI9LmRvdGNvcmUuYXBwbGljYXRpb24udjEuUmVzb2x2ZWRBcHBsaWNhdGlvbkRlcGxveW1lbnRTbmFwc2hvdBJQCg1kZXNpcmVkX3N0YXRlGAUgASgOMjkuZG90Y29yZS5hcHBsaWNhdGlvbi52MS5BcHBsaWNhdGlvbkRlcGxveW1lbnREZXNpcmVkU3RhdGUSQQoFc3RhdGUYBiABKAsyMi5kb3Rjb3JlLmFwcGxpY2F0aW9uLnYxLkFwcGxpY2F0aW9uRGVwbG95bWVudFN0YXRlElEKE3N0b3JhZ2VfYXR0YWNobWVudHMYByADKAsyNC5kb3Rjb3JlLmFwcGxpY2F0aW9uLnYxLkFwcGxpY2F0aW9uU3RvcmFnZUF0dGFjaG1lbnQSWwoYc2VsZWN0ZWRfaW5mZXJlbmNlX3JvdXRlGAggASgLMjkuZG90Y29yZS5hcHBsaWNhdGlvbi52MS5BcHBsaWNhdGlvblNlbGVjdGVkSW5mZXJlbmNlUm91dGUSWgofbm9kZV9yZXNvbHZlZF9sYXVuY2hfaWRlbnRpdGllcxgJIAMoCzIxLmRvdGNvcmUuZGVwbG95bWVudC52MS5Ob2RlUmVzb2x2ZWRMYXVuY2hJZGVudGl0eRJfChpzZXJ2aWNlX2FjY2VsZXJhdG9yX21lbW9yeRgKIAMoCzI7LmRvdGNvcmUuYXBwbGljYXRpb24udjEuQXBwbGljYXRpb25TZXJ2aWNlQWNjZWxlcmF0b3JNZW1vcnkijAEKI0FwcGxpY2F0aW9uU2VydmljZUFjY2VsZXJhdG9yTWVtb3J5EhQKDHNlcnZpY2VfbmFtZRgBIAEoCRJPChJhY2NlbGVyYXRvcl9tZW1vcnkYAiABKAsyMy5kb3Rjb3JlLmRlcGxveW1lbnQudjEuQWNjZWxlcmF0b3JNZW1vcnlPYnNlcnZhdGlvbiLvAwolUmVzb2x2ZWRBcHBsaWNhdGlvbkRlcGxveW1lbnRTbmFwc2hvdBIWCg5hcHBsaWNhdGlvbl9pZBgBIAEoCRIUCgxkaXNwbGF5X25hbWUYAiABKAkSDwoHdmVyc2lvbhgDIAEoCRINCgVub3RlcxgIIAEoCRIzCgVncmFwaBgEIAEoCzIkLmRvdGNvcmUuY2F0YWxvZy52MS5BcHBsaWNhdGlvbkdyYXBoEjwKEHBhcmFtZXRlcl92YWx1ZXMYCiADKAsyIi5kb3Rjb3JlLmNhdGFsb2cudjEuUGFyYW1ldGVyVmFsdWUSHwoXZ2VuZXJhdGVkX3BhcmFtZXRlcl9pZHMYCyADKAkSOAoJcGxhY2VtZW50GAUgASgLMiUuZG90Y29yZS5kZXBsb3ltZW50LnYxLkV4YWN0UGxhY2VtZW50Eh4KFmFjY2VsZXJhdG9yX2RldmljZV9pZHMYCSADKAkSMQoIcGxhdGZvcm0YBiABKAsyHy5kb3Rjb3JlLmNhdGFsb2cudjEuT2NpUGxhdGZvcm0SVwoSc2VydmljZV9zZWxlY3Rpb25zGAcgAygLMjsuZG90Y29yZS5hcHBsaWNhdGlvbi52MS5SZXNvbHZlZEFwcGxpY2F0aW9uU2VydmljZVNlbGVjdGlvbiKiAQojUmVzb2x2ZWRBcHBsaWNhdGlvblNlcnZpY2VTZWxlY3Rpb24SFAoMc2VydmljZV9uYW1lGAEgASgJEkwKEnByZXBhcmF0aW9uX3BvbGljeRgDIAEoDjIwLmRvdGNvcmUuZGVwbG95bWVudC52MS5BcnRpZmFjdFByZXBhcmF0aW9uUG9saWN5EhcKD2xhdW5jaF9pZGVudGl0eRgEIAEoCSJTCiFBcHBsaWNhdGlvblNlbGVjdGVkSW5mZXJlbmNlUm91dGUSGgoSaW5mZXJlbmNlX3JvdXRlX2lkGAEgASgJEhIKCnJvdXRlX25hbWUYAiABKAkiYAocQXBwbGljYXRpb25TdG9yYWdlQXR0YWNobWVudBIUCgxzZXJ2aWNlX25hbWUYASABKAkSFAoMYmluZGluZ19uYW1lGAIgASgJEhQKDHN0b3JhZ2VfbmFtZRgDIAEoCSJ9CiJBcHBsaWNhdGlvbkNvbmZpZ3VyYXRpb25BdHRhY2htZW50EhQKDHNlcnZpY2VfbmFtZRgBIAEoCRIUCgxiaW5kaW5nX25hbWUYAiABKAkSHAoUZW52aXJvbm1lbnRfdmFyaWFibGUYAyABKAkSDQoFdmFsdWUYBCABKAkidgobQXBwbGljYXRpb25TZWNyZXRBdHRhY2htZW50EhQKDHNlcnZpY2VfbmFtZRgBIAEoCRIUCgxiaW5kaW5nX25hbWUYAiABKAkSHAoUZW52aXJvbm1lbnRfdmFyaWFibGUYAyABKAkSDQoFdmFsdWUYBCABKAkq7wEKIUFwcGxpY2F0aW9uRGVwbG95bWVudERlc2lyZWRTdGF0ZRI0CjBBUFBMSUNBVElPTl9ERVBMT1lNRU5UX0RFU0lSRURfU1RBVEVfVU5TUEVDSUZJRUQQABIwCixBUFBMSUNBVElPTl9ERVBMT1lNRU5UX0RFU0lSRURfU1RBVEVfUFJFU0VOVBABEjEKLUFQUExJQ0FUSU9OX0RFUExPWU1FTlRfREVTSVJFRF9TVEFURV9URUFSRE9XThACEi8KK0FQUExJQ0FUSU9OX0RFUExPWU1FTlRfREVTSVJFRF9TVEFURV9QQVVTRUQQA2IGcHJvdG8z", [file_dotcore_application_v1_state, file_dotcore_catalog_v1_application, file_dotcore_catalog_v1_oci, file_dotcore_catalog_v1_parameter, file_dotcore_deployment_v1_accelerator_memory, file_dotcore_deployment_v1_artifact, file_dotcore_deployment_v1_placement]);
/**
* Complete declarative site record owned by the apps controller.
@@ -81,6 +81,13 @@ export type ApplicationDeploymentDeclaration = Message<"dotcore.application.v1.A
*/
configurationAttachments: ApplicationConfigurationAttachment[];
+ /**
+ * Secret parameter values in the form each secret binding needs, computed once at creation.
+ *
+ * @generated from field: repeated dotcore.application.v1.ApplicationSecretAttachment secret_attachments = 12;
+ */
+ secretAttachments: ApplicationSecretAttachment[];
+
/**
* Optional stable inference route selected for this deployment.
*
@@ -273,12 +280,20 @@ export type ResolvedApplicationDeploymentSnapshot = Message<"dotcore.application
graph?: ApplicationGraph;
/**
- * Complete parameter values resolved at creation, defaults filled, in graph declaration order.
+ * Complete parameter values resolved at creation, defaults filled and missing secrets generated,
+ * in graph declaration order.
*
* @generated from field: repeated dotcore.catalog.v1.ParameterValue parameter_values = 10;
*/
parameterValues: ParameterValue[];
+ /**
+ * Secret parameters whose value was generated at creation rather than supplied.
+ *
+ * @generated from field: repeated string generated_parameter_ids = 11;
+ */
+ generatedParameterIds: string[];
+
/**
* The exact placement selected for this application deployment.
*
@@ -455,6 +470,48 @@ export type ApplicationConfigurationAttachment = Message<"dotcore.application.v1
export const ApplicationConfigurationAttachmentSchema: GenMessage = /*@__PURE__*/
messageDesc(file_dotcore_application_v1_deployment, 7);
+/**
+ * One secret parameter value in the form a service's secret binding needs.
+ *
+ * @generated from message dotcore.application.v1.ApplicationSecretAttachment
+ */
+export type ApplicationSecretAttachment = Message<"dotcore.application.v1.ApplicationSecretAttachment"> & {
+ /**
+ * Name of the service that receives this attachment.
+ *
+ * @generated from field: string service_name = 1;
+ */
+ serviceName: string;
+
+ /**
+ * Secret binding name from the immutable application snapshot.
+ *
+ * @generated from field: string binding_name = 2;
+ */
+ bindingName: string;
+
+ /**
+ * Environment variable receiving the value.
+ *
+ * @generated from field: string environment_variable = 3;
+ */
+ environmentVariable: string;
+
+ /**
+ * Secret value in the binding's form, supplied only to the runtime launch.
+ *
+ * @generated from field: string value = 4;
+ */
+ value: string;
+};
+
+/**
+ * Describes the message dotcore.application.v1.ApplicationSecretAttachment.
+ * Use `create(ApplicationSecretAttachmentSchema)` to create a new message.
+ */
+export const ApplicationSecretAttachmentSchema: GenMessage = /*@__PURE__*/
+ messageDesc(file_dotcore_application_v1_deployment, 8);
+
/**
* The accepted desired presence of an application deployment.
*
diff --git a/frontend/src/gen/dotcore/catalog/v1/application_pb.ts b/frontend/src/gen/dotcore/catalog/v1/application_pb.ts
index 3b34fb91..bf03df6d 100644
--- a/frontend/src/gen/dotcore/catalog/v1/application_pb.ts
+++ b/frontend/src/gen/dotcore/catalog/v1/application_pb.ts
@@ -18,7 +18,7 @@ import type { Message } from "@bufbuild/protobuf";
* Describes the file dotcore/catalog/v1/application.proto.
*/
export const file_dotcore_catalog_v1_application: GenFile = /*@__PURE__*/
- fileDesc("CiRkb3Rjb3JlL2NhdGFsb2cvdjEvYXBwbGljYXRpb24ucHJvdG8SEmRvdGNvcmUuY2F0YWxvZy52MSKlAQoLQXBwbGljYXRpb24SFgoOYXBwbGljYXRpb25faWQYASABKAkSFAoMZGlzcGxheV9uYW1lGAIgASgJEhMKC2Rlc2NyaXB0aW9uGAUgASgJEg0KBW5vdGVzGAYgASgJEg8KB3ZlcnNpb24YAyABKAkSMwoFZ3JhcGgYBCABKAsyJC5kb3Rjb3JlLmNhdGFsb2cudjEuQXBwbGljYXRpb25HcmFwaCLTAQoQQXBwbGljYXRpb25HcmFwaBI4CghzZXJ2aWNlcxgBIAMoCzImLmRvdGNvcmUuY2F0YWxvZy52MS5BcHBsaWNhdGlvblNlcnZpY2USSAoQcHJpbWFyeV9lbmRwb2ludBgCIAEoCzIuLmRvdGNvcmUuY2F0YWxvZy52MS5BcHBsaWNhdGlvblByaW1hcnlFbmRwb2ludBI7CgpwYXJhbWV0ZXJzGAMgAygLMicuZG90Y29yZS5jYXRhbG9nLnYxLlBhcmFtZXRlckRlZmluaXRpb24isQQKEkFwcGxpY2F0aW9uU2VydmljZRIUCgxzZXJ2aWNlX25hbWUYASABKAkSQAoIYXJ0aWZhY3QYAiABKAsyLi5kb3Rjb3JlLmNhdGFsb2cudjEuQXBwbGljYXRpb25TZXJ2aWNlQXJ0aWZhY3QSDwoHY29tbWFuZBgDIAMoCRIRCglhcmd1bWVudHMYBCADKAkSOQoFcG9ydHMYBSADKAsyKi5kb3Rjb3JlLmNhdGFsb2cudjEuQXBwbGljYXRpb25TZXJ2aWNlUG9ydBJTChZjb25maWd1cmF0aW9uX2JpbmRpbmdzGAYgAygLMjMuZG90Y29yZS5jYXRhbG9nLnYxLkFwcGxpY2F0aW9uQ29uZmlndXJhdGlvbkJpbmRpbmcSPAoQc3RvcmFnZV9iaW5kaW5ncxgHIAMoCzIiLmRvdGNvcmUuY2F0YWxvZy52MS5TdG9yYWdlQmluZGluZxJFCg9zZWNyZXRfYmluZGluZ3MYCCADKAsyLC5kb3Rjb3JlLmNhdGFsb2cudjEuQXBwbGljYXRpb25TZWNyZXRCaW5kaW5nEkYKDGRlcGVuZGVuY2llcxgJIAMoCzIwLmRvdGNvcmUuY2F0YWxvZy52MS5BcHBsaWNhdGlvblNlcnZpY2VEZXBlbmRlbmN5EkIKCXJlYWRpbmVzcxgKIAEoCzIvLmRvdGNvcmUuY2F0YWxvZy52MS5BcHBsaWNhdGlvblNlcnZpY2VSZWFkaW5lc3MiyAEKGkFwcGxpY2F0aW9uU2VydmljZUFydGlmYWN0EjQKA29jaRgBIAEoCzIlLmRvdGNvcmUuY2F0YWxvZy52MS5PY2lJbWFnZVJlZmVyZW5jZUgAEi4KA25peBgDIAEoCzIfLmRvdGNvcmUuY2F0YWxvZy52MS5OaXhBcnRpZmFjdEgAEjwKE3N1cHBvcnRlZF9wbGF0Zm9ybXMYAiADKAsyHy5kb3Rjb3JlLmNhdGFsb2cudjEuT2NpUGxhdGZvcm1CBgoEa2luZCJAChZBcHBsaWNhdGlvblNlcnZpY2VQb3J0EhEKCXBvcnRfbmFtZRgBIAEoCRITCgtwb3J0X251bWJlchgCIAEoDSJ7ChxBcHBsaWNhdGlvblNlcnZpY2VEZXBlbmRlbmN5EhQKDHNlcnZpY2VfbmFtZRgBIAEoCRJFCgljb25kaXRpb24YAiABKA4yMi5kb3Rjb3JlLmNhdGFsb2cudjEuQXBwbGljYXRpb25EZXBlbmRlbmN5Q29uZGl0aW9uImgKG0FwcGxpY2F0aW9uU2VydmljZVJlYWRpbmVzcxJACghodHRwX2dldBgBIAEoCzIsLmRvdGNvcmUuY2F0YWxvZy52MS5BcHBsaWNhdGlvbkh0dHBSZWFkaW5lc3NIAEIHCgVjaGVjayI7ChhBcHBsaWNhdGlvbkh0dHBSZWFkaW5lc3MSEQoJcG9ydF9uYW1lGAEgASgJEgwKBHBhdGgYAiABKAki7gEKH0FwcGxpY2F0aW9uQ29uZmlndXJhdGlvbkJpbmRpbmcSFAoMYmluZGluZ19uYW1lGAEgASgJEhwKFGVudmlyb25tZW50X3ZhcmlhYmxlGAIgASgJEhcKDWxpdGVyYWxfdmFsdWUYAyABKAlIABIdChNyZXNvbHZlZF92YWx1ZV9uYW1lGAQgASgJSAASFgoMcGFyYW1ldGVyX2lkGAYgASgJSAASPQoFaW5wdXQYBSABKAsyLi5kb3Rjb3JlLmNhdGFsb2cudjEuQXBwbGljYXRpb25EZXBsb3ltZW50SW5wdXRCCAoGc291cmNlInMKGkFwcGxpY2F0aW9uRGVwbG95bWVudElucHV0Ek0KD2luZmVyZW5jZV9yb3V0ZRgCIAEoCzIyLmRvdGNvcmUuY2F0YWxvZy52MS5BcHBsaWNhdGlvbkluZmVyZW5jZVJvdXRlSW5wdXRIAEIGCgRraW5kIm0KHkFwcGxpY2F0aW9uSW5mZXJlbmNlUm91dGVJbnB1dBJLCgpwcm9qZWN0aW9uGAEgASgOMjcuZG90Y29yZS5jYXRhbG9nLnYxLkFwcGxpY2F0aW9uSW5mZXJlbmNlUm91dGVQcm9qZWN0aW9uIk4KGEFwcGxpY2F0aW9uU2VjcmV0QmluZGluZxIUCgxiaW5kaW5nX25hbWUYASABKAkSHAoUZW52aXJvbm1lbnRfdmFyaWFibGUYAiABKAkiiAEKGkFwcGxpY2F0aW9uUHJpbWFyeUVuZHBvaW50EhQKDHNlcnZpY2VfbmFtZRgBIAEoCRIRCglwb3J0X25hbWUYAiABKAkSQQoIcHJvdG9jb2wYAyABKA4yLy5kb3Rjb3JlLmNhdGFsb2cudjEuQXBwbGljYXRpb25FbmRwb2ludFByb3RvY29sKpQBCiNBcHBsaWNhdGlvbkluZmVyZW5jZVJvdXRlUHJvamVjdGlvbhI2CjJBUFBMSUNBVElPTl9JTkZFUkVOQ0VfUk9VVEVfUFJPSkVDVElPTl9VTlNQRUNJRklFRBAAEjUKMUFQUExJQ0FUSU9OX0lORkVSRU5DRV9ST1VURV9QUk9KRUNUSU9OX1JPVVRFX05BTUUQASqsAQoeQXBwbGljYXRpb25EZXBlbmRlbmN5Q29uZGl0aW9uEjAKLEFQUExJQ0FUSU9OX0RFUEVOREVOQ1lfQ09ORElUSU9OX1VOU1BFQ0lGSUVEEAASLAooQVBQTElDQVRJT05fREVQRU5ERU5DWV9DT05ESVRJT05fU1RBUlRFRBABEioKJkFQUExJQ0FUSU9OX0RFUEVOREVOQ1lfQ09ORElUSU9OX1JFQURZEAIqdAobQXBwbGljYXRpb25FbmRwb2ludFByb3RvY29sEi0KKUFQUExJQ0FUSU9OX0VORFBPSU5UX1BST1RPQ09MX1VOU1BFQ0lGSUVEEAASJgoiQVBQTElDQVRJT05fRU5EUE9JTlRfUFJPVE9DT0xfSFRUUBABYgZwcm90bzM", [file_dotcore_catalog_v1_nix, file_dotcore_catalog_v1_oci, file_dotcore_catalog_v1_parameter, file_dotcore_catalog_v1_storage]);
+ fileDesc("CiRkb3Rjb3JlL2NhdGFsb2cvdjEvYXBwbGljYXRpb24ucHJvdG8SEmRvdGNvcmUuY2F0YWxvZy52MSKlAQoLQXBwbGljYXRpb24SFgoOYXBwbGljYXRpb25faWQYASABKAkSFAoMZGlzcGxheV9uYW1lGAIgASgJEhMKC2Rlc2NyaXB0aW9uGAUgASgJEg0KBW5vdGVzGAYgASgJEg8KB3ZlcnNpb24YAyABKAkSMwoFZ3JhcGgYBCABKAsyJC5kb3Rjb3JlLmNhdGFsb2cudjEuQXBwbGljYXRpb25HcmFwaCLTAQoQQXBwbGljYXRpb25HcmFwaBI4CghzZXJ2aWNlcxgBIAMoCzImLmRvdGNvcmUuY2F0YWxvZy52MS5BcHBsaWNhdGlvblNlcnZpY2USSAoQcHJpbWFyeV9lbmRwb2ludBgCIAEoCzIuLmRvdGNvcmUuY2F0YWxvZy52MS5BcHBsaWNhdGlvblByaW1hcnlFbmRwb2ludBI7CgpwYXJhbWV0ZXJzGAMgAygLMicuZG90Y29yZS5jYXRhbG9nLnYxLlBhcmFtZXRlckRlZmluaXRpb24isQQKEkFwcGxpY2F0aW9uU2VydmljZRIUCgxzZXJ2aWNlX25hbWUYASABKAkSQAoIYXJ0aWZhY3QYAiABKAsyLi5kb3Rjb3JlLmNhdGFsb2cudjEuQXBwbGljYXRpb25TZXJ2aWNlQXJ0aWZhY3QSDwoHY29tbWFuZBgDIAMoCRIRCglhcmd1bWVudHMYBCADKAkSOQoFcG9ydHMYBSADKAsyKi5kb3Rjb3JlLmNhdGFsb2cudjEuQXBwbGljYXRpb25TZXJ2aWNlUG9ydBJTChZjb25maWd1cmF0aW9uX2JpbmRpbmdzGAYgAygLMjMuZG90Y29yZS5jYXRhbG9nLnYxLkFwcGxpY2F0aW9uQ29uZmlndXJhdGlvbkJpbmRpbmcSPAoQc3RvcmFnZV9iaW5kaW5ncxgHIAMoCzIiLmRvdGNvcmUuY2F0YWxvZy52MS5TdG9yYWdlQmluZGluZxJFCg9zZWNyZXRfYmluZGluZ3MYCCADKAsyLC5kb3Rjb3JlLmNhdGFsb2cudjEuQXBwbGljYXRpb25TZWNyZXRCaW5kaW5nEkYKDGRlcGVuZGVuY2llcxgJIAMoCzIwLmRvdGNvcmUuY2F0YWxvZy52MS5BcHBsaWNhdGlvblNlcnZpY2VEZXBlbmRlbmN5EkIKCXJlYWRpbmVzcxgKIAEoCzIvLmRvdGNvcmUuY2F0YWxvZy52MS5BcHBsaWNhdGlvblNlcnZpY2VSZWFkaW5lc3MiyAEKGkFwcGxpY2F0aW9uU2VydmljZUFydGlmYWN0EjQKA29jaRgBIAEoCzIlLmRvdGNvcmUuY2F0YWxvZy52MS5PY2lJbWFnZVJlZmVyZW5jZUgAEi4KA25peBgDIAEoCzIfLmRvdGNvcmUuY2F0YWxvZy52MS5OaXhBcnRpZmFjdEgAEjwKE3N1cHBvcnRlZF9wbGF0Zm9ybXMYAiADKAsyHy5kb3Rjb3JlLmNhdGFsb2cudjEuT2NpUGxhdGZvcm1CBgoEa2luZCJAChZBcHBsaWNhdGlvblNlcnZpY2VQb3J0EhEKCXBvcnRfbmFtZRgBIAEoCRITCgtwb3J0X251bWJlchgCIAEoDSJ7ChxBcHBsaWNhdGlvblNlcnZpY2VEZXBlbmRlbmN5EhQKDHNlcnZpY2VfbmFtZRgBIAEoCRJFCgljb25kaXRpb24YAiABKA4yMi5kb3Rjb3JlLmNhdGFsb2cudjEuQXBwbGljYXRpb25EZXBlbmRlbmN5Q29uZGl0aW9uImgKG0FwcGxpY2F0aW9uU2VydmljZVJlYWRpbmVzcxJACghodHRwX2dldBgBIAEoCzIsLmRvdGNvcmUuY2F0YWxvZy52MS5BcHBsaWNhdGlvbkh0dHBSZWFkaW5lc3NIAEIHCgVjaGVjayI7ChhBcHBsaWNhdGlvbkh0dHBSZWFkaW5lc3MSEQoJcG9ydF9uYW1lGAEgASgJEgwKBHBhdGgYAiABKAki7gEKH0FwcGxpY2F0aW9uQ29uZmlndXJhdGlvbkJpbmRpbmcSFAoMYmluZGluZ19uYW1lGAEgASgJEhwKFGVudmlyb25tZW50X3ZhcmlhYmxlGAIgASgJEhcKDWxpdGVyYWxfdmFsdWUYAyABKAlIABIdChNyZXNvbHZlZF92YWx1ZV9uYW1lGAQgASgJSAASFgoMcGFyYW1ldGVyX2lkGAYgASgJSAASPQoFaW5wdXQYBSABKAsyLi5kb3Rjb3JlLmNhdGFsb2cudjEuQXBwbGljYXRpb25EZXBsb3ltZW50SW5wdXRCCAoGc291cmNlInMKGkFwcGxpY2F0aW9uRGVwbG95bWVudElucHV0Ek0KD2luZmVyZW5jZV9yb3V0ZRgCIAEoCzIyLmRvdGNvcmUuY2F0YWxvZy52MS5BcHBsaWNhdGlvbkluZmVyZW5jZVJvdXRlSW5wdXRIAEIGCgRraW5kIm0KHkFwcGxpY2F0aW9uSW5mZXJlbmNlUm91dGVJbnB1dBJLCgpwcm9qZWN0aW9uGAEgASgOMjcuZG90Y29yZS5jYXRhbG9nLnYxLkFwcGxpY2F0aW9uSW5mZXJlbmNlUm91dGVQcm9qZWN0aW9uIp0BChhBcHBsaWNhdGlvblNlY3JldEJpbmRpbmcSFAoMYmluZGluZ19uYW1lGAEgASgJEhwKFGVudmlyb25tZW50X3ZhcmlhYmxlGAIgASgJEhQKDHBhcmFtZXRlcl9pZBgDIAEoCRI3CgRmb3JtGAQgASgOMikuZG90Y29yZS5jYXRhbG9nLnYxLkFwcGxpY2F0aW9uU2VjcmV0Rm9ybSKIAQoaQXBwbGljYXRpb25QcmltYXJ5RW5kcG9pbnQSFAoMc2VydmljZV9uYW1lGAEgASgJEhEKCXBvcnRfbmFtZRgCIAEoCRJBCghwcm90b2NvbBgDIAEoDjIvLmRvdGNvcmUuY2F0YWxvZy52MS5BcHBsaWNhdGlvbkVuZHBvaW50UHJvdG9jb2wqlAEKI0FwcGxpY2F0aW9uSW5mZXJlbmNlUm91dGVQcm9qZWN0aW9uEjYKMkFQUExJQ0FUSU9OX0lORkVSRU5DRV9ST1VURV9QUk9KRUNUSU9OX1VOU1BFQ0lGSUVEEAASNQoxQVBQTElDQVRJT05fSU5GRVJFTkNFX1JPVVRFX1BST0pFQ1RJT05fUk9VVEVfTkFNRRABKmMKFUFwcGxpY2F0aW9uU2VjcmV0Rm9ybRInCiNBUFBMSUNBVElPTl9TRUNSRVRfRk9STV9VTlNQRUNJRklFRBAAEiEKHUFQUExJQ0FUSU9OX1NFQ1JFVF9GT1JNX1BMQUlOEAEqrAEKHkFwcGxpY2F0aW9uRGVwZW5kZW5jeUNvbmRpdGlvbhIwCixBUFBMSUNBVElPTl9ERVBFTkRFTkNZX0NPTkRJVElPTl9VTlNQRUNJRklFRBAAEiwKKEFQUExJQ0FUSU9OX0RFUEVOREVOQ1lfQ09ORElUSU9OX1NUQVJURUQQARIqCiZBUFBMSUNBVElPTl9ERVBFTkRFTkNZX0NPTkRJVElPTl9SRUFEWRACKnQKG0FwcGxpY2F0aW9uRW5kcG9pbnRQcm90b2NvbBItCilBUFBMSUNBVElPTl9FTkRQT0lOVF9QUk9UT0NPTF9VTlNQRUNJRklFRBAAEiYKIkFQUExJQ0FUSU9OX0VORFBPSU5UX1BST1RPQ09MX0hUVFAQAWIGcHJvdG8z", [file_dotcore_catalog_v1_nix, file_dotcore_catalog_v1_oci, file_dotcore_catalog_v1_parameter, file_dotcore_catalog_v1_storage]);
/**
* An immutable artifact-manager catalog declaration for an approved application release.
@@ -97,8 +97,8 @@ export type ApplicationGraph = Message<"dotcore.catalog.v1.ApplicationGraph"> &
primaryEndpoint?: ApplicationPrimaryEndpoint;
/**
- * Typed parameters operators may set at deployment creation, in display order; each declares a
- * default and is basic or advanced.
+ * Typed parameters operators may set at deployment creation, in display order; each is basic or
+ * advanced and, except a secret, declares a default.
*
* @generated from field: repeated dotcore.catalog.v1.ParameterDefinition parameters = 3;
*/
@@ -169,7 +169,7 @@ export type ApplicationService = Message<"dotcore.catalog.v1.ApplicationService"
storageBindings: StorageBinding[];
/**
- * Named secret references that require site-specific resolution.
+ * Secret parameters this service receives, each in the form it needs.
*
* @generated from field: repeated dotcore.catalog.v1.ApplicationSecretBinding secret_bindings = 8;
*/
@@ -465,24 +465,38 @@ export const ApplicationInferenceRouteInputSchema: GenMessage & {
/**
- * Stable name used by an application deployment to supply the secret.
+ * Stable binding name, unique within the service.
*
* @generated from field: string binding_name = 1;
*/
bindingName: string;
/**
- * Environment variable that receives the resolved secret value.
+ * Environment variable that receives the secret in its form.
*
* @generated from field: string environment_variable = 2;
*/
environmentVariable: string;
+
+ /**
+ * Graph secret parameter whose value supplies this binding.
+ *
+ * @generated from field: string parameter_id = 3;
+ */
+ parameterId: string;
+
+ /**
+ * Form of the value the service receives.
+ *
+ * @generated from field: dotcore.catalog.v1.ApplicationSecretForm form = 4;
+ */
+ form: ApplicationSecretForm;
};
/**
@@ -554,6 +568,33 @@ export enum ApplicationInferenceRouteProjection {
export const ApplicationInferenceRouteProjectionSchema: GenEnum = /*@__PURE__*/
enumDesc(file_dotcore_catalog_v1_application, 0);
+/**
+ * The form in which a service receives a secret parameter's value.
+ *
+ * @generated from enum dotcore.catalog.v1.ApplicationSecretForm
+ */
+export enum ApplicationSecretForm {
+ /**
+ * Invalid; every secret binding declares its form.
+ *
+ * @generated from enum value: APPLICATION_SECRET_FORM_UNSPECIFIED = 0;
+ */
+ UNSPECIFIED = 0,
+
+ /**
+ * The value exactly as retained.
+ *
+ * @generated from enum value: APPLICATION_SECRET_FORM_PLAIN = 1;
+ */
+ PLAIN = 1,
+}
+
+/**
+ * Describes the enum dotcore.catalog.v1.ApplicationSecretForm.
+ */
+export const ApplicationSecretFormSchema: GenEnum = /*@__PURE__*/
+ enumDesc(file_dotcore_catalog_v1_application, 1);
+
/**
* The condition that a dependency service must meet before a dependent service starts.
*
@@ -586,7 +627,7 @@ export enum ApplicationDependencyCondition {
* Describes the enum dotcore.catalog.v1.ApplicationDependencyCondition.
*/
export const ApplicationDependencyConditionSchema: GenEnum = /*@__PURE__*/
- enumDesc(file_dotcore_catalog_v1_application, 1);
+ enumDesc(file_dotcore_catalog_v1_application, 2);
/**
* The application protocol projected through a declared endpoint.
@@ -613,5 +654,5 @@ export enum ApplicationEndpointProtocol {
* Describes the enum dotcore.catalog.v1.ApplicationEndpointProtocol.
*/
export const ApplicationEndpointProtocolSchema: GenEnum = /*@__PURE__*/
- enumDesc(file_dotcore_catalog_v1_application, 2);
+ enumDesc(file_dotcore_catalog_v1_application, 3);
diff --git a/frontend/src/gen/dotcore/catalog/v1/parameter_pb.ts b/frontend/src/gen/dotcore/catalog/v1/parameter_pb.ts
index 8632aef4..4a87281d 100644
--- a/frontend/src/gen/dotcore/catalog/v1/parameter_pb.ts
+++ b/frontend/src/gen/dotcore/catalog/v1/parameter_pb.ts
@@ -10,7 +10,7 @@ import type { Message } from "@bufbuild/protobuf";
* Describes the file dotcore/catalog/v1/parameter.proto.
*/
export const file_dotcore_catalog_v1_parameter: GenFile = /*@__PURE__*/
- fileDesc("CiJkb3Rjb3JlL2NhdGFsb2cvdjEvcGFyYW1ldGVyLnByb3RvEhJkb3Rjb3JlLmNhdGFsb2cudjEi+gIKE1BhcmFtZXRlckRlZmluaXRpb24SFAoMcGFyYW1ldGVyX2lkGAEgASgJEhQKDGRpc3BsYXlfbmFtZRgCIAEoCRITCgtkZXNjcmlwdGlvbhgDIAEoCRI7Cgp2aXNpYmlsaXR5GAcgASgOMicuZG90Y29yZS5jYXRhbG9nLnYxLlBhcmFtZXRlclZpc2liaWxpdHkSPQoHYm9vbGVhbhgEIAEoCzIqLmRvdGNvcmUuY2F0YWxvZy52MS5Cb29sZWFuUGFyYW1ldGVyU2NoZW1hSAASTgoQdW5zaWduZWRfaW50ZWdlchgFIAEoCzIyLmRvdGNvcmUuY2F0YWxvZy52MS5VbnNpZ25lZEludGVnZXJQYXJhbWV0ZXJTY2hlbWFIABJECgtzdHJpbmdfZW51bRgGIAEoCzItLmRvdGNvcmUuY2F0YWxvZy52MS5TdHJpbmdFbnVtUGFyYW1ldGVyU2NoZW1hSABCEAoOcGFyYW1ldGVyX3R5cGUiRgoWQm9vbGVhblBhcmFtZXRlclNjaGVtYRIaCg1kZWZhdWx0X3ZhbHVlGAEgASgISACIAQFCEAoOX2RlZmF1bHRfdmFsdWUifgoeVW5zaWduZWRJbnRlZ2VyUGFyYW1ldGVyU2NoZW1hEg8KB21pbmltdW0YASABKAQSDwoHbWF4aW11bRgCIAEoBBIMCgR1bml0GAMgASgJEhoKDWRlZmF1bHRfdmFsdWUYBCABKARIAIgBAUIQCg5fZGVmYXVsdF92YWx1ZSJZChlTdHJpbmdFbnVtUGFyYW1ldGVyU2NoZW1hEg4KBnZhbHVlcxgBIAMoCRIaCg1kZWZhdWx0X3ZhbHVlGAIgASgJSACIAQFCEAoOX2RlZmF1bHRfdmFsdWUiRQoPUGFyYW1ldGVyVmFsdWVzEjIKBnZhbHVlcxgBIAMoCzIiLmRvdGNvcmUuY2F0YWxvZy52MS5QYXJhbWV0ZXJWYWx1ZSJwCg5QYXJhbWV0ZXJWYWx1ZRIUCgxwYXJhbWV0ZXJfaWQYASABKAkSEQoHYm9vbGVhbhgCIAEoCEgAEhoKEHVuc2lnbmVkX2ludGVnZXIYAyABKARIABIQCgZzdHJpbmcYBCABKAlIAEIHCgV2YWx1ZSqfAQoTUGFyYW1ldGVyVmlzaWJpbGl0eRIkCiBQQVJBTUVURVJfVklTSUJJTElUWV9VTlNQRUNJRklFRBAAEh4KGlBBUkFNRVRFUl9WSVNJQklMSVRZX0JBU0lDEAESIQodUEFSQU1FVEVSX1ZJU0lCSUxJVFlfQURWQU5DRUQQAhIfChtQQVJBTUVURVJfVklTSUJJTElUWV9ISURERU4QA2IGcHJvdG8z");
+ fileDesc("CiJkb3Rjb3JlL2NhdGFsb2cvdjEvcGFyYW1ldGVyLnByb3RvEhJkb3Rjb3JlLmNhdGFsb2cudjEi8AMKE1BhcmFtZXRlckRlZmluaXRpb24SFAoMcGFyYW1ldGVyX2lkGAEgASgJEhQKDGRpc3BsYXlfbmFtZRgCIAEoCRITCgtkZXNjcmlwdGlvbhgDIAEoCRI7Cgp2aXNpYmlsaXR5GAcgASgOMicuZG90Y29yZS5jYXRhbG9nLnYxLlBhcmFtZXRlclZpc2liaWxpdHkSPQoHYm9vbGVhbhgEIAEoCzIqLmRvdGNvcmUuY2F0YWxvZy52MS5Cb29sZWFuUGFyYW1ldGVyU2NoZW1hSAASTgoQdW5zaWduZWRfaW50ZWdlchgFIAEoCzIyLmRvdGNvcmUuY2F0YWxvZy52MS5VbnNpZ25lZEludGVnZXJQYXJhbWV0ZXJTY2hlbWFIABJECgtzdHJpbmdfZW51bRgGIAEoCzItLmRvdGNvcmUuY2F0YWxvZy52MS5TdHJpbmdFbnVtUGFyYW1ldGVyU2NoZW1hSAASNwoEdGV4dBgIIAEoCzInLmRvdGNvcmUuY2F0YWxvZy52MS5UZXh0UGFyYW1ldGVyU2NoZW1hSAASOwoGc2VjcmV0GAkgASgLMikuZG90Y29yZS5jYXRhbG9nLnYxLlNlY3JldFBhcmFtZXRlclNjaGVtYUgAQhAKDnBhcmFtZXRlcl90eXBlIkYKFkJvb2xlYW5QYXJhbWV0ZXJTY2hlbWESGgoNZGVmYXVsdF92YWx1ZRgBIAEoCEgAiAEBQhAKDl9kZWZhdWx0X3ZhbHVlIn4KHlVuc2lnbmVkSW50ZWdlclBhcmFtZXRlclNjaGVtYRIPCgdtaW5pbXVtGAEgASgEEg8KB21heGltdW0YAiABKAQSDAoEdW5pdBgDIAEoCRIaCg1kZWZhdWx0X3ZhbHVlGAQgASgESACIAQFCEAoOX2RlZmF1bHRfdmFsdWUiWQoZU3RyaW5nRW51bVBhcmFtZXRlclNjaGVtYRIOCgZ2YWx1ZXMYASADKAkSGgoNZGVmYXVsdF92YWx1ZRgCIAEoCUgAiAEBQhAKDl9kZWZhdWx0X3ZhbHVlInMKE1RleHRQYXJhbWV0ZXJTY2hlbWESFgoObWluaW11bV9sZW5ndGgYASABKA0SFgoObWF4aW11bV9sZW5ndGgYAiABKA0SGgoNZGVmYXVsdF92YWx1ZRgDIAEoCUgAiAEBQhAKDl9kZWZhdWx0X3ZhbHVlIkkKFVNlY3JldFBhcmFtZXRlclNjaGVtYRIYChBnZW5lcmF0ZWRfbGVuZ3RoGAEgASgNEhYKDm1pbmltdW1fbGVuZ3RoGAIgASgNIkUKD1BhcmFtZXRlclZhbHVlcxIyCgZ2YWx1ZXMYASADKAsyIi5kb3Rjb3JlLmNhdGFsb2cudjEuUGFyYW1ldGVyVmFsdWUicAoOUGFyYW1ldGVyVmFsdWUSFAoMcGFyYW1ldGVyX2lkGAEgASgJEhEKB2Jvb2xlYW4YAiABKAhIABIaChB1bnNpZ25lZF9pbnRlZ2VyGAMgASgESAASEAoGc3RyaW5nGAQgASgJSABCBwoFdmFsdWUqnwEKE1BhcmFtZXRlclZpc2liaWxpdHkSJAogUEFSQU1FVEVSX1ZJU0lCSUxJVFlfVU5TUEVDSUZJRUQQABIeChpQQVJBTUVURVJfVklTSUJJTElUWV9CQVNJQxABEiEKHVBBUkFNRVRFUl9WSVNJQklMSVRZX0FEVkFOQ0VEEAISHwobUEFSQU1FVEVSX1ZJU0lCSUxJVFlfSElEREVOEANiBnByb3RvMw");
/**
* Defines one owner-neutral typed parameter: its identity, label, guidance, visibility and value
@@ -75,6 +75,22 @@ export type ParameterDefinition = Message<"dotcore.catalog.v1.ParameterDefinitio
*/
value: StringEnumParameterSchema;
case: "stringEnum";
+ } | {
+ /**
+ * Free text constraints for this parameter.
+ *
+ * @generated from field: dotcore.catalog.v1.TextParameterSchema text = 8;
+ */
+ value: TextParameterSchema;
+ case: "text";
+ } | {
+ /**
+ * Secret constraints for this parameter; its value is generated when none is supplied.
+ *
+ * @generated from field: dotcore.catalog.v1.SecretParameterSchema secret = 9;
+ */
+ value: SecretParameterSchema;
+ case: "secret";
} | { case: undefined; value?: undefined };
};
@@ -176,6 +192,71 @@ export type StringEnumParameterSchema = Message<"dotcore.catalog.v1.StringEnumPa
export const StringEnumParameterSchemaSchema: GenMessage = /*@__PURE__*/
messageDesc(file_dotcore_catalog_v1_parameter, 3);
+/**
+ * Defines a free text parameter: any text within its length bounds, without `=` or control
+ * characters.
+ *
+ * @generated from message dotcore.catalog.v1.TextParameterSchema
+ */
+export type TextParameterSchema = Message<"dotcore.catalog.v1.TextParameterSchema"> & {
+ /**
+ * Inclusive minimum length in characters.
+ *
+ * @generated from field: uint32 minimum_length = 1;
+ */
+ minimumLength: number;
+
+ /**
+ * Inclusive maximum length in characters, at least 1.
+ *
+ * @generated from field: uint32 maximum_length = 2;
+ */
+ maximumLength: number;
+
+ /**
+ * Value used when the caller supplies none, within the length bounds.
+ *
+ * @generated from field: optional string default_value = 3;
+ */
+ defaultValue?: string;
+};
+
+/**
+ * Describes the message dotcore.catalog.v1.TextParameterSchema.
+ * Use `create(TextParameterSchemaSchema)` to create a new message.
+ */
+export const TextParameterSchemaSchema: GenMessage = /*@__PURE__*/
+ messageDesc(file_dotcore_catalog_v1_parameter, 4);
+
+/**
+ * Defines a secret parameter: never defaulted, generated at random from letters and digits when the
+ * caller supplies none, and masked by clients until asked.
+ *
+ * @generated from message dotcore.catalog.v1.SecretParameterSchema
+ */
+export type SecretParameterSchema = Message<"dotcore.catalog.v1.SecretParameterSchema"> & {
+ /**
+ * Length in characters of a generated value, at least the minimum length.
+ *
+ * @generated from field: uint32 generated_length = 1;
+ */
+ generatedLength: number;
+
+ /**
+ * Inclusive minimum length in characters of a supplied value, at least 1.
+ *
+ * @generated from field: uint32 minimum_length = 2;
+ */
+ minimumLength: number;
+};
+
+/**
+ * Describes the message dotcore.catalog.v1.SecretParameterSchema.
+ * Use `create(SecretParameterSchemaSchema)` to create a new message.
+ */
+export const SecretParameterSchemaSchema: GenMessage = /*@__PURE__*/
+ messageDesc(file_dotcore_catalog_v1_parameter, 5);
+
/**
* Supplies a set of exact typed parameter values.
*
@@ -195,7 +276,7 @@ export type ParameterValues = Message<"dotcore.catalog.v1.ParameterValues"> & {
* Use `create(ParameterValuesSchema)` to create a new message.
*/
export const ParameterValuesSchema: GenMessage = /*@__PURE__*/
- messageDesc(file_dotcore_catalog_v1_parameter, 4);
+ messageDesc(file_dotcore_catalog_v1_parameter, 6);
/**
* Supplies one exact typed parameter value.
@@ -231,7 +312,7 @@ export type ParameterValue = Message<"dotcore.catalog.v1.ParameterValue"> & {
case: "unsignedInteger";
} | {
/**
- * Exact string-enum value.
+ * Exact string-enum, text or secret value.
*
* @generated from field: string string = 4;
*/
@@ -245,7 +326,7 @@ export type ParameterValue = Message<"dotcore.catalog.v1.ParameterValue"> & {
* Use `create(ParameterValueSchema)` to create a new message.
*/
export const ParameterValueSchema: GenMessage = /*@__PURE__*/
- messageDesc(file_dotcore_catalog_v1_parameter, 5);
+ messageDesc(file_dotcore_catalog_v1_parameter, 7);
/**
* Where the console shows a parameter.
diff --git a/proto/dotcore/application/v1/deployment.proto b/proto/dotcore/application/v1/deployment.proto
index 79c96696..a433ce87 100644
--- a/proto/dotcore/application/v1/deployment.proto
+++ b/proto/dotcore/application/v1/deployment.proto
@@ -33,6 +33,9 @@ message ApplicationDeploymentDeclaration {
// Resolved non-secret configuration required to reproduce this deployment.
repeated ApplicationConfigurationAttachment configuration_attachments = 7;
+ // Secret parameter values in the form each secret binding needs, computed once at creation.
+ repeated ApplicationSecretAttachment secret_attachments = 12;
+
// Optional stable inference route selected for this deployment.
optional string inference_route_id = 8;
@@ -107,9 +110,13 @@ message ResolvedApplicationDeploymentSnapshot {
// Immutable runtime-neutral graph retained from the catalog application.
dotcore.catalog.v1.ApplicationGraph graph = 4;
- // Complete parameter values resolved at creation, defaults filled, in graph declaration order.
+ // Complete parameter values resolved at creation, defaults filled and missing secrets generated,
+ // in graph declaration order.
repeated dotcore.catalog.v1.ParameterValue parameter_values = 10;
+ // Secret parameters whose value was generated at creation rather than supplied.
+ repeated string generated_parameter_ids = 11;
+
// The exact placement selected for this application deployment.
dotcore.deployment.v1.ExactPlacement placement = 5;
@@ -171,6 +178,21 @@ message ApplicationConfigurationAttachment {
string value = 4;
}
+// One secret parameter value in the form a service's secret binding needs.
+message ApplicationSecretAttachment {
+ // Name of the service that receives this attachment.
+ string service_name = 1;
+
+ // Secret binding name from the immutable application snapshot.
+ string binding_name = 2;
+
+ // Environment variable receiving the value.
+ string environment_variable = 3;
+
+ // Secret value in the binding's form, supplied only to the runtime launch.
+ string value = 4;
+}
+
// The accepted desired presence of an application deployment.
enum ApplicationDeploymentDesiredState {
// The application deployment desired state unspecified value.
diff --git a/proto/dotcore/catalog/v1/application.proto b/proto/dotcore/catalog/v1/application.proto
index 5ed0c51f..aa1fe140 100644
--- a/proto/dotcore/catalog/v1/application.proto
+++ b/proto/dotcore/catalog/v1/application.proto
@@ -36,8 +36,8 @@ message ApplicationGraph {
// The one service port that can be projected as the primary endpoint.
ApplicationPrimaryEndpoint primary_endpoint = 2;
- // Typed parameters operators may set at deployment creation, in display order; each declares a
- // default and is basic or advanced.
+ // Typed parameters operators may set at deployment creation, in display order; each is basic or
+ // advanced and, except a secret, declares a default.
repeated ParameterDefinition parameters = 3;
}
@@ -65,7 +65,7 @@ message ApplicationService {
// Writable storage the apps controller attaches to this service.
repeated StorageBinding storage_bindings = 7;
- // Named secret references that require site-specific resolution.
+ // Secret parameters this service receives, each in the form it needs.
repeated ApplicationSecretBinding secret_bindings = 8;
// Conditions on named services that must hold before this service starts.
@@ -171,13 +171,19 @@ enum ApplicationInferenceRouteProjection {
APPLICATION_INFERENCE_ROUTE_PROJECTION_ROUTE_NAME = 1;
}
-// Declares a named unresolved secret reference and its environment destination.
+// Declares one secret parameter's environment destination and the form the service receives.
message ApplicationSecretBinding {
- // Stable name used by an application deployment to supply the secret.
+ // Stable binding name, unique within the service.
string binding_name = 1;
- // Environment variable that receives the resolved secret value.
+ // Environment variable that receives the secret in its form.
string environment_variable = 2;
+
+ // Graph secret parameter whose value supplies this binding.
+ string parameter_id = 3;
+
+ // Form of the value the service receives.
+ ApplicationSecretForm form = 4;
}
// Selects the one declared service port that can become the primary endpoint.
@@ -192,6 +198,15 @@ message ApplicationPrimaryEndpoint {
ApplicationEndpointProtocol protocol = 3;
}
+// The form in which a service receives a secret parameter's value.
+enum ApplicationSecretForm {
+ // Invalid; every secret binding declares its form.
+ APPLICATION_SECRET_FORM_UNSPECIFIED = 0;
+
+ // The value exactly as retained.
+ APPLICATION_SECRET_FORM_PLAIN = 1;
+}
+
// The condition that a dependency service must meet before a dependent service starts.
enum ApplicationDependencyCondition {
// The application dependency condition unspecified value.
diff --git a/proto/dotcore/catalog/v1/parameter.proto b/proto/dotcore/catalog/v1/parameter.proto
index 70a466be..7321e675 100644
--- a/proto/dotcore/catalog/v1/parameter.proto
+++ b/proto/dotcore/catalog/v1/parameter.proto
@@ -27,6 +27,12 @@ message ParameterDefinition {
// Closed string choices for this parameter.
StringEnumParameterSchema string_enum = 6;
+
+ // Free text constraints for this parameter.
+ TextParameterSchema text = 8;
+
+ // Secret constraints for this parameter; its value is generated when none is supplied.
+ SecretParameterSchema secret = 9;
}
}
@@ -60,6 +66,29 @@ message StringEnumParameterSchema {
optional string default_value = 2;
}
+// Defines a free text parameter: any text within its length bounds, without `=` or control
+// characters.
+message TextParameterSchema {
+ // Inclusive minimum length in characters.
+ uint32 minimum_length = 1;
+
+ // Inclusive maximum length in characters, at least 1.
+ uint32 maximum_length = 2;
+
+ // Value used when the caller supplies none, within the length bounds.
+ optional string default_value = 3;
+}
+
+// Defines a secret parameter: never defaulted, generated at random from letters and digits when the
+// caller supplies none, and masked by clients until asked.
+message SecretParameterSchema {
+ // Length in characters of a generated value, at least the minimum length.
+ uint32 generated_length = 1;
+
+ // Inclusive minimum length in characters of a supplied value, at least 1.
+ uint32 minimum_length = 2;
+}
+
// Supplies a set of exact typed parameter values.
message ParameterValues {
// Exact values keyed by stable parameter identity.
@@ -78,7 +107,7 @@ message ParameterValue {
// Exact unsigned-integer value.
uint64 unsigned_integer = 3;
- // Exact string-enum value.
+ // Exact string-enum, text or secret value.
string string = 4;
}
}
From 2f7e14204f42982a324e0cc611189b6295dd7a66 Mon Sep 17 00:00:00 2001
From: Andre-Philippe Paquet
Date: Thu, 12 Nov 2026 18:11:45 -0500
Subject: [PATCH 16/40] unreviewed: feat(backend): generate and retain secret
app parameters
---
backend/catalog/src/lib.rs | 2 +
backend/catalog/src/sensitive_names.rs | 73 ++++
backend/catalog/src/typed_parameters.rs | 157 ++++++-
backend/catalog/src/validation/application.rs | 209 ++++++++-
.../catalog/src/validation/engine_release.rs | 46 +-
backend/catalog/src/validation/graph.rs | 42 +-
backend/catalog/src/validation/service.rs | 14 +-
.../core/src/application/controller/launch.rs | 93 +++-
.../core/src/application/controller/logs.rs | 10 +-
.../core/src/application/controller/mod.rs | 4 +
.../core/src/application/controller/node.rs | 21 +-
.../src/application/controller/operations.rs | 14 +-
.../core/src/application/controller/owner.rs | 1 +
.../src/application/controller/parameters.rs | 137 +++++-
.../application/controller/test_support.rs | 95 ++++
.../core/src/application/controller/tests.rs | 277 +++++++++++-
backend/core/src/application/mod.rs | 3 +-
backend/core/src/application/repository.rs | 190 +++++++-
backend/core/src/composition/mod.rs | 3 +-
backend/core/src/management_logs.rs | 107 ++---
backend/core/src/model/controller/logs.rs | 9 +-
.../model/controller/reconciliation/mod.rs | 2 +-
.../core/src/model/engine/vllm/declaration.rs | 7 +-
.../gated/docker_application_integration.rs | 406 ++++++++++++++++++
backend/proto/tests/application_contract.rs | 7 +
backend/proto/tests/model_contract.rs | 2 +
backend/proto/tests/site_contract.rs | 2 +
backend/runtime/src/workload_group.rs | 26 +-
28 files changed, 1785 insertions(+), 174 deletions(-)
create mode 100644 backend/catalog/src/sensitive_names.rs
diff --git a/backend/catalog/src/lib.rs b/backend/catalog/src/lib.rs
index 52b86e36..d510413c 100644
--- a/backend/catalog/src/lib.rs
+++ b/backend/catalog/src/lib.rs
@@ -25,6 +25,7 @@ use dotcore_proto::dotcore::model::v1::ResolvedLocalModelDeploymentSnapshot;
pub mod options;
mod selector;
+mod sensitive_names;
pub mod service;
pub mod shared_parameters;
mod sizing;
@@ -35,6 +36,7 @@ pub mod typed_parameters;
mod validation;
pub use selector::{SelectorRelation, selector_relation};
+pub use sensitive_names::is_sensitive_name;
pub use sizing::{
ACCELERATOR_MEMORY_RESOURCE, HOST_MEMORY_RESOURCE, ScalarResourceEstimate, SizingError,
SizingFitModel,
diff --git a/backend/catalog/src/sensitive_names.rs b/backend/catalog/src/sensitive_names.rs
new file mode 100644
index 00000000..229edda4
--- /dev/null
+++ b/backend/catalog/src/sensitive_names.rs
@@ -0,0 +1,73 @@
+//! Recognizes names that mark a value as secret, such as an environment variable or a launch flag.
+//!
+//! A name is split into words on `-` and `_` and compared without regard to case, so a sensitive
+//! word matches only as a whole word: an API token variable matches, a tokenizer path does not.
+//! Callers use it to mask values they record and to refuse literal values where a secret belongs.
+
+/// Words, or runs of words joined by `_`, whose presence marks a name as carrying a secret.
+// `TOKENS` is left out: engine flags such as a batch's maximum tokens count tokens and carry no
+// secret.
+const SENSITIVE_NAMES: [&str; 9] = [
+ "TOKEN",
+ "SECRET",
+ "SECRETS",
+ "PASSWORD",
+ "PASSWORDS",
+ "API_KEY",
+ "API_KEYS",
+ "CREDENTIAL",
+ "CREDENTIALS",
+];
+
+/// Returns whether `name` contains a sensitive name as whole words.
+pub fn is_sensitive_name(name: &str) -> bool {
+ let words = name
+ .split(['-', '_'])
+ .map(str::to_ascii_uppercase)
+ .collect::>();
+ SENSITIVE_NAMES.iter().any(|sensitive| {
+ let sensitive_words = sensitive.split('_').collect::>();
+ words.windows(sensitive_words.len()).any(|window| {
+ window
+ .iter()
+ .map(String::as_str)
+ .eq(sensitive_words.iter().copied())
+ })
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::is_sensitive_name;
+
+ #[test]
+ fn sensitive_names_match_whole_words_in_any_case() {
+ for name in [
+ "hf_token",
+ "HF_TOKEN",
+ "MY_API_KEY_FILE",
+ "api-key",
+ "N8N_BASIC_AUTH_PASSWORD",
+ "CLIENT_SECRET",
+ "aws-credential",
+ "GOOGLE_APPLICATION_CREDENTIALS",
+ "OPENAI_API_KEYS",
+ "DB_PASSWORDS",
+ "app-secrets",
+ ] {
+ assert!(is_sensitive_name(name), "{name}");
+ }
+ for name in [
+ "TOKENIZER_PATH",
+ "max-num-batched-tokens",
+ "CUDA_VISIBLE_DEVICES",
+ "API_URL",
+ "KEY",
+ "PASSWORDLESS",
+ "KEYS",
+ "",
+ ] {
+ assert!(!is_sensitive_name(name), "{name}");
+ }
+ }
+}
diff --git a/backend/catalog/src/typed_parameters.rs b/backend/catalog/src/typed_parameters.rs
index 70b725fd..e6c91e3c 100644
--- a/backend/catalog/src/typed_parameters.rs
+++ b/backend/catalog/src/typed_parameters.rs
@@ -64,6 +64,40 @@ pub(crate) fn validate_parameter_definition(
));
}
}
+ Some(ParameterType::Text(schema)) => {
+ if schema.maximum_length == 0 {
+ errors.push(anyhow!(
+ "parameter {id:?} maximum length must be at least 1"
+ ));
+ }
+ if schema.minimum_length > schema.maximum_length {
+ errors.push(anyhow!(
+ "parameter {id:?} minimum length must not exceed its maximum length"
+ ));
+ }
+ if schema.maximum_length > MAXIMUM_FREE_TEXT_LENGTH {
+ errors.push(anyhow!(
+ "parameter {id:?} maximum length must not exceed {MAXIMUM_FREE_TEXT_LENGTH}"
+ ));
+ }
+ }
+ Some(ParameterType::Secret(schema)) => {
+ if schema.minimum_length == 0 {
+ errors.push(anyhow!(
+ "parameter {id:?} minimum length must be at least 1"
+ ));
+ }
+ if schema.minimum_length > schema.generated_length {
+ errors.push(anyhow!(
+ "parameter {id:?} minimum length must not exceed its generated length"
+ ));
+ }
+ if schema.generated_length > MAXIMUM_FREE_TEXT_LENGTH {
+ errors.push(anyhow!(
+ "parameter {id:?} generated length must not exceed {MAXIMUM_FREE_TEXT_LENGTH}"
+ ));
+ }
+ }
None => errors.push(anyhow!("parameter {id:?} requires a type")),
}
if errors.is_empty()
@@ -75,16 +109,24 @@ pub(crate) fn validate_parameter_definition(
errors
}
-/// Returns the definition's declared default, if any.
+/// Returns the definition's declared default, if any. A secret never has one.
pub fn default_value(definition: &ParameterDefinition) -> Option {
match definition.parameter_type.as_ref()? {
ParameterType::Boolean(schema) => schema.default_value.map(Value::Boolean),
ParameterType::UnsignedInteger(schema) => schema.default_value.map(Value::UnsignedInteger),
ParameterType::StringEnum(schema) => schema.default_value.clone().map(Value::String),
+ ParameterType::Text(schema) => schema.default_value.clone().map(Value::String),
+ ParameterType::Secret(_) => None,
}
}
-/// Checks that a value has the definition's type and lies within its range or choices.
+/// Returns whether the definition declares a secret.
+pub fn is_secret(definition: &ParameterDefinition) -> bool {
+ matches!(definition.parameter_type, Some(ParameterType::Secret(_)))
+}
+
+/// Checks that a value has the definition's type and lies within its range, choices or length
+/// bounds. Errors never quote a text or secret value.
pub fn check_value(definition: &ParameterDefinition, value: &Value) -> anyhow::Result<()> {
match (definition.parameter_type.as_ref(), value) {
(Some(ParameterType::Boolean(_)), Value::Boolean(_)) => Ok(()),
@@ -106,6 +148,12 @@ pub fn check_value(definition: &ParameterDefinition, value: &Value) -> anyhow::R
Err(anyhow!("{value:?} is not one of {:?}", schema.values))
}
}
+ (Some(ParameterType::Text(schema)), Value::String(value)) => {
+ check_free_text(value, schema.minimum_length, schema.maximum_length)
+ }
+ (Some(ParameterType::Secret(schema)), Value::String(value)) => {
+ check_free_text(value, schema.minimum_length, MAXIMUM_FREE_TEXT_LENGTH)
+ }
_ => Err(anyhow!("value type does not match the parameter type")),
}
}
@@ -129,10 +177,33 @@ pub fn canonical_value_text(values: &[ParameterValue], parameter_id: &str) -> Op
.map(canonical_text)
}
+/// Longest text or secret value in characters, which keeps every value a reasonable environment
+/// value; a secret declares no maximum of its own.
+const MAXIMUM_FREE_TEXT_LENGTH: u32 = 1024;
+
+/// Checks a text or secret value's length in characters and that it is safe as an environment
+/// value: no `=` and no control characters.
+fn check_free_text(value: &str, minimum: u32, maximum: u32) -> anyhow::Result<()> {
+ if value.contains('=') || value.chars().any(char::is_control) {
+ return Err(anyhow!("it contains `=` or a control character"));
+ }
+ let length = value.chars().count();
+ if length < minimum as usize {
+ return Err(anyhow!(
+ "it is shorter than the minimum length of {minimum}"
+ ));
+ }
+ if length > maximum as usize {
+ return Err(anyhow!("it is longer than the maximum length of {maximum}"));
+ }
+ Ok(())
+}
+
#[cfg(test)]
mod tests {
use dotcore_proto::dotcore::catalog::v1::{
- BooleanParameterSchema, StringEnumParameterSchema, UnsignedIntegerParameterSchema,
+ BooleanParameterSchema, SecretParameterSchema, StringEnumParameterSchema,
+ TextParameterSchema, UnsignedIntegerParameterSchema,
};
use super::*;
@@ -182,6 +253,86 @@ mod tests {
);
}
+ #[test]
+ fn check_value_bounds_text_and_secrets_without_quoting_them() {
+ let text = definition(ParameterType::Text(TextParameterSchema {
+ minimum_length: 2,
+ maximum_length: 4,
+ default_value: None,
+ }));
+ let secret = definition(ParameterType::Secret(SecretParameterSchema {
+ generated_length: 32,
+ minimum_length: 3,
+ }));
+ let check = |definition: &ParameterDefinition, value: &str| {
+ check_value(definition, &Value::String(value.to_owned()))
+ .map_err(|error| error.to_string())
+ };
+
+ assert_eq!(check(&text, "ab"), Ok(()));
+ assert_eq!(check(&text, "éèêë"), Ok(()));
+ assert_eq!(
+ check(&text, "a"),
+ Err("it is shorter than the minimum length of 2".to_owned())
+ );
+ assert_eq!(
+ check(&text, "abcde"),
+ Err("it is longer than the maximum length of 4".to_owned())
+ );
+ assert_eq!(check(&secret, &"s".repeat(1024)), Ok(()));
+ assert_eq!(
+ check(&secret, &"s".repeat(1025)),
+ Err("it is longer than the maximum length of 1024".to_owned())
+ );
+ assert_eq!(
+ check(&secret, ""),
+ Err("it is shorter than the minimum length of 3".to_owned())
+ );
+ for unsafe_value in ["a=bc", "ab\tc", "ab\nc"] {
+ assert_eq!(
+ check(&secret, unsafe_value),
+ Err("it contains `=` or a control character".to_owned())
+ );
+ }
+ assert_eq!(
+ check_value(&secret, &Value::Boolean(true))
+ .unwrap_err()
+ .to_string(),
+ "value type does not match the parameter type"
+ );
+ assert_eq!(default_value(&secret), None);
+ assert!(is_secret(&secret));
+ assert!(!is_secret(&text));
+ }
+
+ #[test]
+ fn definition_validation_caps_text_and_generated_secret_lengths() {
+ assert_errors(
+ &definition(ParameterType::Text(TextParameterSchema {
+ minimum_length: 0,
+ maximum_length: 1025,
+ default_value: None,
+ })),
+ &["parameter \"mode\" maximum length must not exceed 1024"],
+ );
+ assert_errors(
+ &definition(ParameterType::Secret(SecretParameterSchema {
+ generated_length: 1025,
+ minimum_length: 12,
+ })),
+ &["parameter \"mode\" generated length must not exceed 1024"],
+ );
+ assert!(
+ validate_parameter_definition(&definition(ParameterType::Secret(
+ SecretParameterSchema {
+ generated_length: 1024,
+ minimum_length: 12,
+ }
+ )))
+ .is_empty()
+ );
+ }
+
#[test]
fn definition_validation_rejects_invalid_shapes() {
let mut bad_id = definition(ParameterType::Boolean(BooleanParameterSchema {
diff --git a/backend/catalog/src/validation/application.rs b/backend/catalog/src/validation/application.rs
index ed845117..66d1ea75 100644
--- a/backend/catalog/src/validation/application.rs
+++ b/backend/catalog/src/validation/application.rs
@@ -60,10 +60,11 @@ mod tests {
ApplicationDeploymentInput, ApplicationEndpointProtocol, ApplicationGraph,
ApplicationHttpReadiness, ApplicationInferenceRouteInput,
ApplicationInferenceRouteProjection, ApplicationPrimaryEndpoint, ApplicationSecretBinding,
- ApplicationService, ApplicationServiceDependency, ApplicationServicePort,
- ApplicationServiceReadiness, BooleanParameterSchema, NixArtifact, NixDockerImage,
- NixSystemStorePath, OciPlatform, ParameterDefinition, ParameterVisibility, StorageBinding,
- StorageKind, StorageScope, StringEnumParameterSchema, UnsignedIntegerParameterSchema,
+ ApplicationSecretForm, ApplicationService, ApplicationServiceDependency,
+ ApplicationServicePort, ApplicationServiceReadiness, BooleanParameterSchema, NixArtifact,
+ NixDockerImage, NixSystemStorePath, OciPlatform, ParameterDefinition, ParameterVisibility,
+ SecretParameterSchema, StorageBinding, StorageKind, StorageScope,
+ StringEnumParameterSchema, TextParameterSchema, UnsignedIntegerParameterSchema,
application_configuration_binding,
application_service_artifact::Kind as ServiceArtifactKind, nix_artifact::Provides,
parameter_definition::ParameterType,
@@ -442,6 +443,8 @@ mod tests {
web.secret_bindings.push(ApplicationSecretBinding {
binding_name: "secret".to_owned(),
environment_variable: "DATABASE_URL".to_owned(),
+ parameter_id: "password".to_owned(),
+ form: ApplicationSecretForm::Plain as i32,
});
let errors = validate_application(&application)
@@ -634,7 +637,7 @@ mod tests {
],
&["mode"],
),
- "application parameter \"debug\" is not used by any configuration binding",
+ "application parameter \"debug\" is not used by any binding",
),
(
parameterized_application(
@@ -669,6 +672,172 @@ mod tests {
}
}
+ #[test]
+ fn application_validation_accepts_text_and_secret_parameters() {
+ let mut application = parameterized_application(
+ vec![
+ parameter("username", text(1, 64, Some("admin"))),
+ parameter("password", secret(32, 12)),
+ ],
+ &["username"],
+ );
+ bind_secret(&mut application, "password", "password");
+
+ assert!(validate_application(&application).is_empty());
+ }
+
+ #[test]
+ fn application_validation_rejects_invalid_text_and_secret_parameters() {
+ let with_secret_binding = |parameters, bound_ids: &[&str], secret_id: &str| {
+ let mut application = parameterized_application(parameters, bound_ids);
+ bind_secret(&mut application, "password", secret_id);
+ application
+ };
+ let cases = [
+ (
+ with_secret_binding(vec![parameter("password", secret(8, 12))], &[], "password"),
+ "parameter \"password\" minimum length must not exceed its generated length",
+ ),
+ (
+ with_secret_binding(vec![parameter("password", secret(32, 0))], &[], "password"),
+ "parameter \"password\" minimum length must be at least 1",
+ ),
+ (
+ parameterized_application(
+ vec![parameter("username", text(1, 4, Some("administrator")))],
+ &["username"],
+ ),
+ "parameter \"username\" default is invalid: it is longer than the maximum length of 4",
+ ),
+ (
+ parameterized_application(
+ vec![parameter("username", text(6, 64, Some("admin")))],
+ &["username"],
+ ),
+ "parameter \"username\" default is invalid: it is shorter than the minimum length of 6",
+ ),
+ (
+ parameterized_application(
+ vec![parameter("username", text(1, 64, Some("a=b")))],
+ &["username"],
+ ),
+ "parameter \"username\" default is invalid: it contains `=` or a control character",
+ ),
+ (
+ parameterized_application(
+ vec![parameter("username", text(0, 0, Some("")))],
+ &["username"],
+ ),
+ "parameter \"username\" maximum length must be at least 1",
+ ),
+ (
+ parameterized_application(
+ vec![parameter("username", text(1, 64, None))],
+ &["username"],
+ ),
+ "application parameter \"username\" requires a default",
+ ),
+ (
+ with_secret_binding(vec![parameter("password", secret(32, 12))], &[], "missing"),
+ "application secret binding \"password\" on service \"web\" references missing parameter \"missing\"",
+ ),
+ (
+ with_secret_binding(
+ vec![
+ parameter("username", text(1, 64, Some("admin"))),
+ parameter("password", secret(32, 12)),
+ ],
+ &["password"],
+ "username",
+ ),
+ "application configuration binding \"password\" on service \"web\" references secret parameter \"password\"; secrets use secret bindings",
+ ),
+ ];
+
+ for (application, expected) in cases {
+ let errors = validate_application(&application)
+ .into_iter()
+ .map(|error| error.to_string())
+ .collect::>();
+ assert!(
+ errors.iter().any(|error| error == expected),
+ "expected {expected:?} in {errors:?}"
+ );
+ }
+
+ let not_secret = with_secret_binding(
+ vec![parameter("username", text(1, 64, Some("admin")))],
+ &[],
+ "username",
+ );
+ assert_eq!(
+ validate_application(¬_secret)
+ .into_iter()
+ .map(|error| error.to_string())
+ .collect::>(),
+ [
+ "application secret binding \"password\" on service \"web\" references parameter \"username\", which is not a secret"
+ ]
+ );
+
+ let mut unknown_form =
+ parameterized_application(vec![parameter("password", secret(32, 12))], &[]);
+ bind_secret(&mut unknown_form, "password", "password");
+ unknown_form.graph.as_mut().expect("graph").services[1].secret_bindings[0].form =
+ ApplicationSecretForm::Unspecified as i32;
+ assert_eq!(
+ validate_application(&unknown_form)
+ .into_iter()
+ .map(|error| error.to_string())
+ .collect::>(),
+ ["application secret binding \"password\" requires a known form"]
+ );
+ }
+
+ #[test]
+ fn application_yaml_declares_secret_parameters_without_a_default() {
+ let graph = dotcore_proto::yaml::from_str::(
+ r#"parameters:
+ - parameterId: "password"
+ displayName: "password"
+ visibility: "PARAMETER_VISIBILITY_BASIC"
+ secret:
+ generatedLength: 32
+ minimumLength: 12
+services:
+ - secretBindings:
+ - bindingName: "password"
+ environmentVariable: "PASSWORD"
+ parameterId: "password"
+ form: "APPLICATION_SECRET_FORM_PLAIN"
+"#,
+ )
+ .expect("secret graph decodes");
+
+ assert_eq!(graph.parameters, [parameter("password", secret(32, 12))]);
+ assert_eq!(
+ graph.services[0].secret_bindings,
+ [ApplicationSecretBinding {
+ binding_name: "password".to_owned(),
+ environment_variable: "PASSWORD".to_owned(),
+ parameter_id: "password".to_owned(),
+ form: ApplicationSecretForm::Plain as i32,
+ }]
+ );
+ dotcore_proto::yaml::from_str::(
+ r#"parameters:
+ - parameterId: "password"
+ displayName: "password"
+ visibility: "PARAMETER_VISIBILITY_BASIC"
+ secret:
+ generatedLength: 32
+ minimumLength: 12
+ defaultValue: "dotcore"
+"#,
+ )
+ .expect_err("a secret parameter cannot declare a default");
+ }
+
#[test]
fn application_validation_rejects_a_parameter_binding_with_a_route_input() {
let mut application = parameterized_application(
@@ -789,6 +958,17 @@ services:
application
}
+ fn bind_secret(application: &mut Application, binding_name: &str, parameter_id: &str) {
+ application.graph.as_mut().expect("graph").services[1]
+ .secret_bindings
+ .push(ApplicationSecretBinding {
+ binding_name: binding_name.to_owned(),
+ environment_variable: binding_name.to_uppercase(),
+ parameter_id: parameter_id.to_owned(),
+ form: ApplicationSecretForm::Plain as i32,
+ });
+ }
+
fn parameter(id: &str, parameter_type: ParameterType) -> ParameterDefinition {
parameter_with_visibility(id, parameter_type, ParameterVisibility::Basic)
}
@@ -820,6 +1000,25 @@ services:
})
}
+ fn text(
+ minimum_length: u32,
+ maximum_length: u32,
+ default_value: Option<&str>,
+ ) -> ParameterType {
+ ParameterType::Text(TextParameterSchema {
+ minimum_length,
+ maximum_length,
+ default_value: default_value.map(str::to_owned),
+ })
+ }
+
+ fn secret(generated_length: u32, minimum_length: u32) -> ParameterType {
+ ParameterType::Secret(SecretParameterSchema {
+ generated_length,
+ minimum_length,
+ })
+ }
+
fn string_enum(values: &[&str], default_value: Option<&str>) -> ParameterType {
ParameterType::StringEnum(StringEnumParameterSchema {
values: values.iter().map(|value| (*value).to_owned()).collect(),
diff --git a/backend/catalog/src/validation/engine_release.rs b/backend/catalog/src/validation/engine_release.rs
index 0634824d..bd9399fd 100644
--- a/backend/catalog/src/validation/engine_release.rs
+++ b/backend/catalog/src/validation/engine_release.rs
@@ -9,16 +9,15 @@ use std::collections::BTreeSet;
use dotcore_domain::{Accelerator, valid_id};
use dotcore_proto::dotcore::catalog::v1::{
- EngineArtifact, EngineParameterSchema, EngineRelease, ParameterDefinition, ParameterVisibility,
- Selector, engine_artifact::Kind as EngineArtifactKind, parameter_definition::ParameterType,
+ EngineArtifact, EngineParameterSchema, EngineRelease, ParameterDefinition, Selector,
+ engine_artifact::Kind as EngineArtifactKind, parameter_definition::ParameterType,
};
use super::common::{valid_selector_memory_architecture, validate_oci_reference};
use super::nix::{AcceptedProvides, nix_systems, validate_nix_artifact};
use super::storage::{StorageOwner, validate_storage_bindings};
use crate::{
- OciArtifactPolicy,
- shared_parameters::{in_main_view, validate_shared_parameters},
+ OciArtifactPolicy, shared_parameters::validate_shared_parameters,
typed_parameters::validate_parameter_definition as validate_shared_definition,
};
@@ -234,8 +233,7 @@ fn validate_parameter_schema(
}
/// Validates one engine parameter definition: the shared definition rules, a unique id, and the
-/// engine owner rules that parameters are booleans or unsigned integers with a unit, and that only
-/// the context parameters are basic.
+/// engine owner rule that parameters are booleans or unsigned integers with a unit.
fn validate_parameter_definition(
definition: &ParameterDefinition,
ids: &mut BTreeSet,
@@ -250,25 +248,21 @@ fn validate_parameter_definition(
Some(ParameterType::UnsignedInteger(schema)) if schema.unit.trim().is_empty() => {
errors.push(anyhow::anyhow!("engine parameter {id:?} requires a unit"));
}
- Some(ParameterType::StringEnum(_)) => errors.push(anyhow::anyhow!(
- "engine parameter {id:?} must be a boolean or an unsigned integer"
- )),
+ Some(ParameterType::StringEnum(_) | ParameterType::Text(_) | ParameterType::Secret(_)) => {
+ errors.push(anyhow::anyhow!(
+ "engine parameter {id:?} must be a boolean or an unsigned integer"
+ ))
+ }
Some(ParameterType::Boolean(_) | ParameterType::UnsignedInteger(_)) | None => {}
}
- // The shared parameter check reports a context parameter in the wrong view.
- if definition.visibility() == ParameterVisibility::Basic && !in_main_view(id) {
- errors.push(anyhow::anyhow!(
- "engine parameter {id:?} must be advanced or hidden; only the context parameters are basic"
- ));
- }
}
#[cfg(test)]
mod tests {
use dotcore_proto::dotcore::{
catalog::v1::{
- NixArtifact, NixDockerImage, StorageBinding, StorageKind, StorageScope,
- nix_artifact::Provides,
+ NixArtifact, NixDockerImage, ParameterVisibility, StorageBinding, StorageKind,
+ StorageScope, nix_artifact::Provides,
},
observation::v1::AcceleratorMemoryArchitecture,
};
@@ -439,7 +433,7 @@ mod tests {
}
#[test]
- fn engine_parameters_are_non_basic_booleans_or_unsigned_integers_with_a_unit() {
+ fn engine_parameters_are_booleans_or_unsigned_integers_with_a_unit() {
let definition = |parameter_type| ParameterDefinition {
parameter_id: "feature".to_owned(),
display_name: "Feature".to_owned(),
@@ -483,14 +477,18 @@ mod tests {
))),
["engine parameter \"feature\" must be a boolean or an unsigned integer"]
);
- let mut basic = definition(boolean.clone());
- basic.visibility = ParameterVisibility::Basic as i32;
assert_eq!(
- errors(&basic),
- [
- "engine parameter \"feature\" must be advanced or hidden; only the context parameters are basic"
- ]
+ errors(&definition(ParameterType::Secret(
+ dotcore_proto::dotcore::catalog::v1::SecretParameterSchema {
+ generated_length: 32,
+ minimum_length: 12,
+ },
+ ))),
+ ["engine parameter \"feature\" must be a boolean or an unsigned integer"]
);
+ let mut basic = definition(boolean.clone());
+ basic.visibility = ParameterVisibility::Basic as i32;
+ assert_eq!(errors(&basic), Vec::::new());
let mut unplaced = definition(boolean);
unplaced.visibility = ParameterVisibility::Unspecified as i32;
assert_eq!(
diff --git a/backend/catalog/src/validation/graph.rs b/backend/catalog/src/validation/graph.rs
index 388f92c4..b99233a0 100644
--- a/backend/catalog/src/validation/graph.rs
+++ b/backend/catalog/src/validation/graph.rs
@@ -12,7 +12,7 @@ use dotcore_proto::dotcore::catalog::v1::{
use super::application::valid_configuration_value;
use super::service::validate_application_service;
use crate::OciArtifactPolicy;
-use crate::typed_parameters::{default_value, validate_parameter_definition};
+use crate::typed_parameters::{default_value, is_secret, validate_parameter_definition};
pub(super) fn validate_application_graph(
graph: &ApplicationGraph,
@@ -180,9 +180,10 @@ fn validate_primary_endpoint(
}
}
-/// Checks parameter definitions, each with a default and shown as basic or advanced, and that
-/// bindings and parameters reference each other exactly: every binding names a declared parameter
-/// and every parameter supplies at least one binding.
+/// Checks parameter definitions, each shown as basic or advanced and, except a secret, with a
+/// default, and that bindings and parameters reference each other exactly: every binding names a
+/// declared parameter, secrets only through secret bindings and other parameters only through
+/// configuration bindings, and every parameter supplies at least one binding.
fn validate_parameters(graph: &ApplicationGraph, errors: &mut Vec) {
let mut parameter_ids = BTreeSet::new();
for definition in &graph.parameters {
@@ -193,7 +194,7 @@ fn validate_parameters(graph: &ApplicationGraph, errors: &mut Vec
let definition_errors = validate_parameter_definition(definition);
let valid_definition = definition_errors.is_empty();
errors.extend(definition_errors);
- if valid_definition && default_value(definition).is_none() {
+ if valid_definition && !is_secret(definition) && default_value(definition).is_none() {
errors.push(anyhow::anyhow!(
"application parameter {id:?} requires a default"
));
@@ -217,6 +218,12 @@ fn validate_parameters(graph: &ApplicationGraph, errors: &mut Vec
}
}
+ let secret_ids = graph
+ .parameters
+ .iter()
+ .filter(|definition| is_secret(definition))
+ .map(|definition| definition.parameter_id.as_str())
+ .collect::>();
let mut referenced_ids = BTreeSet::new();
for service in &graph.services {
for binding in &service.configuration_bindings {
@@ -232,12 +239,35 @@ fn validate_parameters(graph: &ApplicationGraph, errors: &mut Vec
binding.binding_name,
service.service_name
));
+ } else if secret_ids.contains(id.as_str()) {
+ errors.push(anyhow::anyhow!(
+ "application configuration binding {:?} on service {:?} references secret parameter {id:?}; secrets use secret bindings",
+ binding.binding_name,
+ service.service_name
+ ));
+ }
+ }
+ for binding in &service.secret_bindings {
+ let id = binding.parameter_id.as_str();
+ referenced_ids.insert(id);
+ if !parameter_ids.contains(id) {
+ errors.push(anyhow::anyhow!(
+ "application secret binding {:?} on service {:?} references missing parameter {id:?}",
+ binding.binding_name,
+ service.service_name
+ ));
+ } else if !secret_ids.contains(id) {
+ errors.push(anyhow::anyhow!(
+ "application secret binding {:?} on service {:?} references parameter {id:?}, which is not a secret",
+ binding.binding_name,
+ service.service_name
+ ));
}
}
}
for id in parameter_ids.difference(&referenced_ids) {
errors.push(anyhow::anyhow!(
- "application parameter {id:?} is not used by any configuration binding"
+ "application parameter {id:?} is not used by any binding"
));
}
}
diff --git a/backend/catalog/src/validation/service.rs b/backend/catalog/src/validation/service.rs
index f7cce03f..113a1191 100644
--- a/backend/catalog/src/validation/service.rs
+++ b/backend/catalog/src/validation/service.rs
@@ -4,8 +4,9 @@ use std::collections::BTreeSet;
use dotcore_domain::valid_id;
use dotcore_proto::dotcore::catalog::v1::{
- ApplicationInferenceRouteProjection, ApplicationService, application_configuration_binding,
- application_deployment_input, application_service_artifact::Kind as ServiceArtifactKind,
+ ApplicationInferenceRouteProjection, ApplicationSecretForm, ApplicationService,
+ application_configuration_binding, application_deployment_input,
+ application_service_artifact::Kind as ServiceArtifactKind,
};
use super::{
@@ -145,6 +146,15 @@ fn validate_bindings(service: &ApplicationService, errors: &mut Vec String
desired.application_deployment_id.clone()
}
-#[derive(Debug)]
struct LaunchPlan {
deployment_id: String,
site_node_id: SiteNodeId,
@@ -325,10 +324,12 @@ struct LaunchPrimaryEndpoint {
port_name: String,
}
-#[derive(Debug)]
+/// Retained attachment values keyed by service and binding name. It holds secret values, so it
+/// has no `Debug` form.
struct AttachmentIndexes {
storage: BTreeMap<(String, String), String>,
configuration: BTreeMap<(String, String), String>,
+ secret: BTreeMap<(String, String), String>,
}
impl AttachmentIndexes {
@@ -361,19 +362,33 @@ impl AttachmentIndexes {
))
})
.collect::, ApplicationLifecycleError>>()?;
+ let secret = desired
+ .secret_attachments
+ .iter()
+ .map(|attachment| {
+ (
+ (
+ attachment.service_name.clone(),
+ attachment.binding_name.clone(),
+ ),
+ attachment.value.clone(),
+ )
+ })
+ .collect::>();
if storage.len() != desired.storage_attachments.len()
|| configuration_values.len() != desired.configuration_attachments.len()
+ || secret.len() != desired.secret_attachments.len()
{
return Err(configuration("Application attachment is duplicated"));
}
Ok(Self {
storage,
configuration: configuration_values,
+ secret,
})
}
}
-#[derive(Debug)]
struct WorkloadUnitPlan {
image: String,
command: Vec,
@@ -415,6 +430,17 @@ impl WorkloadUnitPlan {
Ok((binding.environment_variable.clone(), value))
})
.collect::>()?;
+ for binding in &service.secret_bindings {
+ let value = attachments
+ .secret
+ .get(&(service.service_name.clone(), binding.binding_name.clone()))
+ .cloned()
+ .ok_or_else(|| configuration("Application secret attachment is missing"))?;
+ environment.push((
+ binding.environment_variable.clone(),
+ LaunchEnvironmentValue::Fixed(value),
+ ));
+ }
let host_aliases = service
.configuration_bindings
.iter()
@@ -543,7 +569,6 @@ impl WorkloadUnitPlan {
}
}
-#[derive(Debug)]
enum LaunchEnvironmentValue {
Fixed(String),
@@ -570,8 +595,14 @@ fn canonical_launch_generation(
publication: &WorkloadPublicationRequest,
) -> u64 {
let mut value = 0xcbf29ce484222325_u64;
+ // A unit's `Debug` form leaves out environment values, so they are hashed explicitly.
+ let environment = units
+ .values()
+ .flat_map(|unit| &unit.environment)
+ .map(|(name, value)| format!("{name}={value}\n"))
+ .collect::();
for byte in format!(
- "{execution_target:?}:{execution_target_id}:{required_platform:?}:{units:?}:{publication:?}"
+ "{execution_target:?}:{execution_target_id}:{required_platform:?}:{units:?}:{publication:?}:{environment}"
)
.bytes()
{
@@ -617,13 +648,6 @@ pub(super) fn configuration_attachments(
.graph
.as_ref()
.ok_or_else(|| configuration("Application graph is missing"))?;
- if graph
- .services
- .iter()
- .any(|service| !service.secret_bindings.is_empty())
- {
- return Err(ApplicationLifecycleError::FailedPrecondition);
- }
let resolve = &resolve;
graph
.services
@@ -695,6 +719,47 @@ fn configuration_attachment_value(
}
}
+/// Resolves the retained secret attachments of a new deployment: each secret binding receives its
+/// parameter's resolved value in the binding's form.
+pub(super) fn secret_attachments(
+ application: &Application,
+ parameter_values: &[ParameterValue],
+) -> Result, ApplicationLifecycleError> {
+ let graph = application
+ .graph
+ .as_ref()
+ .ok_or_else(|| configuration("Application graph is missing"))?;
+ graph
+ .services
+ .iter()
+ .flat_map(|service| {
+ service.secret_bindings.iter().map(move |binding| {
+ let value = canonical_value_text(parameter_values, &binding.parameter_id)
+ .ok_or_else(|| {
+ configuration("Application secret parameter value is missing")
+ })?;
+ let value = match ApplicationSecretForm::try_from(binding.form) {
+ Ok(ApplicationSecretForm::Plain) => value,
+ Ok(ApplicationSecretForm::Unspecified) | Err(_) => {
+ return Err(configuration("Application secret binding form is invalid"));
+ }
+ };
+ if !valid_configuration_value(&value) {
+ return Err(configuration(
+ "Application secret value cannot be represented safely",
+ ));
+ }
+ Ok(ApplicationSecretAttachment {
+ service_name: service.service_name.clone(),
+ binding_name: binding.binding_name.clone(),
+ environment_variable: binding.environment_variable.clone(),
+ value,
+ })
+ })
+ })
+ .collect()
+}
+
pub(super) fn storage_attachments(
application: &Application,
) -> Result, ApplicationLifecycleError> {
diff --git a/backend/core/src/application/controller/logs.rs b/backend/core/src/application/controller/logs.rs
index a9e5f77f..f5476716 100644
--- a/backend/core/src/application/controller/logs.rs
+++ b/backend/core/src/application/controller/logs.rs
@@ -569,8 +569,8 @@ fn step_sources(services: &[RuntimeLogSource]) -> Vec {
.collect()
}
-/// Returns the configuration values the deployment's launch receives, which step lines must
-/// not reveal either.
+/// Returns the configuration and secret values the deployment's launch receives, which step
+/// lines must not reveal either.
fn attachment_values(
deployment: &ApplicationDeploymentDeclaration,
) -> impl Iterator- {
@@ -578,6 +578,12 @@ fn attachment_values(
.configuration_attachments
.iter()
.map(|attachment| attachment.value.clone())
+ .chain(
+ deployment
+ .secret_attachments
+ .iter()
+ .map(|attachment| attachment.value.clone()),
+ )
}
/// Selects the requested sources among `declared`, or all of them.
diff --git a/backend/core/src/application/controller/mod.rs b/backend/core/src/application/controller/mod.rs
index eca41141..7b4d803b 100644
--- a/backend/core/src/application/controller/mod.rs
+++ b/backend/core/src/application/controller/mod.rs
@@ -83,6 +83,7 @@ use operations::{
create_deployment, pause_deployment, remove_deployment, resume_deployment, update_metadata,
};
use owner::ApplicationOwner;
+pub use parameters::{RandomSecretGenerator, SecretGenerator};
use projection::project;
use realization::{
ApplicationRealizationInput, ApplicationRealizationResults, ApplicationRealizationSnapshot,
@@ -208,6 +209,7 @@ pub struct ApplicationControllerConfiguration {
pub configuration_sources: ApplicationConfigurationSources,
pub inference_route_resolver: Arc
,
pub target_capabilities: Arc,
+ pub secret_generator: Arc,
}
/// Apps-controller lifecycle authority on the site manager: the owner of application deployment
@@ -237,12 +239,14 @@ where
configuration_sources,
inference_route_resolver,
target_capabilities,
+ secret_generator,
} = controller_configuration;
let owner = Arc::new(ApplicationOwner {
catalog,
configuration_sources,
inference_route_resolver,
target_capabilities,
+ secret_generator,
desired: Arc::new(desired),
current: Arc::new(current),
operation: AsyncMutex::new(()),
diff --git a/backend/core/src/application/controller/node.rs b/backend/core/src/application/controller/node.rs
index e7596de7..d9af2289 100644
--- a/backend/core/src/application/controller/node.rs
+++ b/backend/core/src/application/controller/node.rs
@@ -922,9 +922,10 @@ mod tests {
v1::{
ApplicationConfigurationAttachment, ApplicationDeploymentDeclaration,
ApplicationDeploymentDesiredState, ApplicationLifecycleState,
- ApplicationReadinessState, ApplicationServiceAcceleratorMemory,
- ApplicationStorageAttachment, CurrentApplicationDeployment,
- ResolvedApplicationDeploymentSnapshot, ResolvedApplicationServiceSelection,
+ ApplicationReadinessState, ApplicationSecretAttachment,
+ ApplicationServiceAcceleratorMemory, ApplicationStorageAttachment,
+ CurrentApplicationDeployment, ResolvedApplicationDeploymentSnapshot,
+ ResolvedApplicationServiceSelection,
},
},
catalog::v1::{
@@ -2480,6 +2481,14 @@ mod tests {
environment_variable: "TOKEN".to_owned(),
value: "secret-token".to_owned(),
});
+ preparing
+ .secret_attachments
+ .push(ApplicationSecretAttachment {
+ service_name: "web".to_owned(),
+ binding_name: "password".to_owned(),
+ environment_variable: "PASSWORD".to_owned(),
+ value: "secret-password".to_owned(),
+ });
fixture.install(1, vec![assignment(preparing, None)]);
// Never reconciled, so the deployment's group does not exist yet.
let logs = fixture.realizer().log_owner();
@@ -2495,7 +2504,7 @@ mod tests {
build.append(
time(1),
RuntimeLogStreamKind::Stderr,
- b"building with secret-token",
+ b"building with secret-token and secret-password",
);
preparation
.writer(
@@ -2555,7 +2564,7 @@ mod tests {
));
assert_eq!(
all.protected,
- ProtectedLogValues::new(["secret-token".to_owned()])
+ ProtectedLogValues::new(["secret-token".to_owned(), "secret-password".to_owned()])
);
let build_entry = all.sources[0]
.result
@@ -2565,7 +2574,7 @@ mod tests {
.clone();
assert_eq!(
filter_entry(build_entry, &all.protected).content,
- b"building with [REDACTED]"
+ b"building with [REDACTED] and [REDACTED]"
);
assert_eq!(
filtered
diff --git a/backend/core/src/application/controller/operations.rs b/backend/core/src/application/controller/operations.rs
index 2388c48d..8f77646b 100644
--- a/backend/core/src/application/controller/operations.rs
+++ b/backend/core/src/application/controller/operations.rs
@@ -527,7 +527,8 @@ fn resolve_application(
}
/// Builds the declaration of a new deployment on `placement`, with its immutable snapshot for
-/// the selected `platform`, the parameter values resolved from the request, and a published host
+/// the selected `platform`, the parameter values resolved from the request with missing secrets
+/// generated, the secret attachments derived from them once, and a published host
/// port no other retained deployment holds, even one still tearing down. Local-development launch
/// identities are left empty for the hosting node to pin once on its own image store.
fn declaration(
@@ -543,12 +544,13 @@ where
D: ApplicationDeclarationRepository,
S: CurrentApplicationRepository + ApplicationLifecycleGenerations,
{
- let parameter_values = super::parameters::resolve_parameter_values(
+ let parameters = super::parameters::resolve_parameter_values(
application
.graph
.as_ref()
.ok_or(ApplicationLifecycleError::FailedPrecondition)?,
&request.parameter_values,
+ owner.secret_generator.as_ref(),
)?;
// The link is checked for identity and generation only; route availability never gates creation.
if let Some(inference_route_id) = request.inference_route_id.as_deref() {
@@ -586,9 +588,10 @@ where
.configuration_sources
.resolve(name, site, &placement.site_node_id, published_port)
},
- ¶meter_values,
+ ¶meters.values,
request.inference_route_id.is_some(),
)?;
+ let secret_attachments = super::launch::secret_attachments(&application, ¶meters.values)?;
let storage_attachments = super::launch::storage_attachments(&application)?;
Ok(ApplicationDeploymentDeclaration {
@@ -600,12 +603,14 @@ where
.map_err(|_| ApplicationLifecycleError::InvalidName)?,
placement: Some(placement.clone()),
snapshot: Some(ResolvedApplicationDeploymentSnapshot {
- parameter_values,
+ parameter_values: parameters.values,
+ generated_parameter_ids: parameters.generated_parameter_ids,
..resolve_snapshot(&application, site, placement, platform)?
}),
desired_state: ApplicationDeploymentDesiredState::Present as i32,
storage_attachments,
configuration_attachments,
+ secret_attachments,
inference_route_id: request.inference_route_id,
lifecycle_generation: 0,
node_resolved_launch_identities: Vec::new(),
@@ -741,6 +746,7 @@ pub(super) fn resolve_snapshot(
platform: Some(platform.clone()),
service_selections,
parameter_values: Vec::new(),
+ generated_parameter_ids: Vec::new(),
})
}
diff --git a/backend/core/src/application/controller/owner.rs b/backend/core/src/application/controller/owner.rs
index 76465466..31f77630 100644
--- a/backend/core/src/application/controller/owner.rs
+++ b/backend/core/src/application/controller/owner.rs
@@ -48,6 +48,7 @@ pub(super) struct ApplicationOwner {
pub(super) configuration_sources: ApplicationConfigurationSources,
pub(super) inference_route_resolver: Arc,
pub(super) target_capabilities: Arc,
+ pub(super) secret_generator: Arc,
pub(super) desired: Arc,
pub(super) current: Arc,
diff --git a/backend/core/src/application/controller/parameters.rs b/backend/core/src/application/controller/parameters.rs
index ef9b7f7c..6c8f9374 100644
--- a/backend/core/src/application/controller/parameters.rs
+++ b/backend/core/src/application/controller/parameters.rs
@@ -1,22 +1,69 @@
//! Application parameter resolution at deployment creation.
//!
//! Merges operator-supplied values over the defaults an application declares, rejecting values
-//! the application does not declare or does not accept. The result is the complete set a
-//! deployment retains for its whole life, in the application's declaration order.
+//! the application does not declare or does not accept, and generates a random value for each
+//! secret the operator leaves out. The result is the complete set a deployment retains for its
+//! whole life, in the application's declaration order, with the secrets that were generated.
use std::collections::BTreeSet;
use dotcore_catalog::typed_parameters::{check_value, default_value};
-use dotcore_proto::dotcore::catalog::v1::{ApplicationGraph, ParameterValue};
+use dotcore_proto::dotcore::catalog::v1::{
+ ApplicationGraph, ParameterValue, parameter_definition::ParameterType, parameter_value::Value,
+};
use super::errors::{ApplicationLifecycleError, ParameterRejection, configuration};
+/// Source of the random values generated for secret parameters an operator leaves out.
+pub trait SecretGenerator: Send + Sync {
+ /// Returns `length` characters drawn uniformly from ASCII letters and digits.
+ fn generate(&self, length: usize) -> anyhow::Result;
+}
+
+/// Generates secret values from the operating system's cryptographic random source.
+#[derive(Clone, Copy, Debug, Default)]
+pub struct RandomSecretGenerator;
+
+impl SecretGenerator for RandomSecretGenerator {
+ fn generate(&self, length: usize) -> anyhow::Result {
+ // 248 is the largest multiple of the alphabet size within a byte; rejecting bytes above
+ // it keeps every character equally likely.
+ const ACCEPTED_BYTES: u8 =
+ (u8::MAX / SECRET_ALPHABET.len() as u8) * SECRET_ALPHABET.len() as u8;
+ let mut value = String::with_capacity(length);
+ let mut bytes = [0_u8; 64];
+ while value.len() < length {
+ getrandom::fill(&mut bytes).map_err(|source| {
+ anyhow::Error::new(EntropyError(source)).context("Secret entropy failed")
+ })?;
+ value.extend(
+ bytes
+ .iter()
+ .filter(|byte| **byte < ACCEPTED_BYTES)
+ .map(|byte| {
+ char::from(SECRET_ALPHABET[usize::from(*byte) % SECRET_ALPHABET.len()])
+ })
+ .take(length - value.len()),
+ );
+ }
+ Ok(value)
+ }
+}
+
+/// The parameter values a new deployment retains and the secrets among them that were generated.
+#[derive(Debug, PartialEq, Eq)]
+pub(super) struct ResolvedParameters {
+ pub(super) values: Vec,
+ pub(super) generated_parameter_ids: Vec,
+}
+
/// Resolves the complete parameter values of a new deployment from the operator's `supplied`
-/// overrides and the graph's declared defaults.
+/// overrides, the graph's declared defaults, and a generated value for each secret left out.
pub(super) fn resolve_parameter_values(
graph: &ApplicationGraph,
supplied: &[ParameterValue],
-) -> Result, ApplicationLifecycleError> {
+ secrets: &dyn SecretGenerator,
+) -> Result {
let mut supplied_ids = BTreeSet::new();
for value in supplied {
let reject = |reason| ApplicationLifecycleError::InvalidParameter {
@@ -39,22 +86,68 @@ pub(super) fn resolve_parameter_values(
.map_err(|source| reject(ParameterRejection::InvalidValue(source)))?;
}
- graph
- .parameters
- .iter()
- .map(|definition| {
- let value = match supplied
- .iter()
- .find(|value| value.parameter_id == definition.parameter_id)
- {
- Some(supplied) => supplied.value.clone(),
- None => default_value(definition),
+ let mut resolved = ResolvedParameters {
+ values: Vec::with_capacity(graph.parameters.len()),
+ generated_parameter_ids: Vec::new(),
+ };
+ for definition in &graph.parameters {
+ let supplied = supplied
+ .iter()
+ .find(|value| value.parameter_id == definition.parameter_id)
+ .and_then(|value| value.value.clone());
+ let value = match (supplied, definition.parameter_type.as_ref()) {
+ (Some(value), _) => value,
+ (None, Some(ParameterType::Secret(schema))) => {
+ let value =
+ secrets
+ .generate(schema.generated_length as usize)
+ .map_err(|source| {
+ ApplicationLifecycleError::Configuration(
+ source.context("Application secret parameter generation failed"),
+ )
+ })?;
+ resolved
+ .generated_parameter_ids
+ .push(definition.parameter_id.clone());
+ Value::String(value)
}
- .ok_or_else(|| configuration("Application parameter default is missing"))?;
- Ok(ParameterValue {
- parameter_id: definition.parameter_id.clone(),
- value: Some(value),
- })
- })
- .collect()
+ (None, _) => default_value(definition)
+ .ok_or_else(|| configuration("Application parameter default is missing"))?,
+ };
+ resolved.values.push(ParameterValue {
+ parameter_id: definition.parameter_id.clone(),
+ value: Some(value),
+ });
+ }
+ Ok(resolved)
+}
+
+/// Characters a generated secret is drawn from: safe in environment values, URLs and shells.
+const SECRET_ALPHABET: &[u8; 62] =
+ b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
+
+#[derive(Debug, thiserror::Error)]
+#[error("{0}")]
+struct EntropyError(getrandom::Error);
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn random_secrets_have_the_requested_length_from_the_alphanumeric_alphabet() {
+ let generator = RandomSecretGenerator;
+ for length in [1, 32, 200] {
+ let value = generator.generate(length).expect("generated secret");
+ assert_eq!(value.len(), length);
+ assert!(
+ value.bytes().all(|byte| SECRET_ALPHABET.contains(&byte)),
+ "{value}"
+ );
+ }
+ assert_ne!(
+ generator.generate(32).expect("first secret"),
+ generator.generate(32).expect("second secret")
+ );
+ }
}
diff --git a/backend/core/src/application/controller/test_support.rs b/backend/core/src/application/controller/test_support.rs
index 9d13dab7..99ebf16e 100644
--- a/backend/core/src/application/controller/test_support.rs
+++ b/backend/core/src/application/controller/test_support.rs
@@ -861,6 +861,7 @@ pub(super) fn manager_controller(
target_capabilities,
configuration_sources: ApplicationConfigurationSources::default(),
inference_route_resolver: test_inference_route_resolver(),
+ secret_generator: test_secret_generator(),
},
)
.expect("manager apps controller")
@@ -1149,6 +1150,7 @@ where
target_capabilities: test_target_capability_source(),
configuration_sources: ApplicationConfigurationSources::default(),
inference_route_resolver: test_inference_route_resolver(),
+ secret_generator: test_secret_generator(),
},
artifacts,
)
@@ -1174,6 +1176,7 @@ where
target_capabilities: test_target_capability_source(),
configuration_sources: ApplicationConfigurationSources::default(),
inference_route_resolver,
+ secret_generator: test_secret_generator(),
},
)
.expect("Apps controller")
@@ -1193,6 +1196,7 @@ where
target_capabilities: test_target_capability_source(),
configuration_sources: ApplicationConfigurationSources::default(),
inference_route_resolver: test_inference_route_resolver(),
+ secret_generator: test_secret_generator(),
},
)
.expect("Apps controller")
@@ -2768,6 +2772,32 @@ pub(super) fn test_inference_route_resolver_with(
Arc::new(TestInferenceRouteResolver { result })
}
+/// A secret generator whose values tests can predict: the `n`th call returns `secret` padded
+/// with `x` to the requested length.
+pub(super) fn test_secret_generator() -> Arc {
+ Arc::new(SequentialSecretGenerator::default())
+}
+
+/// The `n`th value [`test_secret_generator`] returns at `length`, counting from 1.
+pub(super) fn sequential_secret(n: usize, length: usize) -> String {
+ let mut value = format!("secret{n}");
+ value.extend(std::iter::repeat_n('x', length.saturating_sub(value.len())));
+ value.truncate(length);
+ value
+}
+
+#[derive(Default)]
+struct SequentialSecretGenerator {
+ calls: AtomicUsize,
+}
+
+impl super::SecretGenerator for SequentialSecretGenerator {
+ fn generate(&self, length: usize) -> anyhow::Result {
+ let n = self.calls.fetch_add(1, Ordering::SeqCst) + 1;
+ Ok(sequential_secret(n, length))
+ }
+}
+
pub(super) fn resolved_route(route_name: &str) -> ResolvedInferenceRoute {
ResolvedInferenceRoute {
inference_route_id: "route-id".to_owned(),
@@ -2813,6 +2843,7 @@ pub(super) fn deployment(
desired_state: desired_state as i32,
storage_attachments: Vec::new(),
configuration_attachments: Vec::new(),
+ secret_attachments: Vec::new(),
inference_route_id: None,
lifecycle_generation: 1,
node_resolved_launch_identities: Vec::new(),
@@ -2865,6 +2896,7 @@ pub(super) fn controller_for_application_and_target_source(
target_capabilities,
configuration_sources: ApplicationConfigurationSources::default(),
inference_route_resolver: test_inference_route_resolver(),
+ secret_generator: test_secret_generator(),
},
)
.expect("Apps controller")
@@ -2994,6 +3026,69 @@ pub(super) fn application_with_parameters() -> Application {
application
}
+/// The test application with a text `username` parameter bound to `USERNAME` and a secret
+/// `password` parameter bound in plain form to `PASSWORD`.
+pub(super) fn application_with_secret_parameters() -> Application {
+ use dotcore_proto::dotcore::catalog::v1::{
+ ApplicationSecretBinding, ApplicationSecretForm, ParameterDefinition, ParameterVisibility,
+ TextParameterSchema, parameter_definition::ParameterType,
+ };
+
+ let mut application = application();
+ let graph = application.graph.as_mut().expect("graph");
+ graph.parameters = vec![
+ ParameterDefinition {
+ parameter_id: "username".to_owned(),
+ display_name: "Username".to_owned(),
+ description: String::new(),
+ parameter_type: Some(ParameterType::Text(TextParameterSchema {
+ minimum_length: 1,
+ maximum_length: 64,
+ default_value: Some("admin".to_owned()),
+ })),
+ visibility: ParameterVisibility::Basic as i32,
+ },
+ secret_parameter("password"),
+ ];
+ let web = &mut graph.services[0];
+ web.configuration_bindings = vec![ApplicationConfigurationBinding {
+ binding_name: "username".to_owned(),
+ environment_variable: "USERNAME".to_owned(),
+ source: Some(application_configuration_binding::Source::ParameterId(
+ "username".to_owned(),
+ )),
+ input: None,
+ }];
+ web.secret_bindings = vec![ApplicationSecretBinding {
+ binding_name: "password".to_owned(),
+ environment_variable: "PASSWORD".to_owned(),
+ parameter_id: "password".to_owned(),
+ form: ApplicationSecretForm::Plain as i32,
+ }];
+ application
+}
+
+/// A basic secret parameter generated at 32 characters and accepted from 12.
+pub(super) fn secret_parameter(
+ id: &str,
+) -> dotcore_proto::dotcore::catalog::v1::ParameterDefinition {
+ use dotcore_proto::dotcore::catalog::v1::{
+ ParameterDefinition, ParameterVisibility, SecretParameterSchema,
+ parameter_definition::ParameterType,
+ };
+
+ ParameterDefinition {
+ parameter_id: id.to_owned(),
+ display_name: id.to_owned(),
+ description: String::new(),
+ parameter_type: Some(ParameterType::Secret(SecretParameterSchema {
+ generated_length: 32,
+ minimum_length: 12,
+ })),
+ visibility: ParameterVisibility::Basic as i32,
+ }
+}
+
/// The test application with its web service bound to the hosting node's inference base URL.
pub(super) fn base_url_application() -> Application {
let mut application = application();
diff --git a/backend/core/src/application/controller/tests.rs b/backend/core/src/application/controller/tests.rs
index b62f6f29..396c6e30 100644
--- a/backend/core/src/application/controller/tests.rs
+++ b/backend/core/src/application/controller/tests.rs
@@ -19,7 +19,9 @@ use dotcore_domain::{
},
timing::{ReconcileCadence, ReconcileOutcome, Timing},
};
-use dotcore_proto::dotcore::application::v1::ApplicationConfigurationAttachment;
+use dotcore_proto::dotcore::application::v1::{
+ ApplicationConfigurationAttachment, ApplicationSecretAttachment,
+};
use dotcore_proto::dotcore::{
application::private::v1::{ApplicationLifecycleObservation, ApplicationStatusStage},
application::v1::{
@@ -34,10 +36,11 @@ use dotcore_proto::dotcore::{
},
catalog::v1::{
ApplicationConfigurationBinding, ApplicationDependencyCondition, ApplicationHttpReadiness,
- ApplicationInferenceRouteProjection, ApplicationSecretBinding, ApplicationService,
- ApplicationServiceDependency, ApplicationServicePort, ApplicationServiceReadiness,
- ParameterValue, StorageBinding, StorageEnvironmentVariable, StorageKind, StorageScope,
- application_configuration_binding, application_service_readiness, parameter_value,
+ ApplicationInferenceRouteProjection, ApplicationSecretBinding, ApplicationSecretForm,
+ ApplicationService, ApplicationServiceDependency, ApplicationServicePort,
+ ApplicationServiceReadiness, ParameterValue, StorageBinding, StorageEnvironmentVariable,
+ StorageKind, StorageScope, application_configuration_binding,
+ application_service_readiness, parameter_value,
},
deployment::v1::{
AcceleratorMemoryDeviceUsage, AcceleratorMemoryObservation, AcceleratorMemoryUnavailable,
@@ -66,7 +69,7 @@ use tokio::{io::AsyncWriteExt, sync::Notify};
use tonic::Request;
use super::identity::allocate_deployment_id;
-use super::launch::configuration_attachments;
+use super::launch::{configuration_attachments, secret_attachments};
use super::launch::{deployment_data, workload_group_launch};
use super::observation::{exact_owned_observation, stale_owned_group};
use super::readiness::{
@@ -115,6 +118,7 @@ async fn application_artifact_verification_failure_prevents_runtime_creation_wit
target_capabilities: test_target_capability_source(),
configuration_sources: ApplicationConfigurationSources::default(),
inference_route_resolver: test_inference_route_resolver(),
+ secret_generator: test_secret_generator(),
},
artifacts,
)
@@ -2764,6 +2768,7 @@ async fn unknown_group_after_restart_is_rehydrated_before_present_or_absent_reco
target_capabilities: test_target_capability_source(),
configuration_sources: ApplicationConfigurationSources::default(),
inference_route_resolver: test_inference_route_resolver(),
+ secret_generator: test_secret_generator(),
},
)
.expect("Apps controller");
@@ -2813,6 +2818,7 @@ async fn cold_absent_reconciliation_passes_stable_identity_to_runtime_cleanup()
target_capabilities: test_target_capability_source(),
configuration_sources: ApplicationConfigurationSources::default(),
inference_route_resolver: test_inference_route_resolver(),
+ secret_generator: test_secret_generator(),
},
)
.expect("Apps controller");
@@ -3007,6 +3013,7 @@ async fn teardown_startup_allows_target_drift_and_retains_missing_runtime_for_re
target_capabilities: test_target_capability_source(),
configuration_sources: ApplicationConfigurationSources::default(),
inference_route_resolver: test_inference_route_resolver(),
+ secret_generator: test_secret_generator(),
},
)
.expect("teardown controller without configured runtime");
@@ -3088,6 +3095,7 @@ async fn teardown_retries_exact_network_only_residue_and_finalizes_records() {
target_capabilities: test_target_capability_source(),
configuration_sources: ApplicationConfigurationSources::default(),
inference_route_resolver: test_inference_route_resolver(),
+ secret_generator: test_secret_generator(),
},
)
.expect("Apps controller");
@@ -3620,6 +3628,7 @@ async fn present_completes_in_process_network_only_residue_after_failed_removal(
target_capabilities: test_target_capability_source(),
configuration_sources: ApplicationConfigurationSources::default(),
inference_route_resolver: test_inference_route_resolver(),
+ secret_generator: test_secret_generator(),
},
)
.expect("Apps controller");
@@ -3834,6 +3843,7 @@ async fn created_deployment_retains_the_base_url_of_its_hosting_node() {
6421,
),
inference_route_resolver: test_inference_route_resolver(),
+ secret_generator: test_secret_generator(),
},
)
.expect("manager apps controller");
@@ -3904,6 +3914,7 @@ async fn created_deployments_hold_distinct_published_ports_through_teardown_and_
6421,
),
inference_route_resolver: test_inference_route_resolver(),
+ secret_generator: test_secret_generator(),
},
)
.expect("manager apps controller");
@@ -4004,6 +4015,7 @@ async fn created_deployment_snapshot_retains_the_application_notes() {
6421,
),
inference_route_resolver: test_inference_route_resolver(),
+ secret_generator: test_secret_generator(),
},
)
.expect("manager apps controller");
@@ -4108,19 +4120,33 @@ fn launch_pins_only_site_host_names_from_site_host_sources() {
}
#[test]
-fn configuration_attachments_reject_secret_bindings() {
+fn secret_bindings_attach_apart_from_configuration() {
let mut application = application();
- application.graph.as_mut().expect("graph").services[0]
+ let graph = application.graph.as_mut().expect("graph");
+ graph.parameters.push(secret_parameter("password"));
+ graph.services[0]
.secret_bindings
.push(ApplicationSecretBinding {
- binding_name: "secret".to_owned(),
+ binding_name: "password".to_owned(),
environment_variable: "SECRET_VALUE".to_owned(),
+ parameter_id: "password".to_owned(),
+ form: ApplicationSecretForm::Plain as i32,
});
+ let values = [string_parameter("password", "typed-password")];
- assert!(matches!(
- configuration_attachments(&application, |_| None, &[], false),
- Err(ApplicationLifecycleError::FailedPrecondition)
- ));
+ assert_eq!(
+ configuration_attachments(&application, |_| None, &values, false).expect("configuration"),
+ []
+ );
+ assert_eq!(
+ secret_attachments(&application, &values).expect("secrets"),
+ [ApplicationSecretAttachment {
+ service_name: "web".to_owned(),
+ binding_name: "password".to_owned(),
+ environment_variable: "SECRET_VALUE".to_owned(),
+ value: "typed-password".to_owned(),
+ }]
+ );
}
#[test]
@@ -4363,6 +4389,7 @@ async fn deployment_creation_persists_the_route_link_without_a_route_name_attach
target_capabilities: test_target_capability_source(),
configuration_sources: ApplicationConfigurationSources::default(),
inference_route_resolver: test_inference_route_resolver(),
+ secret_generator: test_secret_generator(),
},
)
.expect("Apps controller");
@@ -4439,6 +4466,7 @@ async fn route_input_uses_its_literal_without_a_route_link() {
inference_route_resolver: test_inference_route_resolver_with(Err(
InferenceRouteResolutionError::Stale,
)),
+ secret_generator: test_secret_generator(),
},
)
.expect("Apps controller");
@@ -4506,6 +4534,7 @@ async fn invalid_route_binding_identity_or_generation_rejects_without_mutation()
target_capabilities: test_target_capability_source(),
configuration_sources: ApplicationConfigurationSources::default(),
inference_route_resolver: test_inference_route_resolver(),
+ secret_generator: test_secret_generator(),
},
)
.expect("Apps controller");
@@ -4582,6 +4611,7 @@ async fn route_binding_without_projection_rejects_before_resolution_or_mutation(
inference_route_resolver: Arc::new(CountingCreateInferenceRouteResolver {
calls: route_resolution_calls.clone(),
}),
+ secret_generator: test_secret_generator(),
},
)
.expect("Apps controller");
@@ -4651,6 +4681,7 @@ async fn route_binding_failures_reject_before_site_store_or_runtime_mutation() {
target_capabilities: test_target_capability_source(),
configuration_sources: ApplicationConfigurationSources::default(),
inference_route_resolver: test_inference_route_resolver_with(Err(resolution)),
+ secret_generator: test_secret_generator(),
},
)
.expect("Apps controller");
@@ -5980,6 +6011,7 @@ async fn invalid_persisted_oci_artifact_never_mutates_the_runtime() {
target_capabilities: test_target_capability_source(),
configuration_sources: ApplicationConfigurationSources::default(),
inference_route_resolver: test_inference_route_resolver(),
+ secret_generator: test_secret_generator(),
},
)
.expect("Apps controller");
@@ -6029,6 +6061,7 @@ async fn unavailable_configuration_source_rejects_before_desired_or_runtime_muta
target_capabilities: test_target_capability_source(),
configuration_sources: ApplicationConfigurationSources::default(),
inference_route_resolver: test_inference_route_resolver(),
+ secret_generator: test_secret_generator(),
},
)
.expect("Apps controller");
@@ -6109,6 +6142,7 @@ async fn controller_rejects_retained_declarations_without_an_eligible_target() {
target_capabilities: test_target_capability_source(),
configuration_sources: ApplicationConfigurationSources::default(),
inference_route_resolver: test_inference_route_resolver(),
+ secret_generator: test_secret_generator(),
},
) {
Ok(_) => panic!("declarations for an undeclared target must be rejected"),
@@ -6156,6 +6190,7 @@ async fn create_rejects_site_node_identity_outside_the_identifier_rule() {
target_capabilities: test_target_capability_source(),
configuration_sources: ApplicationConfigurationSources::default(),
inference_route_resolver: test_inference_route_resolver(),
+ secret_generator: test_secret_generator(),
},
)
.expect("Apps controller");
@@ -6479,6 +6514,7 @@ async fn creation_failure_is_local_redacted_logged_retried_and_does_not_block_la
6470,
),
inference_route_resolver: test_inference_route_resolver(),
+ secret_generator: test_secret_generator(),
},
)
.expect("Apps controller");
@@ -7297,6 +7333,7 @@ fn controller_restart_rejects_a_retained_deployment_whose_target_is_undeclared()
target_capabilities: test_target_capability_source(),
configuration_sources: ApplicationConfigurationSources::default(),
inference_route_resolver: test_inference_route_resolver(),
+ secret_generator: test_secret_generator(),
},
);
@@ -7699,6 +7736,7 @@ async fn reconcile_after_target_change(
target_capabilities: test_target_capability_source(),
configuration_sources: ApplicationConfigurationSources::default(),
inference_route_resolver: test_inference_route_resolver(),
+ secret_generator: test_secret_generator(),
},
)
.expect("Apps controller");
@@ -7957,11 +7995,221 @@ async fn invalid_parameter_values_reject_creation_without_mutation() {
std::fs::remove_dir_all(directory).expect("test directory cleanup");
}
+#[tokio::test]
+async fn deployment_creation_generates_missing_secrets_once_and_keeps_typed_ones() {
+ let directory = test_directory();
+ let (controller, desired) =
+ secret_parameter_test_controller(&directory, test_secret_generator());
+
+ let generated = controller
+ .create_deployment(parameter_request(Vec::new()))
+ .await
+ .expect("deployment with a generated secret");
+ let typed = controller
+ .create_deployment(parameter_request(vec![
+ string_parameter("username", "operator"),
+ string_parameter("password", "typed-password-1"),
+ ]))
+ .await
+ .expect("deployment with a typed secret");
+
+ let generated_password = sequential_secret(1, 32);
+ assert_eq!(generated_password, "secret1xxxxxxxxxxxxxxxxxxxxxxxxx");
+ let snapshot = generated.snapshot.as_ref().expect("snapshot");
+ assert_eq!(
+ snapshot.parameter_values,
+ [
+ string_parameter("username", "admin"),
+ string_parameter("password", &generated_password),
+ ]
+ );
+ assert_eq!(snapshot.generated_parameter_ids, ["password"]);
+ let typed_snapshot = typed.snapshot.as_ref().expect("snapshot");
+ assert_eq!(
+ typed_snapshot.parameter_values,
+ [
+ string_parameter("username", "operator"),
+ string_parameter("password", "typed-password-1"),
+ ]
+ );
+ assert!(typed_snapshot.generated_parameter_ids.is_empty());
+
+ let declarations = desired
+ .snapshot()
+ .expect("persisted declarations")
+ .deployments;
+ let declaration = declarations
+ .iter()
+ .find(|declaration| {
+ declaration.application_deployment_id == generated.application_deployment_id
+ })
+ .expect("generated declaration")
+ .clone();
+ assert_eq!(
+ declaration.secret_attachments,
+ [ApplicationSecretAttachment {
+ service_name: "web".to_owned(),
+ binding_name: "password".to_owned(),
+ environment_variable: "PASSWORD".to_owned(),
+ value: generated_password.clone(),
+ }]
+ );
+ assert_eq!(
+ declaration
+ .configuration_attachments
+ .iter()
+ .map(|attachment| (
+ attachment.environment_variable.as_str(),
+ attachment.value.as_str()
+ ))
+ .collect::>(),
+ [("USERNAME", "admin")]
+ );
+ let launch = |declaration: &ApplicationDeploymentDeclaration| {
+ workload_group_launch(
+ declaration,
+ Ipv4Addr::LOCALHOST,
+ test_inference_route_resolver().as_ref(),
+ )
+ .expect("workload launch")
+ };
+ let first_launch = launch(&declaration);
+ assert_eq!(
+ first_launch.units[&WorkloadUnitName("web".to_owned())].environment,
+ BTreeMap::from([
+ ("PASSWORD".to_owned(), generated_password.clone()),
+ ("USERNAME".to_owned(), "admin".to_owned()),
+ ])
+ );
+ assert_eq!(launch(&declaration), first_launch);
+ // Launch types never show a secret in their `Debug` form.
+ assert!(!format!("{first_launch:?}").contains(&generated_password));
+ drop(controller);
+
+ // A restarted control plane reads the retained value back and never generates again.
+ let path = directory.join("site.yaml");
+ let mut persisted_site = site();
+ persisted_site.application_deployments = declarations.clone();
+ fs::write(
+ &path,
+ dotcore_proto::yaml::to_string(&persisted_site).expect("site YAML"),
+ )
+ .expect("write site YAML");
+ let reloaded = ApplicationDeclarationRepository::snapshot(
+ &SiteRepository::load(&path, crate::test_support::site_declaration_rules())
+ .expect("reloaded site repository"),
+ )
+ .expect("validated declarations after reload");
+ assert_eq!(reloaded.deployments, declarations);
+ let reloaded_declaration = reloaded
+ .deployments
+ .iter()
+ .find(|declaration| {
+ declaration.application_deployment_id == generated.application_deployment_id
+ })
+ .expect("reloaded declaration");
+ let reloaded_launch = launch(reloaded_declaration);
+ assert_eq!(reloaded_launch, first_launch);
+ assert_eq!(
+ reloaded_launch.correlation.reconcile_generation,
+ first_launch.correlation.reconcile_generation
+ );
+ fs::remove_dir_all(directory).expect("test directory cleanup");
+}
+
+#[tokio::test]
+async fn invalid_secret_and_text_values_reject_creation_without_quoting_them() {
+ let directory = test_directory();
+ let (controller, desired) =
+ secret_parameter_test_controller(&directory, test_secret_generator());
+ let cases = [
+ (
+ string_parameter("password", ""),
+ "Application parameter \"password\" has an invalid value: it is shorter than the minimum length of 12",
+ ),
+ (
+ string_parameter("password", "short-pass"),
+ "Application parameter \"password\" has an invalid value: it is shorter than the minimum length of 12",
+ ),
+ (
+ string_parameter("password", "typed=password-1"),
+ "Application parameter \"password\" has an invalid value: it contains `=` or a control character",
+ ),
+ (
+ string_parameter("password", "typed-password\n1"),
+ "Application parameter \"password\" has an invalid value: it contains `=` or a control character",
+ ),
+ (
+ string_parameter("username", ""),
+ "Application parameter \"username\" has an invalid value: it is shorter than the minimum length of 1",
+ ),
+ (
+ string_parameter("username", &"u".repeat(65)),
+ "Application parameter \"username\" has an invalid value: it is longer than the maximum length of 64",
+ ),
+ ];
+
+ for (value, expected) in cases {
+ let error = controller
+ .create_deployment(parameter_request(vec![value.clone()]))
+ .await
+ .expect_err("invalid parameter value");
+ assert!(matches!(
+ error,
+ ApplicationLifecycleError::InvalidParameter { .. }
+ ));
+ assert_eq!(error.to_string(), expected);
+ if let Some(parameter_value::Value::String(value)) = &value.value
+ && !value.is_empty()
+ {
+ assert!(!format!("{error:?}").contains(value.as_str()));
+ }
+ }
+ assert!(
+ desired
+ .snapshot()
+ .expect("declaration snapshot")
+ .deployments
+ .is_empty()
+ );
+ drop(controller);
+ std::fs::remove_dir_all(directory).expect("test directory cleanup");
+}
+
+fn secret_parameter_test_controller(
+ directory: &Path,
+ secret_generator: Arc,
+) -> (
+ TestController,
+ TestApplicationDeclarationRepository,
+) {
+ test_controller_for(
+ directory,
+ application_with_secret_parameters(),
+ secret_generator,
+ )
+}
+
fn parameter_test_controller(
directory: &Path,
) -> (
TestController,
TestApplicationDeclarationRepository,
+) {
+ test_controller_for(
+ directory,
+ application_with_parameters(),
+ test_secret_generator(),
+ )
+}
+
+fn test_controller_for(
+ directory: &Path,
+ application: dotcore_proto::dotcore::catalog::v1::Application,
+ secret_generator: Arc,
+) -> (
+ TestController,
+ TestApplicationDeclarationRepository,
) {
let desired = TestApplicationDeclarationRepository::open(directory, "site-one")
.expect("declaration repository");
@@ -7979,7 +8227,7 @@ fn parameter_test_controller(
let controller = TestController::new(
Arc::new(
CatalogBuilder::new()
- .application("test", application_with_parameters())
+ .application("test", application)
.build()
.expect("Catalog"),
),
@@ -7990,6 +8238,7 @@ fn parameter_test_controller(
target_capabilities: test_target_capability_source(),
configuration_sources: ApplicationConfigurationSources::default(),
inference_route_resolver: test_inference_route_resolver(),
+ secret_generator,
},
)
.expect("Apps controller");
diff --git a/backend/core/src/application/mod.rs b/backend/core/src/application/mod.rs
index 6b5be8b2..7bc0cfbc 100644
--- a/backend/core/src/application/mod.rs
+++ b/backend/core/src/application/mod.rs
@@ -15,5 +15,6 @@ pub use controller::{
ApplicationConfigurationSources, ApplicationController, ApplicationControllerConfiguration,
ApplicationDeploymentLogController, ApplicationLifecycleController, ApplicationLifecycleError,
InferenceRouteResolutionError, InferenceRouteResolver, ParameterRejection,
- ResolvedInferenceRoute, RetainedTargetMismatch, TargetIneligibility,
+ RandomSecretGenerator, ResolvedInferenceRoute, RetainedTargetMismatch, SecretGenerator,
+ TargetIneligibility,
};
diff --git a/backend/core/src/application/repository.rs b/backend/core/src/application/repository.rs
index 8acb5d9e..84f3d005 100644
--- a/backend/core/src/application/repository.rs
+++ b/backend/core/src/application/repository.rs
@@ -3,8 +3,9 @@
use std::collections::BTreeSet;
use dotcore_catalog::{
- OciArtifactPolicy, typed_parameters::canonical_value_text, valid_configuration_value,
- valid_environment_variable, validate_application_with_policy,
+ OciArtifactPolicy,
+ typed_parameters::{canonical_value_text, is_secret},
+ valid_configuration_value, valid_environment_variable, validate_application_with_policy,
};
use dotcore_domain::{
deployment::{
@@ -17,11 +18,11 @@ use dotcore_proto::dotcore::{
application::v1::{
ApplicationConfigurationAttachment, ApplicationDeploymentDeclaration,
ApplicationDeploymentDesiredState, ApplicationLifecycleState, ApplicationReadinessState,
- ApplicationServiceLifecycleState, ApplicationStorageAttachment,
- CurrentApplicationDeployment, ResolvedApplicationDeploymentSnapshot,
- ResolvedApplicationServiceSelection,
+ ApplicationSecretAttachment, ApplicationServiceLifecycleState,
+ ApplicationStorageAttachment, CurrentApplicationDeployment,
+ ResolvedApplicationDeploymentSnapshot, ResolvedApplicationServiceSelection,
},
- catalog::v1::application_configuration_binding,
+ catalog::v1::{ApplicationSecretForm, application_configuration_binding},
site::store::v1::SiteCurrentState,
};
use dotcore_site::{
@@ -589,6 +590,7 @@ pub(crate) fn validate_declaration(
validate_storage_attachments(deployment)?;
validate_inference_route_binding(deployment)?;
validate_configuration_attachments(deployment)?;
+ validate_secret_attachments(deployment)?;
Ok(())
}
@@ -830,6 +832,79 @@ fn validate_configuration_attachments(
Ok(())
}
+/// Checks that every secret binding has exactly one attachment carrying its parameter's retained
+/// value in the binding's form, and that the generated parameters are distinct retained secrets.
+fn validate_secret_attachments(
+ deployment: &ApplicationDeploymentDeclaration,
+) -> Result<(), ApplicationRepositoryError> {
+ let snapshot = deployment
+ .snapshot
+ .as_ref()
+ .expect("Application declaration snapshot was validated");
+ let graph = snapshot
+ .graph
+ .as_ref()
+ .expect("Application snapshot was validated");
+ let mut expected = BTreeSet::new();
+ for service in &graph.services {
+ for binding in &service.secret_bindings {
+ let value = canonical_value_text(&snapshot.parameter_values, &binding.parameter_id)
+ .ok_or(ApplicationRepositoryError::InvalidState)?;
+ let value = match ApplicationSecretForm::try_from(binding.form) {
+ Ok(ApplicationSecretForm::Plain) => value,
+ Ok(ApplicationSecretForm::Unspecified) | Err(_) => {
+ return Err(ApplicationRepositoryError::InvalidState);
+ }
+ };
+ expected.insert((
+ service.service_name.as_str(),
+ binding.binding_name.as_str(),
+ binding.environment_variable.as_str(),
+ value,
+ ));
+ }
+ }
+ let mut actual = BTreeSet::new();
+ for ApplicationSecretAttachment {
+ service_name,
+ binding_name,
+ environment_variable,
+ value,
+ } in &deployment.secret_attachments
+ {
+ if !valid_configuration_value(value)
+ || !actual.insert((
+ service_name.as_str(),
+ binding_name.as_str(),
+ environment_variable.as_str(),
+ value.clone(),
+ ))
+ {
+ return Err(ApplicationRepositoryError::InvalidState);
+ }
+ }
+ if actual != expected {
+ return Err(ApplicationRepositoryError::InvalidState);
+ }
+
+ let mut generated = BTreeSet::new();
+ for id in &snapshot.generated_parameter_ids {
+ let retained_secret = graph
+ .parameters
+ .iter()
+ .find(|definition| definition.parameter_id == *id)
+ .is_some_and(is_secret)
+ && snapshot
+ .parameter_values
+ .iter()
+ .any(|value| value.parameter_id == *id);
+ if !retained_secret || !generated.insert(id.as_str()) {
+ return Err(ApplicationRepositoryError::InvalidState);
+ }
+ }
+ Ok(())
+}
+
fn validate_inference_route_binding(
deployment: &ApplicationDeploymentDeclaration,
) -> Result<(), ApplicationRepositoryError> {
@@ -1047,14 +1122,15 @@ mod tests {
use dotcore_proto::dotcore::{
application::v1::{
ApplicationConfigurationAttachment, ApplicationDeploymentDeclaration,
- ApplicationDeploymentDesiredState, ResolvedApplicationDeploymentSnapshot,
- ResolvedApplicationServiceSelection,
+ ApplicationDeploymentDesiredState, ApplicationSecretAttachment,
+ ResolvedApplicationDeploymentSnapshot, ResolvedApplicationServiceSelection,
},
catalog::v1::{
Application, ApplicationConfigurationBinding, ApplicationEndpointProtocol,
- ApplicationGraph, ApplicationPrimaryEndpoint, ApplicationService,
- ApplicationServicePort, ParameterDefinition, ParameterValue, ParameterVisibility,
- StringEnumParameterSchema, application_configuration_binding,
+ ApplicationGraph, ApplicationPrimaryEndpoint, ApplicationSecretBinding,
+ ApplicationSecretForm, ApplicationService, ApplicationServicePort, ParameterDefinition,
+ ParameterValue, ParameterVisibility, SecretParameterSchema, StringEnumParameterSchema,
+ TextParameterSchema, application_configuration_binding,
parameter_definition::ParameterType, parameter_value,
},
deployment::v1::{ArtifactPreparationPolicy, NodeResolvedLaunchIdentity},
@@ -1272,6 +1348,97 @@ mod tests {
));
}
+ #[test]
+ fn secret_attachment_must_carry_its_retained_value_and_generated_ids_name_secrets() {
+ let with_secret = |attachment_value: &str, generated: &[&str]| {
+ let mut deployment = valid_deployment();
+ let snapshot = deployment.snapshot.as_mut().expect("snapshot");
+ let graph = snapshot.graph.as_mut().expect("graph");
+ graph.parameters = vec![
+ ParameterDefinition {
+ parameter_id: "username".to_owned(),
+ display_name: "Username".to_owned(),
+ description: String::new(),
+ parameter_type: Some(ParameterType::Text(TextParameterSchema {
+ minimum_length: 1,
+ maximum_length: 64,
+ default_value: Some("admin".to_owned()),
+ })),
+ visibility: ParameterVisibility::Basic as i32,
+ },
+ ParameterDefinition {
+ parameter_id: "password".to_owned(),
+ display_name: "Password".to_owned(),
+ description: String::new(),
+ parameter_type: Some(ParameterType::Secret(SecretParameterSchema {
+ generated_length: 32,
+ minimum_length: 12,
+ })),
+ visibility: ParameterVisibility::Basic as i32,
+ },
+ ];
+ graph.services[0].configuration_bindings = vec![ApplicationConfigurationBinding {
+ binding_name: "username".to_owned(),
+ environment_variable: "USERNAME".to_owned(),
+ source: Some(application_configuration_binding::Source::ParameterId(
+ "username".to_owned(),
+ )),
+ input: None,
+ }];
+ graph.services[0].secret_bindings = vec![ApplicationSecretBinding {
+ binding_name: "password".to_owned(),
+ environment_variable: "PASSWORD".to_owned(),
+ parameter_id: "password".to_owned(),
+ form: ApplicationSecretForm::Plain as i32,
+ }];
+ snapshot.parameter_values = vec![
+ ParameterValue {
+ parameter_id: "username".to_owned(),
+ value: Some(parameter_value::Value::String("admin".to_owned())),
+ },
+ ParameterValue {
+ parameter_id: "password".to_owned(),
+ value: Some(parameter_value::Value::String(
+ "retained-password".to_owned(),
+ )),
+ },
+ ];
+ snapshot.generated_parameter_ids =
+ generated.iter().map(|id| (*id).to_owned()).collect();
+ deployment.configuration_attachments = vec![ApplicationConfigurationAttachment {
+ service_name: "service".to_owned(),
+ binding_name: "username".to_owned(),
+ environment_variable: "USERNAME".to_owned(),
+ value: "admin".to_owned(),
+ }];
+ deployment.secret_attachments = vec![ApplicationSecretAttachment {
+ service_name: "service".to_owned(),
+ binding_name: "password".to_owned(),
+ environment_variable: "PASSWORD".to_owned(),
+ value: attachment_value.to_owned(),
+ }];
+ deployment
+ };
+
+ assert!(validate_declarations(&[with_secret("retained-password", &["password"])]).is_ok());
+ assert!(validate_declarations(&[with_secret("retained-password", &[])]).is_ok());
+ for invalid in [
+ with_secret("other-password-1", &["password"]),
+ with_secret("retained-password", &["username"]),
+ with_secret("retained-password", &["password", "password"]),
+ {
+ let mut missing = with_secret("retained-password", &[]);
+ missing.secret_attachments.clear();
+ missing
+ },
+ ] {
+ assert!(matches!(
+ validate_declarations(&[invalid]),
+ Err(ApplicationRepositoryError::InvalidState)
+ ));
+ }
+ }
+
#[test]
fn resolved_snapshot_validation_rejects_incomplete_or_ambiguous_selection_contracts() {
let valid = valid_deployment();
@@ -1515,6 +1682,7 @@ mod tests {
launch_identity: "example.test/application@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_owned(),
}],
parameter_values: Vec::new(),
+ generated_parameter_ids: Vec::new(),
}
}
diff --git a/backend/core/src/composition/mod.rs b/backend/core/src/composition/mod.rs
index 0a955a0e..609c4a3f 100644
--- a/backend/core/src/composition/mod.rs
+++ b/backend/core/src/composition/mod.rs
@@ -59,7 +59,7 @@ use tracing::{Instrument, Span, debug, error, field, info, info_span, warn};
use crate::{
application::{
ApplicationConfigurationSources, ApplicationController, ApplicationControllerConfiguration,
- service::ApplicationService,
+ RandomSecretGenerator, service::ApplicationService,
},
model::{controller::InferenceController, service::ModelDeploymentService},
route_manager::{
@@ -370,6 +370,7 @@ where
),
inference_route_resolver: inference_route_resolver(routing.route_manager.clone()),
target_capabilities,
+ secret_generator: Arc::new(RandomSecretGenerator),
},
log_admission,
)?;
diff --git a/backend/core/src/management_logs.rs b/backend/core/src/management_logs.rs
index 8f89e875..e755c19b 100644
--- a/backend/core/src/management_logs.rs
+++ b/backend/core/src/management_logs.rs
@@ -168,31 +168,35 @@ impl DeploymentLogFollowQuery {
}
/// Request-time values that must be removed from deployment-log output.
+///
+/// Values are distinct and kept longest first, so a value that contains a shorter one, such as a
+/// password containing the username, is replaced whole before the shorter one could split it.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ProtectedLogValues(Vec>);
impl ProtectedLogValues {
pub fn new(values: impl IntoIterator- ) -> Self {
- Self(
- values
- .into_iter()
- .filter(|value| !value.is_empty())
- .map(String::into_bytes)
- .collect(),
- )
+ let mut protected = Self::default();
+ protected.insert(values.into_iter().map(String::into_bytes));
+ protected
}
+ /// Returns the values longest first.
pub fn values(&self) -> &[Vec
] {
&self.0
}
/// Adds the values of `other` that are not protected yet.
pub(crate) fn extend(&mut self, other: ProtectedLogValues) {
- for value in other.0 {
- if !self.0.contains(&value) {
- self.0.push(value);
- }
- }
+ self.insert(other.0);
+ }
+
+ fn insert(&mut self, values: impl IntoIterator- >) {
+ self.0
+ .extend(values.into_iter().filter(|value| !value.is_empty()));
+ self.0
+ .sort_by(|left, right| right.len().cmp(&left.len()).then_with(|| left.cmp(right)));
+ self.0.dedup();
}
/// Returns `text` with every protected value replaced by the redaction marker, for free text
@@ -206,20 +210,9 @@ impl ProtectedLogValues {
}
}
-/// Names whose presence marks an environment variable or launch flag as carrying a secret value.
-const SENSITIVE_NAMES: [&str; 5] = ["TOKEN", "SECRET", "PASSWORD", "API_KEY", "CREDENTIAL"];
-
/// Stands in for the value of a sensitive launch flag in recorded custom arguments.
const MASKED_ARGUMENT_VALUE: &str = "
";
-/// Whether an environment variable name contains a sensitive name anywhere.
-pub(crate) fn is_sensitive_environment_name(name: &str) -> bool {
- let name = name.to_ascii_uppercase();
- SENSITIVE_NAMES
- .iter()
- .any(|sensitive| name.contains(sensitive))
-}
-
/// Masks the value of every long flag whose name contains a sensitive name as whole words, in the
/// `--flag value` and `--flag=value` forms, keeping the flag and the token order. Best effort:
/// short aliases and secrets without a sensitive flag name are not detected.
@@ -239,7 +232,7 @@ pub(crate) fn mask_sensitive_arguments(tokens: &[String]) -> Vec {
Some((name, _)) => (name, true),
None => (flag, false),
};
- if !is_sensitive_flag_name(name) {
+ if !dotcore_catalog::is_sensitive_name(name) {
masked.push(token.clone());
} else if has_value {
masked.push(format!("--{name}={MASKED_ARGUMENT_VALUE}"));
@@ -251,22 +244,6 @@ pub(crate) fn mask_sensitive_arguments(tokens: &[String]) -> Vec {
masked
}
-fn is_sensitive_flag_name(name: &str) -> bool {
- let words = name
- .split(['-', '_'])
- .map(str::to_ascii_uppercase)
- .collect::>();
- SENSITIVE_NAMES.iter().any(|sensitive| {
- let sensitive_words = sensitive.split('_').collect::>();
- words.windows(sensitive_words.len()).any(|window| {
- window
- .iter()
- .map(String::as_str)
- .eq(sensitive_words.iter().copied())
- })
- })
-}
-
/// Public-safe context attached to one internal deployment-log diagnostic.
#[derive(Clone, Debug)]
pub(crate) struct DeploymentLogDiagnosticContext {
@@ -433,13 +410,13 @@ pub(crate) fn filter_entry(
mut entry: RuntimeLogEntry,
protected: &ProtectedLogValues,
) -> RuntimeLogEntry {
- let truncated_by_runtime = entry.truncated;
+ // The trailing part goes first, so a shorter value it contains can't split it.
+ if entry.truncated {
+ redact_trailing_partial_value(&mut entry.content, protected);
+ }
for value in protected.values() {
entry.content = filter_value(&entry.content, value, &mut entry.truncated);
}
- if truncated_by_runtime {
- redact_trailing_partial_value(&mut entry.content, protected);
- }
if entry.content.len() > MAX_LOG_ENTRY_CONTENT_BYTES {
entry.content.truncate(MAX_LOG_ENTRY_CONTENT_BYTES);
entry.truncated = true;
@@ -1043,13 +1020,6 @@ mod tests {
}
}
- #[test]
- fn environment_names_match_sensitive_names_anywhere_in_the_name() {
- assert!(is_sensitive_environment_name("hf_token"));
- assert!(is_sensitive_environment_name("MY_API_KEY_FILE"));
- assert!(!is_sensitive_environment_name("CUDA_VISIBLE_DEVICES"));
- }
-
#[test]
fn unset_page_size_takes_the_default_and_bounded_sizes_pass_through() {
for (requested, expected) in [
@@ -1237,6 +1207,41 @@ mod tests {
);
}
+ #[test]
+ fn a_shorter_protected_value_never_splits_a_longer_one() {
+ // The shorter value comes first, as a username listed before the password would.
+ let protected = ProtectedLogValues::new([
+ "admin".to_owned(),
+ "admin-Sup3rPass".to_owned(),
+ "admin".to_owned(),
+ ]);
+ assert_eq!(
+ protected.values(),
+ [b"admin-Sup3rPass".to_vec(), b"admin".to_vec()]
+ );
+
+ let filtered = filter_entry(
+ entry(source("web"), 1, b"login admin admin-Sup3rPass"),
+ &protected,
+ );
+ assert_eq!(filtered.content, b"login [REDACTED] [REDACTED]");
+ assert_eq!(
+ protected.redact("rejected admin-Sup3rPass for admin"),
+ "rejected [REDACTED] for [REDACTED]"
+ );
+
+ let mut cut = entry(source("web"), 2, b"login admin admin-Sup");
+ cut.truncated = true;
+ assert_eq!(
+ filter_entry(cut, &protected).content,
+ b"login [REDACTED] [REDACTED]"
+ );
+
+ let mut extended = ProtectedLogValues::new(["1".to_owned()]);
+ extended.extend(ProtectedLogValues::new(["key-1-secret".to_owned()]));
+ assert_eq!(extended.redact("key-1-secret"), "[REDACTED]");
+ }
+
#[test]
fn empty_protected_values_still_bound_entry_content() {
let filtered = filter_entry(
diff --git a/backend/core/src/model/controller/logs.rs b/backend/core/src/model/controller/logs.rs
index 33c53600..fa56937c 100644
--- a/backend/core/src/model/controller/logs.rs
+++ b/backend/core/src/model/controller/logs.rs
@@ -1129,18 +1129,19 @@ mod tests {
.await
.expect("fresh protected values");
+ // Protected values are kept longest first.
assert_eq!(
first.values(),
[
- b"first-secret".to_vec(),
- b"https://private.example/v1".to_vec()
+ b"https://private.example/v1".to_vec(),
+ b"first-secret".to_vec()
]
);
assert_eq!(
replacement.values(),
[
- b"replacement-secret".to_vec(),
- b"https://private.example/v1".to_vec()
+ b"https://private.example/v1".to_vec(),
+ b"replacement-secret".to_vec()
]
);
assert!(
diff --git a/backend/core/src/model/controller/reconciliation/mod.rs b/backend/core/src/model/controller/reconciliation/mod.rs
index a3cca676..e287d18b 100644
--- a/backend/core/src/model/controller/reconciliation/mod.rs
+++ b/backend/core/src/model/controller/reconciliation/mod.rs
@@ -3416,7 +3416,7 @@ fn protected_startup_values(
// literal segment of a sensitive value is protected on its own.
let values = environment
.iter()
- .filter(|(key, _)| crate::management_logs::is_sensitive_environment_name(key))
+ .filter(|(key, _)| dotcore_catalog::is_sensitive_name(key))
.flat_map(|(_, value)| value.segments())
.filter_map(|segment| match segment {
dotcore_runtime::WorkloadValueSegment::Literal(literal) if !literal.is_empty() => {
diff --git a/backend/core/src/model/engine/vllm/declaration.rs b/backend/core/src/model/engine/vllm/declaration.rs
index 84263805..1a7739dc 100644
--- a/backend/core/src/model/engine/vllm/declaration.rs
+++ b/backend/core/src/model/engine/vllm/declaration.rs
@@ -62,7 +62,12 @@ pub(crate) fn validate_engine_release(engine: &EngineRelease) -> Vec= range.minimum && !range.unit.is_empty()
}
Some(ParameterType::Boolean(_)) => true,
- Some(ParameterType::StringEnum(_)) | None => false,
+ Some(
+ ParameterType::StringEnum(_)
+ | ParameterType::Text(_)
+ | ParameterType::Secret(_),
+ )
+ | None => false,
}
})
});
diff --git a/backend/core/tests/gated/docker_application_integration.rs b/backend/core/tests/gated/docker_application_integration.rs
index 13e734c3..3d3e4cd2 100644
--- a/backend/core/tests/gated/docker_application_integration.rs
+++ b/backend/core/tests/gated/docker_application_integration.rs
@@ -83,6 +83,9 @@ const PYTHON_DIGEST: &str = "b3575dcf816cdacc5292df77a6e3e59b8941631078373a045ef
const ARM64_DEFAULT_VARIANT: &str = "v8";
const LIFECYCLE_APPLICATION_ID: &str = "synthetic-lifecycle";
const STORAGE_SCOPES_APPLICATION_ID: &str = "synthetic-storage-scopes";
+const SECRET_APPLICATION_ID: &str = "secret-fixture";
+/// A typed secret value no other site content contains.
+const SECRET_SENTINEL: &str = "SentinelQv8Rk2Ld7Wn4";
const LOCAL_IMAGE_REPOSITORY: &str = "dotcore-test/synthetic-lifecycle";
const UNROUTED_VALUE: &str = "unrouted";
const SUPPORTED_PLATFORMS: &str = " supportedPlatforms:\n - operatingSystem: \"linux\"\n architecture: \"amd64\"\n - operatingSystem: \"linux\"\n architecture: \"arm64\"\n";
@@ -1723,6 +1726,210 @@ async fn hw_docker_lightweight_application_logs_are_owned_filtered_and_cancellab
}
}
+#[tokio::test]
+#[ignore = "hardware: just backend::test-docker"]
+async fn hw_docker_secret_parameter_reaches_only_the_container_and_site_yaml() {
+ // Debug-level records reach the site's retained logs, which the sentinel search covers.
+ dotcore::logging::initialize_with_default_filter(
+ "info,dotcore=debug,dotcore_runtime=debug,dotcore_runtime_backends=debug,dotcore_site=debug",
+ );
+ let resources = TestResources::new_log_fixture()
+ .await
+ .expect("Docker preflight and temporary site");
+ resources
+ .write_secret_application()
+ .expect("secret application catalog declaration");
+ let backend = support::RunningBackend::start_at_with_simulated_runtime_test_support(
+ resources.configuration.clone(),
+ resources.publication_host.into(),
+ dotcore_runtime_backends::simulated::SimulatedRuntimeTestSupport::with_stop_failures(0),
+ )
+ .await;
+ let result = async {
+ let mut applications = backend.application_client();
+ let serving =
+ create_when_target_observed(&mut applications, secret_deployment_request("serve"))
+ .await?;
+ let serving_id = serving.application_deployment_id.clone();
+ let failing =
+ create_when_target_observed(&mut applications, secret_deployment_request("exit"))
+ .await?;
+ let failing_id = failing.application_deployment_id.clone();
+
+ wait_for_endpoint_client(&mut applications, &serving_id).await?;
+ let container = resources.running_container_id_for_group(&serving_id, "web")?;
+ let environment = container_environment(&container)?;
+ if !environment.contains(&format!("PASSWORD={SECRET_SENTINEL}"))
+ || !environment.contains(&"USERNAME=admin".to_owned())
+ {
+ return Err(format!(
+ "the container environment lacks the secret or the username: {} entries",
+ environment.len()
+ ));
+ }
+ // The serving unit prints the username and the secret; deployment logs redact every
+ // launch value.
+ let serving_logs = read_logs_containing(
+ &mut applications,
+ &serving_id,
+ "login [REDACTED] [REDACTED]",
+ )
+ .await?;
+ assert_no_sentinel("serving deployment logs", &serving_logs)?;
+
+ // The failing unit prints the secret, then exits before it ever serves.
+ let failing_logs =
+ read_logs_containing(&mut applications, &failing_id, "stopping with [REDACTED]")
+ .await?;
+ assert_no_sentinel("failing deployment logs", &failing_logs)?;
+ // An exiting unit leaves the deployment unavailable without a recorded failure.
+ let exited = applications
+ .get_application_deployment(GetApplicationDeploymentRequest {
+ application_deployment_id: failing_id.clone(),
+ })
+ .await
+ .map_err(|error| format!("read failing deployment: {error}"))?
+ .into_inner()
+ .deployment
+ .ok_or_else(|| "failing deployment read returned no deployment".to_owned())?;
+ let state = exited
+ .state
+ .as_ref()
+ .ok_or_else(|| "failing deployment has no state".to_owned())?;
+ if state.lifecycle_state == ApplicationLifecycleState::Ready as i32 {
+ return Err("the failing deployment became ready".to_owned());
+ }
+ assert_no_sentinel("failing deployment state", &format!("{state:?}"))?;
+ Ok::<_, String>((serving_id, failing_id, container))
+ }
+ .await;
+ backend.stop().await;
+ if result.is_ok() {
+ wait_for_backend_service_release(&resources.configuration)
+ .await
+ .expect("release backend repositories before the restart");
+ }
+ let result = match result {
+ Ok((serving_id, failing_id, container)) => {
+ let restarted = support::RunningBackend::start_at_with_simulated_runtime_test_support(
+ resources.configuration.clone(),
+ resources.publication_host.into(),
+ dotcore_runtime_backends::simulated::SimulatedRuntimeTestSupport::with_stop_failures(0),
+ )
+ .await;
+ let restart_result = async {
+ let mut applications = restarted.application_client();
+ let retained = wait_for_endpoint_client(&mut applications, &serving_id).await?;
+ let password = retained
+ .snapshot
+ .as_ref()
+ .and_then(|snapshot| {
+ snapshot
+ .parameter_values
+ .iter()
+ .find(|value| value.parameter_id == "password")
+ })
+ .and_then(|value| value.value.clone());
+ if password
+ != Some(
+ dotcore_proto::dotcore::catalog::v1::parameter_value::Value::String(
+ SECRET_SENTINEL.to_owned(),
+ ),
+ )
+ {
+ return Err("the restart changed the retained secret".to_owned());
+ }
+ let restarted_container =
+ resources.running_container_id_for_group(&serving_id, "web")?;
+ if restarted_container != container {
+ return Err(format!(
+ "the restart replaced the container {container} with {restarted_container}"
+ ));
+ }
+ // A launch refused on a taken published port records failure text and its cause.
+ let port = endpoint_port(&endpoint_url(&retained)?)?;
+ applications
+ .pause_deployment(PauseDeploymentRequest {
+ application_deployment_id: serving_id.clone(),
+ })
+ .await
+ .map_err(|error| format!("pause secret deployment: {error}"))?;
+ wait_for_application_lifecycle_client(
+ &mut applications,
+ &serving_id,
+ ApplicationLifecycleState::Paused,
+ )
+ .await?;
+ let holder = std::net::TcpListener::bind((resources.publication_host, port))
+ .map_err(|error| format!("hold published port {port}: {error}"))?;
+ applications
+ .resume_deployment(ResumeDeploymentRequest {
+ application_deployment_id: serving_id.clone(),
+ })
+ .await
+ .map_err(|error| format!("resume secret deployment: {error}"))?;
+ let refused =
+ wait_for_failure_code(&mut applications, &serving_id, "PublishedPortInUse")
+ .await?;
+ drop(holder);
+ let failure = refused
+ .state
+ .and_then(|state| state.failure)
+ .ok_or_else(|| "the refused launch recorded no failure".to_owned())?;
+ if failure.message.is_empty() {
+ return Err("the refused launch recorded no failure text".to_owned());
+ }
+ assert_no_sentinel("refused launch failure", &format!("{failure:?}"))?;
+
+ // Every retained site file but the declarations, durable debug logs included,
+ // lacks the secret; the declarations keep it, which proves the search reads them.
+ let debug_records = support::retained_json_records(&resources.root)
+ .iter()
+ .filter(|record| record["level"] == "DEBUG")
+ .count();
+ if debug_records == 0 {
+ return Err("the site logs retained no debug records".to_owned());
+ }
+ let hits = files_containing(&resources.root, SECRET_SENTINEL.as_bytes())?;
+ if hits != [resources.root.join("site.yaml")] {
+ return Err(format!(
+ "the secret sentinel is retained in {hits:?}, not only site.yaml"
+ ));
+ }
+ for deployment_id in [&serving_id, &failing_id] {
+ applications
+ .remove_application_deployment(RemoveApplicationDeploymentRequest {
+ application_deployment_id: deployment_id.clone(),
+ })
+ .await
+ .map_err(|error| format!("remove secret deployment: {error}"))?;
+ }
+ // The site-wide Docker check passes only once both groups are gone.
+ for deployment_id in [&serving_id, &failing_id] {
+ wait_for_no_docker_resources_client(
+ &resources,
+ &mut applications,
+ deployment_id,
+ )
+ .await?;
+ }
+ Ok(())
+ }
+ .await;
+ restarted.stop().await;
+ restart_result
+ }
+ Err(error) => Err(error),
+ };
+ let cleanup = resources.cleanup();
+ match (result, cleanup) {
+ (Ok(()), Ok(())) => {}
+ (Err(primary), Ok(())) => panic!("{primary}"),
+ (Ok(()), Err(cleanup)) => panic!("cleanup: {cleanup}"),
+ (Err(primary), Err(cleanup)) => panic!("{primary}; cleanup: {cleanup}"),
+ }
+}
+
/// Configuration values the lifecycle application's container must render.
struct ConfigurationExpectation {
inference_base_url: String,
@@ -1786,6 +1993,141 @@ async fn create_when_target_observed(
.map_err(|_| "timed out waiting for the application target platform observation".to_owned())?
}
+/// Requests a secret fixture deployment with the typed sentinel password and `outcome`.
+fn secret_deployment_request(outcome: &str) -> CreateApplicationDeploymentRequest {
+ let text = |id: &str, value: &str| dotcore_proto::dotcore::catalog::v1::ParameterValue {
+ parameter_id: id.to_owned(),
+ value: Some(
+ dotcore_proto::dotcore::catalog::v1::parameter_value::Value::String(value.to_owned()),
+ ),
+ };
+ CreateApplicationDeploymentRequest {
+ application_id: SECRET_APPLICATION_ID.to_owned(),
+ placement: Some(support::exact_placement(
+ "docker-apps",
+ "docker-apps-target",
+ )),
+ inference_route_id: None,
+ expected_inference_route_generation: 0,
+ application_deployment_name: None,
+ parameter_values: vec![text("password", SECRET_SENTINEL), text("outcome", outcome)],
+ }
+}
+
+async fn wait_for_endpoint_client(
+ client: &mut ApplicationClient,
+ deployment_id: &str,
+) -> Result {
+ let deadline = Instant::now() + READY_TIMEOUT;
+ loop {
+ let deployment = client
+ .get_application_deployment(GetApplicationDeploymentRequest {
+ application_deployment_id: deployment_id.to_owned(),
+ })
+ .await
+ .map_err(|error| format!("read application deployment: {error}"))?
+ .into_inner()
+ .deployment
+ .ok_or_else(|| "Application read returned no deployment".to_owned())?;
+ if deployment
+ .state
+ .as_ref()
+ .and_then(|state| state.endpoint.as_ref())
+ .is_some()
+ {
+ return Ok(deployment);
+ }
+ if Instant::now() >= deadline {
+ return Err(format!(
+ "timed out waiting for an application endpoint; last state {:?}",
+ deployment.state
+ ));
+ }
+ sleep(Duration::from_millis(250)).await;
+ }
+}
+
+/// Reads a deployment's log history until one entry contains `expected`, and returns every entry
+/// of that read joined by line breaks.
+async fn read_logs_containing(
+ client: &mut ApplicationClient,
+ deployment_id: &str,
+ expected: &str,
+) -> Result {
+ let deadline = Instant::now() + LOG_HISTORY_TIMEOUT;
+ loop {
+ let history = client
+ .read_deployment_logs(ReadDeploymentLogsRequest {
+ application_deployment_id: deployment_id.to_owned(),
+ ..Default::default()
+ })
+ .await
+ .map_err(|error| format!("read deployment logs: {error}"))?
+ .into_inner();
+ let text = history
+ .entries
+ .iter()
+ .map(|entry| String::from_utf8_lossy(&entry.content).into_owned())
+ .collect::>()
+ .join("\n");
+ if text.lines().any(|line| line.contains(expected)) {
+ return Ok(text);
+ }
+ if Instant::now() >= deadline {
+ return Err(format!(
+ "timed out waiting for log line {expected:?}; last read {:?}",
+ text.replace(SECRET_SENTINEL, "")
+ ));
+ }
+ sleep(Duration::from_millis(250)).await;
+ }
+}
+
+fn assert_no_sentinel(surface: &str, text: &str) -> Result<(), String> {
+ if text.contains(SECRET_SENTINEL) {
+ Err(format!("the secret sentinel appears in the {surface}"))
+ } else {
+ Ok(())
+ }
+}
+
+/// Returns the environment of one container as `NAME=value` entries.
+fn container_environment(container: &str) -> Result, String> {
+ serde_json::from_str(&command(
+ "docker",
+ ["inspect", "--format", "{{json .Config.Env}}", container],
+ )?)
+ .map_err(|error| format!("decode container environment: {error}"))
+}
+
+/// Returns every file under `root` whose bytes contain `needle`.
+fn files_containing(root: &Path, needle: &[u8]) -> Result, String> {
+ let mut hits = Vec::new();
+ let mut pending = vec![root.to_path_buf()];
+ while let Some(directory) = pending.pop() {
+ let entries = fs::read_dir(&directory)
+ .map_err(|error| format!("list {}: {error}", directory.display()))?;
+ for entry in entries {
+ let path = entry
+ .map_err(|error| format!("list {}: {error}", directory.display()))?
+ .path();
+ let file_type = fs::symlink_metadata(&path)
+ .map_err(|error| format!("inspect {}: {error}", path.display()))?
+ .file_type();
+ if file_type.is_dir() {
+ pending.push(path);
+ } else if file_type.is_file() {
+ let bytes =
+ fs::read(&path).map_err(|error| format!("read {}: {error}", path.display()))?;
+ if bytes.windows(needle.len()).any(|window| window == needle) {
+ hits.push(path);
+ }
+ }
+ }
+ }
+ Ok(hits)
+}
+
async fn wait_for_lifecycle_runtime(
resources: &TestResources,
client: &mut ApplicationClient,
@@ -3240,6 +3582,70 @@ graph:
)
}
+ /// Declares a one-service application with a text username, a secret password and an
+ /// outcome choice. Its unit prints the password, then serves, or exits when told to.
+ fn write_secret_application(&self) -> Result<(), String> {
+ self.write_site_catalog_application(
+ "secret-fixture.yaml",
+ &format!(
+ r#"applicationId: "{SECRET_APPLICATION_ID}"
+displayName: "Secret fixture"
+version: "1.0.0"
+graph:
+ parameters:
+ - parameterId: "username"
+ displayName: "Username"
+ visibility: "PARAMETER_VISIBILITY_BASIC"
+ text:
+ minimumLength: 1
+ maximumLength: 64
+ defaultValue: "admin"
+ - parameterId: "password"
+ displayName: "Password"
+ visibility: "PARAMETER_VISIBILITY_BASIC"
+ secret:
+ generatedLength: 32
+ minimumLength: 12
+ - parameterId: "outcome"
+ displayName: "Outcome"
+ visibility: "PARAMETER_VISIBILITY_ADVANCED"
+ stringEnum:
+ values: ["serve", "exit"]
+ defaultValue: "serve"
+ services:
+ - serviceName: "web"
+ artifact:
+{artifact} command: ["sh", "-ec"]
+ arguments: ["trap 'exit 0' TERM; printf 'login %s %s\\n' \"$USERNAME\" \"$PASSWORD\"; if [ \"$OUTCOME\" = exit ]; then printf 'stopping with %s\\n' \"$PASSWORD\" >&2; exit 3; fi; mkdir -p /www; printf 'ready\\n' > /www/index.html; httpd -f -p 8080 -h /www & wait $!"]
+ configurationBindings:
+ - bindingName: "username"
+ environmentVariable: "USERNAME"
+ parameterId: "username"
+ - bindingName: "outcome"
+ environmentVariable: "OUTCOME"
+ parameterId: "outcome"
+ secretBindings:
+ - bindingName: "password"
+ environmentVariable: "PASSWORD"
+ parameterId: "password"
+ form: "APPLICATION_SECRET_FORM_PLAIN"
+ ports:
+ - portName: "http"
+ portNumber: 8080
+ readiness:
+ httpGet:
+ portName: "http"
+ path: "/"
+ primaryEndpoint:
+ serviceName: "web"
+ portName: "http"
+ protocol: "APPLICATION_ENDPOINT_PROTOCOL_HTTP"
+"#,
+ artifact = remote_image_artifact(),
+ ),
+ )
+ }
+
fn write_site_catalog_application(
&self,
file_name: &str,
diff --git a/backend/proto/tests/application_contract.rs b/backend/proto/tests/application_contract.rs
index 8a60aeeb..7fa6c69c 100644
--- a/backend/proto/tests/application_contract.rs
+++ b/backend/proto/tests/application_contract.rs
@@ -143,6 +143,7 @@ fn generated_bindings_preserve_application_graph_and_deployment_state() {
launch_identity: "example.test/web@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_owned(),
}],
parameter_values: Vec::new(),
+ generated_parameter_ids: Vec::new(),
}),
desired_state: ApplicationDeploymentDesiredState::Present as i32,
state: Some(ApplicationDeploymentState {
@@ -483,6 +484,7 @@ fn application_descriptors_define_portable_graph_and_owner_separated_records() {
"desired_state",
"storage_attachments",
"configuration_attachments",
+ "secret_attachments",
"inference_route_id",
"lifecycle_generation",
"node_resolved_launch_identities",
@@ -536,6 +538,7 @@ fn application_descriptors_define_portable_graph_and_owner_separated_records() {
"notes",
"graph",
"parameter_values",
+ "generated_parameter_ids",
"placement",
"accelerator_device_ids",
"platform",
@@ -580,6 +583,10 @@ fn application_descriptors_define_portable_graph_and_owner_separated_records() {
!field_names(deployment, "ApplicationDeployment").contains(&"configuration_attachments"),
"resolved configuration must remain private desired state"
);
+ assert!(
+ !field_names(deployment, "ApplicationDeployment").contains(&"secret_attachments"),
+ "secret attachments must remain private desired state"
+ );
assert_eq!(
field_names(current, "SiteCurrentState"),
[
diff --git a/backend/proto/tests/model_contract.rs b/backend/proto/tests/model_contract.rs
index 023e9ac6..1accb42a 100644
--- a/backend/proto/tests/model_contract.rs
+++ b/backend/proto/tests/model_contract.rs
@@ -1022,6 +1022,8 @@ fn model_descriptors_define_typed_configuration_and_resolved_plan_contracts() {
6,
".dotcore.catalog.v1.StringEnumParameterSchema"
),
+ ("text", 8, ".dotcore.catalog.v1.TextParameterSchema"),
+ ("secret", 9, ".dotcore.catalog.v1.SecretParameterSchema"),
]
);
assert_eq!(
diff --git a/backend/proto/tests/site_contract.rs b/backend/proto/tests/site_contract.rs
index 611ce75a..91855074 100644
--- a/backend/proto/tests/site_contract.rs
+++ b/backend/proto/tests/site_contract.rs
@@ -293,6 +293,7 @@ fn complete_site_contract_round_trips_restricted_yaml_and_rejects_unknown_fields
environment_variable: "ENDPOINT".to_owned(),
value: "http://192.0.2.1/v1".to_owned(),
}],
+ secret_attachments: Vec::new(),
inference_route_id: None,
lifecycle_generation: 4,
node_resolved_launch_identities: vec![NodeResolvedLaunchIdentity {
@@ -552,6 +553,7 @@ fn complete_application_snapshot() -> ResolvedApplicationDeploymentSnapshot {
})
.collect(),
parameter_values: Vec::new(),
+ generated_parameter_ids: Vec::new(),
}
}
diff --git a/backend/runtime/src/workload_group.rs b/backend/runtime/src/workload_group.rs
index 1f3b8b63..ec6f9c06 100644
--- a/backend/runtime/src/workload_group.rs
+++ b/backend/runtime/src/workload_group.rs
@@ -2,6 +2,7 @@
use std::{
collections::{BTreeMap, BTreeSet},
+ fmt,
net::Ipv4Addr,
num::NonZeroU16,
time::{Duration, SystemTime},
@@ -245,8 +246,9 @@ pub struct WorkloadGroupLaunch {
pub host_aliases: BTreeSet,
}
-/// One named runtime-neutral OCI unit in a workload group.
-#[derive(Clone, Debug, PartialEq, Eq)]
+/// One named runtime-neutral OCI unit in a workload group. Its environment may carry secrets, so
+/// its `Debug` form names the environment variables without their values.
+#[derive(Clone, PartialEq, Eq)]
pub struct WorkloadUnitLaunch {
/// Exact immutable image identity selected and retained by the owning controller.
pub image: String,
@@ -264,6 +266,26 @@ pub struct WorkloadUnitLaunch {
pub ready_launch_gate: Option,
}
+impl fmt::Debug for WorkloadUnitLaunch {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("WorkloadUnitLaunch")
+ .field("image", &self.image)
+ .field("command", &self.command)
+ .field("arguments", &self.arguments)
+ .field("environment", &self.environment.keys().collect::>())
+ .field(
+ "reconfigurable_environment",
+ &self.reconfigurable_environment,
+ )
+ .field("storage", &self.storage)
+ .field("ports", &self.ports)
+ .field("dependencies", &self.dependencies)
+ .field("ready_launch_gate", &self.ready_launch_gate)
+ .finish()
+ }
+}
+
/// One condition on another named unit in the same group.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WorkloadUnitDependency {
From 7e6e457cbc91c97c23fd8dc8b54fdf78c35ed753 Mon Sep 17 00:00:00 2001
From: Andre-Philippe Paquet
Date: Thu, 12 Nov 2026 18:15:18 -0500
Subject: [PATCH 17/40] unreviewed: feat(backend): print deployment parameters
in dotc application show
---
backend/cli/src/application/mod.rs | 1 +
backend/cli/src/application/parameters.rs | 129 ++++++++++++++++
backend/cli/src/application/show.rs | 34 ++++-
backend/cli/tests/application_contract.rs | 176 ++++++++++++++++++++++
4 files changed, 335 insertions(+), 5 deletions(-)
create mode 100644 backend/cli/src/application/parameters.rs
diff --git a/backend/cli/src/application/mod.rs b/backend/cli/src/application/mod.rs
index e291cd51..fd6508ac 100644
--- a/backend/cli/src/application/mod.rs
+++ b/backend/cli/src/application/mod.rs
@@ -7,6 +7,7 @@
//! stdout, for example `DOTCORE_APPLICATION_DEPLOYMENT_STATE=Ready`.
mod list;
+mod parameters;
mod projection;
mod show;
diff --git a/backend/cli/src/application/parameters.rs b/backend/cli/src/application/parameters.rs
new file mode 100644
index 00000000..fe3b1355
--- /dev/null
+++ b/backend/cli/src/application/parameters.rs
@@ -0,0 +1,129 @@
+//! The parameters of one application deployment, as rows an operator can read.
+//!
+//! Each row carries the parameter's display name and its value, tagged when the value is the
+//! catalog default or was generated. Secret values are masked unless the caller asks to reveal
+//! them.
+
+use dotcore_proto::dotcore::{
+ application::v1::ApplicationDeployment,
+ catalog::v1::{ParameterDefinition, parameter_definition, parameter_value},
+};
+
+use crate::management::single_line;
+
+const SECRET_MASK: &str = "********";
+const EMPTY_TEXT: &str = "Empty";
+
+/// One shown parameter.
+pub(super) struct ParameterRow {
+ /// Stable parameter identity.
+ pub(super) id: String,
+ /// Human-readable parameter label.
+ pub(super) name: String,
+ /// The value with its `(default)` and `(generated)` tags.
+ pub(super) value: String,
+}
+
+/// The deployment's parameters in catalog order. A parameter without a retained value shows its
+/// default, and one with neither is left out.
+pub(super) fn rows(deployment: &ApplicationDeployment, reveal_secrets: bool) -> Vec {
+ let Some(snapshot) = deployment.snapshot.as_ref() else {
+ return Vec::new();
+ };
+ let Some(graph) = snapshot.graph.as_ref() else {
+ return Vec::new();
+ };
+
+ graph
+ .parameters
+ .iter()
+ .filter_map(|definition| {
+ let retained = snapshot
+ .parameter_values
+ .iter()
+ .find(|value| value.parameter_id == definition.parameter_id)
+ .and_then(|value| value.value.as_ref());
+ let default = default_of(definition);
+ let value = retained.or(default.as_ref())?;
+ let is_secret = matches!(
+ definition.parameter_type,
+ Some(parameter_definition::ParameterType::Secret(_))
+ );
+
+ let mut text = if is_secret && !reveal_secrets {
+ SECRET_MASK.to_owned()
+ } else {
+ match single_line(&display(value, definition)) {
+ empty if empty.is_empty() => EMPTY_TEXT.to_owned(),
+ text => text,
+ }
+ };
+ if default.as_ref() == Some(value) {
+ text.push_str(" (default)");
+ }
+ if snapshot
+ .generated_parameter_ids
+ .contains(&definition.parameter_id)
+ {
+ text.push_str(" (generated)");
+ }
+
+ Some(ParameterRow {
+ id: definition.parameter_id.clone(),
+ name: single_line(&definition.display_name),
+ value: text,
+ })
+ })
+ .collect()
+}
+
+/// The record key suffix of a parameter: its id upper-cased, with every non-alphanumeric
+/// character replaced by an underscore.
+pub(super) fn record_key(id: &str) -> String {
+ id.chars()
+ .map(|character| {
+ if character.is_ascii_alphanumeric() {
+ character.to_ascii_uppercase()
+ } else {
+ '_'
+ }
+ })
+ .collect()
+}
+
+fn default_of(definition: &ParameterDefinition) -> Option {
+ use parameter_definition::ParameterType;
+
+ match definition.parameter_type.as_ref()? {
+ ParameterType::Boolean(schema) => schema.default_value.map(parameter_value::Value::Boolean),
+ ParameterType::UnsignedInteger(schema) => schema
+ .default_value
+ .map(parameter_value::Value::UnsignedInteger),
+ ParameterType::StringEnum(schema) => schema
+ .default_value
+ .clone()
+ .map(parameter_value::Value::String),
+ ParameterType::Text(schema) => schema
+ .default_value
+ .clone()
+ .map(parameter_value::Value::String),
+ ParameterType::Secret(_) => None,
+ }
+}
+
+fn display(value: ¶meter_value::Value, definition: &ParameterDefinition) -> String {
+ match value {
+ parameter_value::Value::Boolean(value) => value.to_string(),
+ parameter_value::Value::UnsignedInteger(value) => {
+ match definition.parameter_type.as_ref() {
+ Some(parameter_definition::ParameterType::UnsignedInteger(schema))
+ if !schema.unit.is_empty() =>
+ {
+ format!("{value} {}", schema.unit)
+ }
+ _ => value.to_string(),
+ }
+ }
+ parameter_value::Value::String(value) => value.clone(),
+ }
+}
diff --git a/backend/cli/src/application/show.rs b/backend/cli/src/application/show.rs
index 2406a116..873212d2 100644
--- a/backend/cli/src/application/show.rs
+++ b/backend/cli/src/application/show.rs
@@ -8,7 +8,7 @@ use clap::Args;
use dotcore_proto::dotcore::application::v1::ApplicationDeployment;
use super::{
- node_of, placement,
+ node_of, parameters, placement,
projection::{
application_display_name, desired_state_label, endpoint, observation_stale,
presentation_name, readiness, readiness_label, service_label, state_label,
@@ -28,6 +28,10 @@ pub struct ApplicationShowArgs {
#[arg(value_name = "DEPLOYMENT")]
reference: String,
+ /// Print secret parameter values instead of masking them.
+ #[arg(long)]
+ reveal_secrets: bool,
+
#[command(flatten)]
manager: ManagerArgs,
}
@@ -38,7 +42,7 @@ pub(crate) async fn application_show(arguments: ApplicationShowArgs) -> anyhow::
let deployment =
find_deployment::(&manager, &arguments.reference).await?;
let nodes = manager.node_names().await;
- let details = Details::of(&deployment, &nodes);
+ let details = Details::of(&deployment, &nodes, arguments.reveal_secrets);
if controlling_terminal_condition() == TerminalCondition::Interactive {
let mut terminal = open_terminal();
@@ -47,6 +51,16 @@ pub(crate) async fn application_show(arguments: ApplicationShowArgs) -> anyhow::
details.name
)))?;
terminal.message(&Message::KeyValues(details.pairs()))?;
+ if !details.parameters.is_empty() {
+ terminal.message(&Message::Heading("Parameters".to_owned()))?;
+ terminal.message(&Message::KeyValues(
+ details
+ .parameters
+ .iter()
+ .map(|row| (row.name.clone(), row.value.clone()))
+ .collect(),
+ ))?;
+ }
return Ok(());
}
@@ -70,10 +84,11 @@ struct Details {
route: String,
failure: String,
message: String,
+ parameters: Vec,
}
impl Details {
- fn of(deployment: &ApplicationDeployment, nodes: &NodeNames) -> Self {
+ fn of(deployment: &ApplicationDeployment, nodes: &NodeNames, reveal_secrets: bool) -> Self {
let state = state_label(deployment);
let observed = deployment.state.as_ref();
let failure = observed
@@ -140,6 +155,7 @@ impl Details {
route: single_line(route),
failure,
message,
+ parameters: parameters::rows(deployment, reveal_secrets),
}
}
@@ -183,7 +199,7 @@ impl Details {
.map(|(name, state)| format!("{name}: {state}"))
.collect::>()
.join(", ");
- format!(
+ let mut block = format!(
"Application deployment\nDOTCORE_APPLICATION_DEPLOYMENT_ID={}\nDOTCORE_APPLICATION_DEPLOYMENT_NAME={}\nDOTCORE_APPLICATION_DEPLOYMENT_STATE={}\nDOTCORE_APPLICATION_DEPLOYMENT_DESIRED_STATE={}\nDOTCORE_APPLICATION_DEPLOYMENT_READINESS={}\nDOTCORE_APPLICATION_DEPLOYMENT_STALE={}\nDOTCORE_APPLICATION_DEPLOYMENT_NODE={}\nDOTCORE_APPLICATION_DEPLOYMENT_EXECUTION_TARGET={}\nDOTCORE_APPLICATION_DEPLOYMENT_APPLICATION={}\nDOTCORE_APPLICATION_DEPLOYMENT_SERVICES={services}\nDOTCORE_APPLICATION_DEPLOYMENT_ENDPOINT={}\nDOTCORE_APPLICATION_DEPLOYMENT_ROUTE={}\nDOTCORE_APPLICATION_DEPLOYMENT_FAILURE={}\nDOTCORE_APPLICATION_DEPLOYMENT_MESSAGE={}\n",
self.id,
self.name,
@@ -198,6 +214,14 @@ impl Details {
self.route,
self.failure,
self.message,
- )
+ );
+ for row in &self.parameters {
+ block.push_str(&format!(
+ "DOTCORE_APPLICATION_DEPLOYMENT_PARAMETER_{}={}\n",
+ parameters::record_key(&row.id),
+ row.value,
+ ));
+ }
+ block
}
}
diff --git a/backend/cli/tests/application_contract.rs b/backend/cli/tests/application_contract.rs
index 9ee6348b..0a5f1c5a 100644
--- a/backend/cli/tests/application_contract.rs
+++ b/backend/cli/tests/application_contract.rs
@@ -14,6 +14,11 @@ use dotcore_proto::{
},
google::protobuf::Timestamp,
};
+use dotcore_proto::dotcore::catalog::v1::{
+ ApplicationGraph, BooleanParameterSchema, ParameterDefinition, ParameterValue,
+ SecretParameterSchema, StringEnumParameterSchema, TextParameterSchema,
+ UnsignedIntegerParameterSchema, parameter_definition::ParameterType, parameter_value::Value,
+};
use tonic::Status;
mod support;
@@ -731,6 +736,177 @@ fn a_terminal_teardown_cancels_by_default_with_exit_code_130() {
);
}
+#[test]
+fn show_prints_parameters_in_catalog_order_with_secrets_masked() {
+ let manager = FakeManager::start(FakeState::with_applications(vec![
+ deployment_with_parameters(),
+ ]));
+
+ let output = run(&manager, &["show", "Chat"]);
+
+ assert_eq!(text(&output.stderr), "");
+ assert_eq!(output.status.code(), Some(0));
+ let stdout = text(&output.stdout);
+ assert!(!stdout.contains("s3cretPassw0rd"), "{stdout}");
+ assert_eq!(parameter_lines(&stdout), PARAMETER_LINES_MASKED);
+}
+
+#[test]
+fn show_reveals_secret_parameters_with_the_flag() {
+ let manager = FakeManager::start(FakeState::with_applications(vec![
+ deployment_with_parameters(),
+ ]));
+
+ let output = run(&manager, &["show", "Chat", "--reveal-secrets"]);
+
+ assert_eq!(output.status.code(), Some(0));
+ let revealed = PARAMETER_LINES_MASKED.map(|line| line.replace("********", "s3cretPassw0rd"));
+ assert_eq!(parameter_lines(&text(&output.stdout)), revealed);
+}
+
+#[test]
+fn help_describes_the_reveal_flag() {
+ let manager = FakeManager::start(FakeState::with_applications(Vec::new()));
+
+ let output = run(&manager, &["show", "--help"]);
+
+ let help = text(&output.stdout);
+ let line = help
+ .lines()
+ .find(|line| line.contains("--reveal-secrets"))
+ .unwrap_or_else(|| panic!("no flag in help\n{help}"));
+ assert_eq!(
+ line.split_whitespace().collect::>().join(" "),
+ "--reveal-secrets Print secret parameter values instead of masking them"
+ );
+}
+
+#[test]
+fn a_terminal_shows_parameters_under_their_own_heading() {
+ let manager = FakeManager::start(FakeState::with_applications(vec![
+ deployment_with_parameters(),
+ ]));
+
+ let Some(run) = run_in_pty(
+ terminal_command(&manager, &["show", "Chat"]),
+ Session::new([]),
+ ) else {
+ return;
+ };
+
+ assert_eq!(run.code(), Some(0), "screen:\n{}", run.screen());
+ let lines = run.screen().lines().map(str::trim).collect::>();
+ let start = lines
+ .iter()
+ .position(|line| *line == "Parameters")
+ .unwrap_or_else(|| panic!("no Parameters heading\n{}", run.screen()));
+ let rows = lines[start + 1..]
+ .iter()
+ .map(|line| line.split_whitespace().collect::>().join(" "))
+ .filter(|line| !line.is_empty())
+ .collect::>();
+ assert_eq!(
+ rows,
+ [
+ "Web search true (default)",
+ "Memory limit 2048 MB",
+ "Mode fast",
+ "Welcome note Empty",
+ "Admin password ******** (generated)",
+ ]
+ );
+}
+
+const PARAMETER_LINES_MASKED: [&str; 5] = [
+ "DOTCORE_APPLICATION_DEPLOYMENT_PARAMETER_WEB_SEARCH=true (default)",
+ "DOTCORE_APPLICATION_DEPLOYMENT_PARAMETER_MEMORY_LIMIT=2048 MB",
+ "DOTCORE_APPLICATION_DEPLOYMENT_PARAMETER_MODE=fast",
+ "DOTCORE_APPLICATION_DEPLOYMENT_PARAMETER_WELCOME_NOTE=Empty",
+ "DOTCORE_APPLICATION_DEPLOYMENT_PARAMETER_ADMIN_PASSWORD=******** (generated)",
+];
+
+/// The parameter lines of a record block.
+fn parameter_lines(stdout: &str) -> Vec<&str> {
+ stdout
+ .lines()
+ .filter(|line| line.starts_with("DOTCORE_APPLICATION_DEPLOYMENT_PARAMETER_"))
+ .collect()
+}
+
+/// A deployment with one parameter of each kind: a default boolean, a non-default integer with a
+/// unit, an enum, an empty text and a generated secret.
+fn deployment_with_parameters() -> ApplicationDeployment {
+ let definition = |id: &str, name: &str, parameter_type| ParameterDefinition {
+ parameter_id: id.to_owned(),
+ display_name: name.to_owned(),
+ parameter_type: Some(parameter_type),
+ ..Default::default()
+ };
+ let value = |id: &str, value| ParameterValue {
+ parameter_id: id.to_owned(),
+ value: Some(value),
+ };
+
+ let mut deployment = local_application_deployment("app-1", "Chat", Ready, Present);
+ let snapshot = deployment.snapshot.as_mut().expect("seeded snapshot");
+ snapshot.graph = Some(ApplicationGraph {
+ parameters: vec![
+ definition(
+ "web-search",
+ "Web search",
+ ParameterType::Boolean(BooleanParameterSchema {
+ default_value: Some(true),
+ }),
+ ),
+ definition(
+ "memory-limit",
+ "Memory limit",
+ ParameterType::UnsignedInteger(UnsignedIntegerParameterSchema {
+ minimum: 512,
+ maximum: 8192,
+ unit: "MB".to_owned(),
+ default_value: Some(1024),
+ }),
+ ),
+ definition(
+ "mode",
+ "Mode",
+ ParameterType::StringEnum(StringEnumParameterSchema {
+ values: vec!["fast".to_owned(), "careful".to_owned()],
+ default_value: Some("careful".to_owned()),
+ }),
+ ),
+ definition(
+ "welcome-note",
+ "Welcome note",
+ ParameterType::Text(TextParameterSchema {
+ minimum_length: 0,
+ maximum_length: 100,
+ default_value: None,
+ }),
+ ),
+ definition(
+ "admin-password",
+ "Admin password",
+ ParameterType::Secret(SecretParameterSchema {
+ generated_length: 32,
+ minimum_length: 8,
+ }),
+ ),
+ ],
+ ..Default::default()
+ });
+ snapshot.parameter_values = vec![
+ value("web-search", Value::Boolean(true)),
+ value("memory-limit", Value::UnsignedInteger(2048)),
+ value("mode", Value::String("fast".to_owned())),
+ value("welcome-note", Value::String(String::new())),
+ value("admin-password", Value::String("s3cretPassw0rd".to_owned())),
+ ];
+ snapshot.generated_parameter_ids = vec!["admin-password".to_owned()];
+ deployment
+}
+
/// Seeds one deployment in each state the commands present differently.
fn mixed_deployments() -> Vec {
let mut ready = local_application_deployment("app-1", "Chat", Ready, Present);
From 2ab71327c6738638213bb1ec704b86345bfcb1d2 Mon Sep 17 00:00:00 2001
From: Andre-Philippe Paquet
Date: Thu, 12 Nov 2026 18:15:18 -0500
Subject: [PATCH 18/40] unreviewed: feat(catalog): make the hermes login
deployment parameters
---
apps/hermes/AGENTS.md | 13 +++++++------
apps/hermes/README.md | 6 ++----
apps/hermes/image-workflow.mjs | 17 ++++++++++++++---
catalog/applications/hermes.yaml | 32 +++++++++++++++++++++++++-------
4 files changed, 48 insertions(+), 20 deletions(-)
diff --git a/apps/hermes/AGENTS.md b/apps/hermes/AGENTS.md
index 0dc0e23d..b7e212ff 100644
--- a/apps/hermes/AGENTS.md
+++ b/apps/hermes/AGENTS.md
@@ -8,9 +8,10 @@
its data. Read it for what it checks and sets. Its placeholder key `dotcore-no-key` is not a
secret: Hermes lists models only for a provider that has a key.
* The dashboard refuses a non-loopback bind without a login, and the Chat page's WebSockets work
- only behind it. The catalog sets Hermes' `basic` login through literal bindings. Its username and
- password are deliberate public placeholders, shown in the catalog `notes`, the same for every
- deployment; they are not a leaked secret. They set no session secret, so a restart logs users out.
+ only behind it. The catalog sets Hermes' `basic` login from two parameters: a username (default
+ `admin`) and a generated secret password, bound to the environment. Hermes hashes the password in
+ memory and never writes it to its configuration. They set no session secret, so a restart logs
+ users out.
Its test reaches the login and chat through a published port, never from inside the container,
which sees loopback.
* The catalog declares no storage binding, so `/opt/data` is container-local on every platform.
@@ -32,10 +33,10 @@
## Validate
1. `just image-test`: against the image tagged `dotcore/hermes:dev`, the startup wrapper's
- configuration and refusals, gateway readiness, the Dotcore provider selection, and the login and
- Chat page WebSocket through a published port, as a browser reaches them.
+ configuration and refusals, gateway readiness, the Dotcore provider selection, and the login (with a
+ generated password) and Chat page WebSocket through a published port, as a browser reaches them.
2. Deploy Hermes with a linked inference route and open the dashboard after readiness, logging in
- with the login from the console's app panel: Dotcore is the selected provider and the selectable
+ with the login from the deployment's sidebar: Dotcore is the selected provider and the selectable
model is the route's current name.
3. Open the dashboard's Chat page and send one prompt; the backend log shows
`POST /v1/chat/completions` with that route name as `model_id`. Rename the route and check the
diff --git a/apps/hermes/README.md b/apps/hermes/README.md
index 7bc3eb75..194f42a4 100644
--- a/apps/hermes/README.md
+++ b/apps/hermes/README.md
@@ -6,18 +6,16 @@ web dashboard.
## Using it
* Open the dashboard from the console. Its address stays the same for the deployment's life.
-* Log in with the username and password shown in the console's app panel.
+* Log in with the username and password in the deployment's sidebar. The password is generated at
+ deploy unless you type one.
* Chat with the agent on the dashboard's Chat page.
* Link an inference route to make it the default model. Without one, pick a route in the dashboard
before chatting.
## Good to know
-* The login is the same for every deployment, so it doesn't protect the dashboard from anyone who
- can reach it.
* The chat runs the agent with full tool and terminal access inside its container.
* Restarting the app logs you out.
-* Dotcore doesn't manage its credentials or secrets.
* The Hermes agent API is off.
* Hermes keeps its data (sessions, memory, settings) inside the app. It is lost when the app is
redeployed or recreated.
diff --git a/apps/hermes/image-workflow.mjs b/apps/hermes/image-workflow.mjs
index 6aaf9670..50cf7978 100644
--- a/apps/hermes/image-workflow.mjs
+++ b/apps/hermes/image-workflow.mjs
@@ -1,3 +1,4 @@
+import { randomInt } from "node:crypto";
import { readFileSync } from "node:fs";
import {
chmod,
@@ -40,10 +41,10 @@ const INVALID_STARTUPS = [
['http://127.0.0.1:50052/"/v1', ROUTE],
[INFERENCE_URL, "Fixture route"],
];
-// The login the catalog sets for the dashboard: deliberate public placeholders, the same for every
-// deployment.
+// The login the catalog binds for the dashboard: the username's default and a generated-style
+// password (32 alphanumeric characters) that differs on every run.
const LOGIN_USERNAME = "admin";
-const LOGIN_PASSWORD = "dotcore";
+const LOGIN_PASSWORD = randomAlphanumeric(32);
const BASE_IMAGE = dockerfileBaseImage(
readFileSync(new URL("./Dockerfile", import.meta.url), "utf8"),
/^nousresearch\/hermes-agent@sha256:[a-f0-9]{64}$/,
@@ -394,3 +395,13 @@ print(f"gateway running, pid {state['pid']}, dashboard 200")
`;
console.log(JSON.stringify(await verifyHermesImage(), null, 2));
+
+/** Returns a random string of letters and digits, like a generated secret parameter. */
+function randomAlphanumeric(length) {
+ const alphabet =
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
+ return Array.from(
+ { length },
+ () => alphabet[randomInt(alphabet.length)],
+ ).join("");
+}
diff --git a/catalog/applications/hermes.yaml b/catalog/applications/hermes.yaml
index 70114612..9da81191 100644
--- a/catalog/applications/hermes.yaml
+++ b/catalog/applications/hermes.yaml
@@ -5,10 +5,26 @@ displayName: "Hermes"
description: "Autonomous AI agent with tools, memory, and a web dashboard that has a chat page."
version: "0.2.1"
notes: |-
- Dashboard login: username admin, password dotcore. Every deployment uses the same login, so it
- doesn't protect the dashboard from anyone who can reach it. Chat with the agent on the dashboard's
- Chat page.
+ Log in to the dashboard with the username and password in this deployment's sidebar. Chat with
+ the agent on the dashboard's Chat page.
graph:
+ parameters:
+ - parameterId: "dashboard-username"
+ displayName: "Dashboard username"
+ visibility: "PARAMETER_VISIBILITY_BASIC"
+ description: "The username for logging in to the Hermes dashboard."
+ text:
+ minimumLength: 1
+ maximumLength: 64
+ defaultValue: "admin"
+ - parameterId: "dashboard-password"
+ displayName: "Dashboard password"
+ visibility: "PARAMETER_VISIBILITY_BASIC"
+ description: "The password for logging in to the Hermes dashboard. Leave it empty to generate a random one, which you can read in the deployment's sidebar."
+ # 12 characters: the shortest password worth exposing on a network-reachable dashboard.
+ secret:
+ generatedLength: 32
+ minimumLength: 12
services:
- serviceName: "gateway"
artifact:
@@ -38,10 +54,7 @@ graph:
literalValue: "0.0.0.0"
- bindingName: "dashboard-login-username"
environmentVariable: "HERMES_DASHBOARD_BASIC_AUTH_USERNAME"
- literalValue: "admin"
- - bindingName: "dashboard-login-password"
- environmentVariable: "HERMES_DASHBOARD_BASIC_AUTH_PASSWORD"
- literalValue: "dotcore"
+ parameterId: "dashboard-username"
- bindingName: "agent-api-enabled"
environmentVariable: "API_SERVER_ENABLED"
literalValue: "false"
@@ -54,6 +67,11 @@ graph:
input:
inferenceRoute:
projection: "APPLICATION_INFERENCE_ROUTE_PROJECTION_ROUTE_NAME"
+ secretBindings:
+ - bindingName: "dashboard-login-password"
+ environmentVariable: "HERMES_DASHBOARD_BASIC_AUTH_PASSWORD"
+ parameterId: "dashboard-password"
+ form: "APPLICATION_SECRET_FORM_PLAIN"
readiness:
httpGet:
portName: "gateway"
From 9f8ec321a2463965756006e486280b5c43413ea1 Mon Sep 17 00:00:00 2001
From: Andre-Philippe Paquet
Date: Thu, 12 Nov 2026 18:15:18 -0500
Subject: [PATCH 19/40] unreviewed: feat(frontend): add secret fields and the
app parameters section
---
.../components/parameters/parameter-view.ts | 13 +++
.../components/parameters/parameters.test.tsx | 60 +++++++++++++-
.../src/components/parameters/parameters.tsx | 72 +++++++++++++++--
frontend/src/components/ui/copy-control.tsx | 24 +++++-
.../src/components/ui/secret-value.test.tsx | 52 ++++++++++++
frontend/src/components/ui/secret-value.tsx | 56 +++++++++++++
.../application-deploy-dialog.test.tsx | 80 +++++++++++++++++++
.../application-inspector.test.tsx | 2 +-
.../applications/application-inspector.tsx | 13 ++-
.../applications/application-parameters.ts | 57 ++++++++++++-
10 files changed, 414 insertions(+), 15 deletions(-)
create mode 100644 frontend/src/components/ui/secret-value.test.tsx
create mode 100644 frontend/src/components/ui/secret-value.tsx
diff --git a/frontend/src/components/parameters/parameter-view.ts b/frontend/src/components/parameters/parameter-view.ts
index 7611ea30..d7a88671 100644
--- a/frontend/src/components/parameters/parameter-view.ts
+++ b/frontend/src/components/parameters/parameter-view.ts
@@ -12,7 +12,9 @@ export interface ParameterView {
/** The value type and constraints of a parameter view. */
export type ParameterViewType =
| { kind: "boolean" }
+ | { kind: "secret"; minimumLength: number }
| { kind: "stringEnum"; values: readonly string[] }
+ | { kind: "text"; maximumLength: number; minimumLength: number }
| { kind: "unsignedInteger"; maximum: bigint; minimum: bigint; unit: string };
/** A typed parameter value, matched to its view by type. */
@@ -29,6 +31,17 @@ export function parameterDisplayValue(
case "unsignedInteger":
return typeof value === "bigint" ? formatQuantity(value, parameter.type.unit) : undefined;
case "stringEnum":
+ case "text":
+ case "secret":
return typeof value === "string" ? value : undefined;
}
}
+
+/** Why a typed text or secret value is refused whatever the parameter's bounds, if it is. */
+export function textValueError(label: string, input: string): string | undefined {
+ if (input.includes("=")) return `${label} can't contain "=".`;
+ // eslint-disable-next-line no-control-regex
+ if (/[\u0000-\u001f\u007f-\u009f]/.test(input))
+ return `${label} can't contain control characters.`;
+ return undefined;
+}
diff --git a/frontend/src/components/parameters/parameters.test.tsx b/frontend/src/components/parameters/parameters.test.tsx
index e4e16b2d..681c78d5 100644
--- a/frontend/src/components/parameters/parameters.test.tsx
+++ b/frontend/src/components/parameters/parameters.test.tsx
@@ -1,8 +1,8 @@
-import { render, screen } from "@testing-library/react";
+import { render, screen, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import type { ParameterView } from "./parameter-view";
-import { ParametersForm } from "./parameters";
+import { ParametersDetails, ParametersForm } from "./parameters";
describe("ParametersForm", () => {
it("shows a number field's unit after its input, and nothing for a unit-less one", () => {
@@ -44,3 +44,59 @@ describe("ParametersForm", () => {
expect(slots).not.toHaveAccessibleDescription();
});
});
+
+describe("ParametersDetails", () => {
+ const parameters: ParameterView[] = [
+ { id: "user", label: "Username", type: { kind: "text", maximumLength: 8, minimumLength: 0 } },
+ { id: "note", label: "Note", type: { kind: "text", maximumLength: 8, minimumLength: 0 } },
+ { id: "pass", label: "Password", type: { kind: "secret", minimumLength: 12 } },
+ ];
+
+ it("tags defaults and generated secrets, and shows empty text as Empty", () => {
+ render(
+
+
+ ,
+ );
+
+ const rows = screen
+ .getAllByRole("term")
+ .map((term) => [term.textContent, term.nextElementSibling?.textContent]);
+ expect(rows).toEqual([
+ ["Username", "defaultadmin"],
+ ["Note", "defaultEmpty"],
+ ["Password", "generated••••••••"],
+ ]);
+ expect(within(screen.getAllByRole("definition")[0]).getByText("default")).toBeVisible();
+ });
+
+ it("tags a changed value with nothing", () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByRole("definition")).toHaveTextContent(/^ops$/);
+ });
+});
diff --git a/frontend/src/components/parameters/parameters.tsx b/frontend/src/components/parameters/parameters.tsx
index 01fb30d5..a679d08d 100644
--- a/frontend/src/components/parameters/parameters.tsx
+++ b/frontend/src/components/parameters/parameters.tsx
@@ -4,6 +4,7 @@ import { Detail } from "@/components/ui/detail-list";
import { Field } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
+import { SecretValue, ValueTag } from "@/components/ui/secret-value";
import { Switch } from "@/components/ui/switch";
import {
@@ -92,7 +93,11 @@ export function ParametersForm({
: null
+ parameter.type.kind === "unsignedInteger" ? (
+
+ ) : parameter.type.kind === "text" || parameter.type.kind === "secret" ? (
+
+ ) : null
}
key={parameter.id}
>
@@ -100,7 +105,10 @@ export function ParametersForm({
const input = (
onBlur(parameter.id)}
onChange={(event) => onChange(parameter.id, event.target.value)}
ref={register}
@@ -121,6 +129,17 @@ export function ParametersForm({
});
}
+/** The accepted length of a text or secret parameter. */
+function LengthFact({ type }: { type: { maximumLength?: number; minimumLength: number } }) {
+ return (
+
+ {type.maximumLength === undefined
+ ? `At least ${type.minimumLength} characters`
+ : `${type.minimumLength}–${type.maximumLength} characters`}
+
+ );
+}
+
/** The accepted range of a number parameter, with its unit when it has one. */
function RangeFact({ type }: { type: { maximum: bigint; minimum: bigint; unit: string } }) {
return (
@@ -131,18 +150,59 @@ function RangeFact({ type }: { type: { maximum: bigint; minimum: bigint; unit: s
);
}
-/** Detail rows for retained parameter values, in view order; a missing or mistyped value is skipped. */
+/**
+ * Detail rows for retained parameter values, in view order; a missing or mistyped value is skipped.
+ * A value equal to its default is tagged `default`, a secret is masked until revealed, and the IDs
+ * in `generatedIds` are tagged `generated`.
+ */
export function ParametersDetails({
+ defaults,
+ generatedIds,
parameters,
values,
}: {
+ defaults?: ReadonlyMap;
+ generatedIds?: ReadonlySet;
parameters: readonly ParameterView[];
values: ReadonlyMap;
}) {
return parameters.map((parameter) => {
- const displayValue = parameterDisplayValue(parameter, values.get(parameter.id));
- return displayValue === undefined ? null : (
-
+ const value = values.get(parameter.id);
+ const displayValue = parameterDisplayValue(parameter, value);
+ if (displayValue === undefined) return null;
+
+ const isDefault = value !== undefined && defaults?.get(parameter.id) === value;
+ const tag = isDefault ? default : null;
+ if (parameter.type.kind === "secret") {
+ return (
+ generated : null}
+ value={displayValue}
+ />
+ }
+ />
+ );
+ }
+ return (
+
+ {tag}
+ {parameter.type.kind === "text" && displayValue === "" ? (
+ Empty
+ ) : (
+ displayValue
+ )}
+
+ }
+ />
);
});
}
diff --git a/frontend/src/components/ui/copy-control.tsx b/frontend/src/components/ui/copy-control.tsx
index bbbb40a4..d7850766 100644
--- a/frontend/src/components/ui/copy-control.tsx
+++ b/frontend/src/components/ui/copy-control.tsx
@@ -71,6 +71,28 @@ function CodeCopyControl({ label, value }: CopyControlProps) {
);
}
+/** An icon-only copy button for a value that must not be rendered, such as a secret. */
+function IconCopyControl({ label, value }: CopyControlProps) {
+ const { copy, feedback } = useCopyFeedback(value);
+
+ return (
+
+
+ void copy()}
+ size="icon-xs"
+ title={`Copy ${label}`}
+ type="button"
+ variant="ghost"
+ >
+
+
+
+ );
+}
+
function Feedback({ feedback }: { feedback: CopyFeedback }) {
if (!feedback) return null;
@@ -107,5 +129,5 @@ function useCopyFeedback(value: string) {
return { copy, feedback };
}
-export { CodeCopyControl, InlineCopyControl };
+export { CodeCopyControl, IconCopyControl, InlineCopyControl };
export type { CopyControlProps, InlineCopyControlProps };
diff --git a/frontend/src/components/ui/secret-value.test.tsx b/frontend/src/components/ui/secret-value.test.tsx
new file mode 100644
index 00000000..eb39f7a6
--- /dev/null
+++ b/frontend/src/components/ui/secret-value.test.tsx
@@ -0,0 +1,52 @@
+import { render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { SecretValue } from "@/components/ui/secret-value";
+import * as clipboard from "@/lib/clipboard";
+import { setupUser } from "@/test/user";
+
+const SECRET = "hunter2-sentinel";
+
+function attributeText(root: HTMLElement): string {
+ return [...root.querySelectorAll("*")]
+ .flatMap((element) => [...element.attributes].map((attribute) => attribute.value))
+ .join("\n");
+}
+
+describe("SecretValue", () => {
+ const writeClipboard = vi.fn();
+
+ beforeEach(() => {
+ writeClipboard.mockReset().mockResolvedValue(undefined);
+ vi.spyOn(clipboard, "writeClipboard").mockImplementation(writeClipboard);
+ });
+
+ it("keeps the value out of the DOM text and accessible names until revealed", async () => {
+ const user = setupUser();
+ const { container } = render( );
+
+ expect(container.textContent).not.toContain(SECRET);
+ expect(attributeText(container)).not.toContain(SECRET);
+ expect(screen.getByRole("button", { name: "Reveal Password" })).toBeVisible();
+ expect(screen.getByRole("button", { name: "Copy Password" })).toBeVisible();
+
+ await user.click(screen.getByRole("button", { name: "Reveal Password" }));
+ expect(screen.getByText(SECRET)).toBeVisible();
+ await user.click(screen.getByRole("button", { name: "Hide Password" }));
+ expect(container.textContent).not.toContain(SECRET);
+ });
+
+ it("copies the value without revealing it", async () => {
+ const user = setupUser();
+ const { container } = render( );
+
+ await user.click(screen.getByRole("button", { name: "Copy Password" }));
+
+ expect(writeClipboard).toHaveBeenCalledExactlyOnceWith(SECRET);
+ expect(container.textContent).not.toContain(SECRET);
+ expect(screen.getByRole("button", { name: "Reveal Password" })).toHaveAttribute(
+ "aria-pressed",
+ "false",
+ );
+ });
+});
diff --git a/frontend/src/components/ui/secret-value.tsx b/frontend/src/components/ui/secret-value.tsx
new file mode 100644
index 00000000..1228cb54
--- /dev/null
+++ b/frontend/src/components/ui/secret-value.tsx
@@ -0,0 +1,56 @@
+import { EyeIcon, EyeOffIcon } from "lucide-react";
+import { type ReactNode, useState } from "react";
+
+import { Button } from "@/components/ui/button";
+import { IconCopyControl } from "@/components/ui/copy-control";
+
+interface SecretValueProps {
+ /** What the secret is, used in the button names ("Reveal "). */
+ label: string;
+ /** Muted tags shown after the value, such as `generated`. */
+ tags?: ReactNode;
+ value: string;
+}
+
+/**
+ * Shows a secret as a fixed mask until the operator reveals it, with a copy button that copies
+ * without revealing. The value is kept out of the DOM and the accessible names while masked.
+ */
+export function SecretValue({ label, tags, value }: SecretValueProps) {
+ const [revealed, setRevealed] = useState(false);
+
+ return (
+
+ {tags}
+
+ {revealed ? (
+ {value}
+ ) : (
+ ••••••••
+ )}
+ setRevealed((current) => !current)}
+ size="icon-xs"
+ title={`${revealed ? "Hide" : "Reveal"} ${label}`}
+ type="button"
+ variant="ghost"
+ >
+ {revealed ? : }
+
+
+
+
+ );
+}
+
+/** A muted tag marking how a value came to be, such as `default` or `generated`. */
+export function ValueTag({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/frontend/src/features/applications/application-deploy-dialog.test.tsx b/frontend/src/features/applications/application-deploy-dialog.test.tsx
index 8b3d46da..d217e64f 100644
--- a/frontend/src/features/applications/application-deploy-dialog.test.tsx
+++ b/frontend/src/features/applications/application-deploy-dialog.test.tsx
@@ -1550,4 +1550,84 @@ describe("Application deployment dialog parameters", () => {
expect(workers).toHaveAttribute("aria-invalid", "true");
expect(screen.getByText("Step 1 of 2 — Settings")).toBeVisible();
});
+
+ describe("text and secret parameters", () => {
+ const login = create(ApplicationSchema, {
+ applicationId: "hermes",
+ displayName: "Hermes",
+ version: "1.0.0",
+ graph: create(ApplicationGraphSchema, {
+ parameters: [
+ create(ParameterDefinitionSchema, {
+ parameterId: "dashboard-username",
+ displayName: "Username",
+ visibility: ParameterVisibility.BASIC,
+ parameterType: {
+ case: "text",
+ value: { minimumLength: 1, maximumLength: 8, defaultValue: "admin" },
+ },
+ }),
+ create(ParameterDefinitionSchema, {
+ parameterId: "dashboard-password",
+ displayName: "Password",
+ visibility: ParameterVisibility.BASIC,
+ parameterType: { case: "secret", value: { generatedLength: 32, minimumLength: 12 } },
+ }),
+ ],
+ }),
+ });
+
+ it("leaves an empty secret out of the request and sends a typed one", async () => {
+ const user = setupUser();
+ const onSubmit = renderDialog(vi.fn(), login);
+
+ expect(screen.getByRole("textbox", { name: "Username" })).toHaveValue("admin");
+ const password = screen.getByRole("textbox", { name: "Password" });
+ expect(password).toHaveValue("");
+ expect(password).toHaveAttribute("placeholder", "Generated at deploy");
+ await user.click(screen.getByRole("button", { name: "Next" }));
+ await user.click(screen.getByRole("button", { name: "Deploy" }));
+ expect(onSubmit).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ parameterValues: [] }),
+ );
+ });
+
+ it("sends a typed secret as typed", async () => {
+ const user = setupUser();
+ const onSubmit = renderDialog(vi.fn(), login);
+
+ await user.type(screen.getByRole("textbox", { name: "Password" }), "typed-password-1");
+ await user.click(screen.getByRole("button", { name: "Next" }));
+ await user.click(screen.getByRole("button", { name: "Deploy" }));
+ expect(onSubmit).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({
+ parameterValues: [
+ create(ParameterValueSchema, {
+ parameterId: "dashboard-password",
+ value: { case: "string", value: "typed-password-1" },
+ }),
+ ],
+ }),
+ );
+ });
+
+ it("shows a length or character error per field", async () => {
+ const user = setupUser();
+ renderDialog(vi.fn(), login);
+
+ await user.type(screen.getByRole("textbox", { name: "Password" }), "short");
+ await user.type(screen.getByRole("textbox", { name: "Username" }), "-way-too-long");
+ await user.click(screen.getByRole("button", { name: "Next" }));
+ expect(screen.getByText("Username must be 1 to 8 characters.")).toBeVisible();
+ expect(screen.getByText("Password must be at least 12 characters.")).toBeVisible();
+
+ const password = screen.getByRole("textbox", { name: "Password" });
+ await user.clear(password);
+ await user.type(password, "has=equals-sign");
+ await user.tab();
+ expect(screen.getByText(`Password can't contain "=".`)).toBeVisible();
+ });
+ });
});
diff --git a/frontend/src/features/applications/application-inspector.test.tsx b/frontend/src/features/applications/application-inspector.test.tsx
index d352ed88..92b9bd32 100644
--- a/frontend/src/features/applications/application-inspector.test.tsx
+++ b/frontend/src/features/applications/application-inspector.test.tsx
@@ -563,7 +563,7 @@ describe("Application deployment dialog", () => {
expect(sectionNames().slice(0, 3)).toEqual(["Status", "Deployment", "Parameters"]);
expect(detailTexts(screen.getByRole("region", { name: "Parameters" }))).toEqual([
["VRAM mode", "highvram"],
- ["Previews", "Disabled"],
+ ["Previews", "defaultDisabled"],
]);
});
diff --git a/frontend/src/features/applications/application-inspector.tsx b/frontend/src/features/applications/application-inspector.tsx
index 1fd72950..87bb3742 100644
--- a/frontend/src/features/applications/application-inspector.tsx
+++ b/frontend/src/features/applications/application-inspector.tsx
@@ -65,7 +65,11 @@ import {
} from "./application-accelerator-memory";
import { formatGpuDevices } from "./application-gpu-devices";
import { applicationInspectorPanelId } from "./application-inspector-panel";
-import { applicationParameterFields, retainedParameterValues } from "./application-parameters";
+import {
+ applicationParameterFields,
+ parameterDefaults,
+ retainedParameterValues,
+} from "./application-parameters";
interface InspectorProps {
onOpenChange: (open: boolean) => void;
@@ -176,9 +180,8 @@ export function DeploymentApplicationInspector({
// The deployment's per-unit accelerator memory from its service observations; units the
// projection does not cover render nothing.
const acceleratorMemory = applicationAcceleratorMemory(deployment);
- const retainedParameters = applicationParameterFields(deployment.snapshot?.graph?.parameters).map(
- (field) => field.view,
- );
+ const parameterFields = applicationParameterFields(deployment.snapshot?.graph?.parameters);
+ const retainedParameters = parameterFields.map((field) => field.view);
useEffect(() => {
if (!canRemove && confirmingRemoval) {
@@ -337,6 +340,8 @@ export function DeploymentApplicationInspector({
/>
diff --git a/frontend/src/features/applications/application-parameters.ts b/frontend/src/features/applications/application-parameters.ts
index a7c9d8d0..0c803a1d 100644
--- a/frontend/src/features/applications/application-parameters.ts
+++ b/frontend/src/features/applications/application-parameters.ts
@@ -1,6 +1,10 @@
import { create } from "@bufbuild/protobuf";
-import type { ParameterView, ParameterViewValue } from "@/components/parameters/parameter-view";
+import {
+ type ParameterView,
+ type ParameterViewValue,
+ textValueError,
+} from "@/components/parameters/parameter-view";
import type { ResolvedApplicationDeploymentSnapshot } from "@/gen/dotcore/application/v1/deployment_pb";
import {
type ParameterDefinition,
@@ -59,6 +63,32 @@ export function applicationParameterFields(
},
},
];
+ case "text": {
+ const { defaultValue, maximumLength, minimumLength } = type.value;
+ return [
+ {
+ advanced,
+ defaultValue,
+ view: {
+ ...base,
+ description: definition.description,
+ type: { kind: "text", maximumLength, minimumLength },
+ },
+ },
+ ];
+ }
+ case "secret":
+ return [
+ {
+ advanced,
+ defaultValue: undefined,
+ view: {
+ ...base,
+ description: definition.description,
+ type: { kind: "secret", minimumLength: type.value.minimumLength },
+ },
+ },
+ ];
default:
return [];
}
@@ -79,7 +109,16 @@ export function defaultParameterInputs(
/** Why an input is not a valid value for its parameter, or `undefined` when it is. */
export function parameterInputError(view: ParameterView, input: string): string | undefined {
+ if (view.type.kind === "secret" && input === "") return undefined;
if (parameterInputValue(view, input) !== undefined) return undefined;
+ if (view.type.kind === "text" || view.type.kind === "secret") {
+ const forbidden = textValueError(view.label, input);
+ if (forbidden) return forbidden;
+ const { minimumLength } = view.type;
+ return view.type.kind === "secret"
+ ? `${view.label} must be at least ${minimumLength} characters.`
+ : `${view.label} must be ${minimumLength} to ${view.type.maximumLength} characters.`;
+ }
if (view.type.kind === "unsignedInteger") {
const { maximum, minimum, unit } = view.type;
return `${view.label} must be an integer from ${minimum} to ${maximum}${unit ? ` ${unit}` : ""}.`;
@@ -97,6 +136,15 @@ export function parameterInputValue(
return input === "true" ? true : input === "false" ? false : undefined;
case "stringEnum":
return view.type.values.includes(input) ? input : undefined;
+ case "text":
+ case "secret": {
+ const length = [...input].length;
+ const { minimumLength } = view.type;
+ const maximumLength = view.type.kind === "text" ? view.type.maximumLength : Infinity;
+ const valid =
+ length >= minimumLength && length <= maximumLength && !textValueError("", input);
+ return valid ? input : undefined;
+ }
case "unsignedInteger": {
if (!/^\d+$/.test(input.trim())) return undefined;
const value = BigInt(input.trim());
@@ -134,6 +182,13 @@ export function parameterOverrides(
);
}
+/** Each parameter's catalog default, keyed by parameter ID. */
+export function parameterDefaults(
+ fields: readonly ApplicationParameterField[],
+): Map {
+ return new Map(fields.map((field) => [field.view.id, field.defaultValue]));
+}
+
/** The values a deployment retained, keyed by parameter ID. */
export function retainedParameterValues(
snapshot: ResolvedApplicationDeploymentSnapshot | undefined,
From 25934aa8f7d91d380983c9379390b93b85f0a9f6 Mon Sep 17 00:00:00 2001
From: Andre-Philippe Paquet
Date: Thu, 12 Nov 2026 18:28:02 -0500
Subject: [PATCH 20/40] unreviewed: feat(proto): add the bcrypt secret form
---
frontend/src/gen/dotcore/catalog/v1/application_pb.ts | 9 ++++++++-
proto/dotcore/catalog/v1/application.proto | 3 +++
2 files changed, 11 insertions(+), 1 deletion(-)
diff --git a/frontend/src/gen/dotcore/catalog/v1/application_pb.ts b/frontend/src/gen/dotcore/catalog/v1/application_pb.ts
index bf03df6d..7094c00c 100644
--- a/frontend/src/gen/dotcore/catalog/v1/application_pb.ts
+++ b/frontend/src/gen/dotcore/catalog/v1/application_pb.ts
@@ -18,7 +18,7 @@ import type { Message } from "@bufbuild/protobuf";
* Describes the file dotcore/catalog/v1/application.proto.
*/
export const file_dotcore_catalog_v1_application: GenFile = /*@__PURE__*/
- fileDesc("CiRkb3Rjb3JlL2NhdGFsb2cvdjEvYXBwbGljYXRpb24ucHJvdG8SEmRvdGNvcmUuY2F0YWxvZy52MSKlAQoLQXBwbGljYXRpb24SFgoOYXBwbGljYXRpb25faWQYASABKAkSFAoMZGlzcGxheV9uYW1lGAIgASgJEhMKC2Rlc2NyaXB0aW9uGAUgASgJEg0KBW5vdGVzGAYgASgJEg8KB3ZlcnNpb24YAyABKAkSMwoFZ3JhcGgYBCABKAsyJC5kb3Rjb3JlLmNhdGFsb2cudjEuQXBwbGljYXRpb25HcmFwaCLTAQoQQXBwbGljYXRpb25HcmFwaBI4CghzZXJ2aWNlcxgBIAMoCzImLmRvdGNvcmUuY2F0YWxvZy52MS5BcHBsaWNhdGlvblNlcnZpY2USSAoQcHJpbWFyeV9lbmRwb2ludBgCIAEoCzIuLmRvdGNvcmUuY2F0YWxvZy52MS5BcHBsaWNhdGlvblByaW1hcnlFbmRwb2ludBI7CgpwYXJhbWV0ZXJzGAMgAygLMicuZG90Y29yZS5jYXRhbG9nLnYxLlBhcmFtZXRlckRlZmluaXRpb24isQQKEkFwcGxpY2F0aW9uU2VydmljZRIUCgxzZXJ2aWNlX25hbWUYASABKAkSQAoIYXJ0aWZhY3QYAiABKAsyLi5kb3Rjb3JlLmNhdGFsb2cudjEuQXBwbGljYXRpb25TZXJ2aWNlQXJ0aWZhY3QSDwoHY29tbWFuZBgDIAMoCRIRCglhcmd1bWVudHMYBCADKAkSOQoFcG9ydHMYBSADKAsyKi5kb3Rjb3JlLmNhdGFsb2cudjEuQXBwbGljYXRpb25TZXJ2aWNlUG9ydBJTChZjb25maWd1cmF0aW9uX2JpbmRpbmdzGAYgAygLMjMuZG90Y29yZS5jYXRhbG9nLnYxLkFwcGxpY2F0aW9uQ29uZmlndXJhdGlvbkJpbmRpbmcSPAoQc3RvcmFnZV9iaW5kaW5ncxgHIAMoCzIiLmRvdGNvcmUuY2F0YWxvZy52MS5TdG9yYWdlQmluZGluZxJFCg9zZWNyZXRfYmluZGluZ3MYCCADKAsyLC5kb3Rjb3JlLmNhdGFsb2cudjEuQXBwbGljYXRpb25TZWNyZXRCaW5kaW5nEkYKDGRlcGVuZGVuY2llcxgJIAMoCzIwLmRvdGNvcmUuY2F0YWxvZy52MS5BcHBsaWNhdGlvblNlcnZpY2VEZXBlbmRlbmN5EkIKCXJlYWRpbmVzcxgKIAEoCzIvLmRvdGNvcmUuY2F0YWxvZy52MS5BcHBsaWNhdGlvblNlcnZpY2VSZWFkaW5lc3MiyAEKGkFwcGxpY2F0aW9uU2VydmljZUFydGlmYWN0EjQKA29jaRgBIAEoCzIlLmRvdGNvcmUuY2F0YWxvZy52MS5PY2lJbWFnZVJlZmVyZW5jZUgAEi4KA25peBgDIAEoCzIfLmRvdGNvcmUuY2F0YWxvZy52MS5OaXhBcnRpZmFjdEgAEjwKE3N1cHBvcnRlZF9wbGF0Zm9ybXMYAiADKAsyHy5kb3Rjb3JlLmNhdGFsb2cudjEuT2NpUGxhdGZvcm1CBgoEa2luZCJAChZBcHBsaWNhdGlvblNlcnZpY2VQb3J0EhEKCXBvcnRfbmFtZRgBIAEoCRITCgtwb3J0X251bWJlchgCIAEoDSJ7ChxBcHBsaWNhdGlvblNlcnZpY2VEZXBlbmRlbmN5EhQKDHNlcnZpY2VfbmFtZRgBIAEoCRJFCgljb25kaXRpb24YAiABKA4yMi5kb3Rjb3JlLmNhdGFsb2cudjEuQXBwbGljYXRpb25EZXBlbmRlbmN5Q29uZGl0aW9uImgKG0FwcGxpY2F0aW9uU2VydmljZVJlYWRpbmVzcxJACghodHRwX2dldBgBIAEoCzIsLmRvdGNvcmUuY2F0YWxvZy52MS5BcHBsaWNhdGlvbkh0dHBSZWFkaW5lc3NIAEIHCgVjaGVjayI7ChhBcHBsaWNhdGlvbkh0dHBSZWFkaW5lc3MSEQoJcG9ydF9uYW1lGAEgASgJEgwKBHBhdGgYAiABKAki7gEKH0FwcGxpY2F0aW9uQ29uZmlndXJhdGlvbkJpbmRpbmcSFAoMYmluZGluZ19uYW1lGAEgASgJEhwKFGVudmlyb25tZW50X3ZhcmlhYmxlGAIgASgJEhcKDWxpdGVyYWxfdmFsdWUYAyABKAlIABIdChNyZXNvbHZlZF92YWx1ZV9uYW1lGAQgASgJSAASFgoMcGFyYW1ldGVyX2lkGAYgASgJSAASPQoFaW5wdXQYBSABKAsyLi5kb3Rjb3JlLmNhdGFsb2cudjEuQXBwbGljYXRpb25EZXBsb3ltZW50SW5wdXRCCAoGc291cmNlInMKGkFwcGxpY2F0aW9uRGVwbG95bWVudElucHV0Ek0KD2luZmVyZW5jZV9yb3V0ZRgCIAEoCzIyLmRvdGNvcmUuY2F0YWxvZy52MS5BcHBsaWNhdGlvbkluZmVyZW5jZVJvdXRlSW5wdXRIAEIGCgRraW5kIm0KHkFwcGxpY2F0aW9uSW5mZXJlbmNlUm91dGVJbnB1dBJLCgpwcm9qZWN0aW9uGAEgASgOMjcuZG90Y29yZS5jYXRhbG9nLnYxLkFwcGxpY2F0aW9uSW5mZXJlbmNlUm91dGVQcm9qZWN0aW9uIp0BChhBcHBsaWNhdGlvblNlY3JldEJpbmRpbmcSFAoMYmluZGluZ19uYW1lGAEgASgJEhwKFGVudmlyb25tZW50X3ZhcmlhYmxlGAIgASgJEhQKDHBhcmFtZXRlcl9pZBgDIAEoCRI3CgRmb3JtGAQgASgOMikuZG90Y29yZS5jYXRhbG9nLnYxLkFwcGxpY2F0aW9uU2VjcmV0Rm9ybSKIAQoaQXBwbGljYXRpb25QcmltYXJ5RW5kcG9pbnQSFAoMc2VydmljZV9uYW1lGAEgASgJEhEKCXBvcnRfbmFtZRgCIAEoCRJBCghwcm90b2NvbBgDIAEoDjIvLmRvdGNvcmUuY2F0YWxvZy52MS5BcHBsaWNhdGlvbkVuZHBvaW50UHJvdG9jb2wqlAEKI0FwcGxpY2F0aW9uSW5mZXJlbmNlUm91dGVQcm9qZWN0aW9uEjYKMkFQUExJQ0FUSU9OX0lORkVSRU5DRV9ST1VURV9QUk9KRUNUSU9OX1VOU1BFQ0lGSUVEEAASNQoxQVBQTElDQVRJT05fSU5GRVJFTkNFX1JPVVRFX1BST0pFQ1RJT05fUk9VVEVfTkFNRRABKmMKFUFwcGxpY2F0aW9uU2VjcmV0Rm9ybRInCiNBUFBMSUNBVElPTl9TRUNSRVRfRk9STV9VTlNQRUNJRklFRBAAEiEKHUFQUExJQ0FUSU9OX1NFQ1JFVF9GT1JNX1BMQUlOEAEqrAEKHkFwcGxpY2F0aW9uRGVwZW5kZW5jeUNvbmRpdGlvbhIwCixBUFBMSUNBVElPTl9ERVBFTkRFTkNZX0NPTkRJVElPTl9VTlNQRUNJRklFRBAAEiwKKEFQUExJQ0FUSU9OX0RFUEVOREVOQ1lfQ09ORElUSU9OX1NUQVJURUQQARIqCiZBUFBMSUNBVElPTl9ERVBFTkRFTkNZX0NPTkRJVElPTl9SRUFEWRACKnQKG0FwcGxpY2F0aW9uRW5kcG9pbnRQcm90b2NvbBItCilBUFBMSUNBVElPTl9FTkRQT0lOVF9QUk9UT0NPTF9VTlNQRUNJRklFRBAAEiYKIkFQUExJQ0FUSU9OX0VORFBPSU5UX1BST1RPQ09MX0hUVFAQAWIGcHJvdG8z", [file_dotcore_catalog_v1_nix, file_dotcore_catalog_v1_oci, file_dotcore_catalog_v1_parameter, file_dotcore_catalog_v1_storage]);
+ fileDesc("CiRkb3Rjb3JlL2NhdGFsb2cvdjEvYXBwbGljYXRpb24ucHJvdG8SEmRvdGNvcmUuY2F0YWxvZy52MSKlAQoLQXBwbGljYXRpb24SFgoOYXBwbGljYXRpb25faWQYASABKAkSFAoMZGlzcGxheV9uYW1lGAIgASgJEhMKC2Rlc2NyaXB0aW9uGAUgASgJEg0KBW5vdGVzGAYgASgJEg8KB3ZlcnNpb24YAyABKAkSMwoFZ3JhcGgYBCABKAsyJC5kb3Rjb3JlLmNhdGFsb2cudjEuQXBwbGljYXRpb25HcmFwaCLTAQoQQXBwbGljYXRpb25HcmFwaBI4CghzZXJ2aWNlcxgBIAMoCzImLmRvdGNvcmUuY2F0YWxvZy52MS5BcHBsaWNhdGlvblNlcnZpY2USSAoQcHJpbWFyeV9lbmRwb2ludBgCIAEoCzIuLmRvdGNvcmUuY2F0YWxvZy52MS5BcHBsaWNhdGlvblByaW1hcnlFbmRwb2ludBI7CgpwYXJhbWV0ZXJzGAMgAygLMicuZG90Y29yZS5jYXRhbG9nLnYxLlBhcmFtZXRlckRlZmluaXRpb24isQQKEkFwcGxpY2F0aW9uU2VydmljZRIUCgxzZXJ2aWNlX25hbWUYASABKAkSQAoIYXJ0aWZhY3QYAiABKAsyLi5kb3Rjb3JlLmNhdGFsb2cudjEuQXBwbGljYXRpb25TZXJ2aWNlQXJ0aWZhY3QSDwoHY29tbWFuZBgDIAMoCRIRCglhcmd1bWVudHMYBCADKAkSOQoFcG9ydHMYBSADKAsyKi5kb3Rjb3JlLmNhdGFsb2cudjEuQXBwbGljYXRpb25TZXJ2aWNlUG9ydBJTChZjb25maWd1cmF0aW9uX2JpbmRpbmdzGAYgAygLMjMuZG90Y29yZS5jYXRhbG9nLnYxLkFwcGxpY2F0aW9uQ29uZmlndXJhdGlvbkJpbmRpbmcSPAoQc3RvcmFnZV9iaW5kaW5ncxgHIAMoCzIiLmRvdGNvcmUuY2F0YWxvZy52MS5TdG9yYWdlQmluZGluZxJFCg9zZWNyZXRfYmluZGluZ3MYCCADKAsyLC5kb3Rjb3JlLmNhdGFsb2cudjEuQXBwbGljYXRpb25TZWNyZXRCaW5kaW5nEkYKDGRlcGVuZGVuY2llcxgJIAMoCzIwLmRvdGNvcmUuY2F0YWxvZy52MS5BcHBsaWNhdGlvblNlcnZpY2VEZXBlbmRlbmN5EkIKCXJlYWRpbmVzcxgKIAEoCzIvLmRvdGNvcmUuY2F0YWxvZy52MS5BcHBsaWNhdGlvblNlcnZpY2VSZWFkaW5lc3MiyAEKGkFwcGxpY2F0aW9uU2VydmljZUFydGlmYWN0EjQKA29jaRgBIAEoCzIlLmRvdGNvcmUuY2F0YWxvZy52MS5PY2lJbWFnZVJlZmVyZW5jZUgAEi4KA25peBgDIAEoCzIfLmRvdGNvcmUuY2F0YWxvZy52MS5OaXhBcnRpZmFjdEgAEjwKE3N1cHBvcnRlZF9wbGF0Zm9ybXMYAiADKAsyHy5kb3Rjb3JlLmNhdGFsb2cudjEuT2NpUGxhdGZvcm1CBgoEa2luZCJAChZBcHBsaWNhdGlvblNlcnZpY2VQb3J0EhEKCXBvcnRfbmFtZRgBIAEoCRITCgtwb3J0X251bWJlchgCIAEoDSJ7ChxBcHBsaWNhdGlvblNlcnZpY2VEZXBlbmRlbmN5EhQKDHNlcnZpY2VfbmFtZRgBIAEoCRJFCgljb25kaXRpb24YAiABKA4yMi5kb3Rjb3JlLmNhdGFsb2cudjEuQXBwbGljYXRpb25EZXBlbmRlbmN5Q29uZGl0aW9uImgKG0FwcGxpY2F0aW9uU2VydmljZVJlYWRpbmVzcxJACghodHRwX2dldBgBIAEoCzIsLmRvdGNvcmUuY2F0YWxvZy52MS5BcHBsaWNhdGlvbkh0dHBSZWFkaW5lc3NIAEIHCgVjaGVjayI7ChhBcHBsaWNhdGlvbkh0dHBSZWFkaW5lc3MSEQoJcG9ydF9uYW1lGAEgASgJEgwKBHBhdGgYAiABKAki7gEKH0FwcGxpY2F0aW9uQ29uZmlndXJhdGlvbkJpbmRpbmcSFAoMYmluZGluZ19uYW1lGAEgASgJEhwKFGVudmlyb25tZW50X3ZhcmlhYmxlGAIgASgJEhcKDWxpdGVyYWxfdmFsdWUYAyABKAlIABIdChNyZXNvbHZlZF92YWx1ZV9uYW1lGAQgASgJSAASFgoMcGFyYW1ldGVyX2lkGAYgASgJSAASPQoFaW5wdXQYBSABKAsyLi5kb3Rjb3JlLmNhdGFsb2cudjEuQXBwbGljYXRpb25EZXBsb3ltZW50SW5wdXRCCAoGc291cmNlInMKGkFwcGxpY2F0aW9uRGVwbG95bWVudElucHV0Ek0KD2luZmVyZW5jZV9yb3V0ZRgCIAEoCzIyLmRvdGNvcmUuY2F0YWxvZy52MS5BcHBsaWNhdGlvbkluZmVyZW5jZVJvdXRlSW5wdXRIAEIGCgRraW5kIm0KHkFwcGxpY2F0aW9uSW5mZXJlbmNlUm91dGVJbnB1dBJLCgpwcm9qZWN0aW9uGAEgASgOMjcuZG90Y29yZS5jYXRhbG9nLnYxLkFwcGxpY2F0aW9uSW5mZXJlbmNlUm91dGVQcm9qZWN0aW9uIp0BChhBcHBsaWNhdGlvblNlY3JldEJpbmRpbmcSFAoMYmluZGluZ19uYW1lGAEgASgJEhwKFGVudmlyb25tZW50X3ZhcmlhYmxlGAIgASgJEhQKDHBhcmFtZXRlcl9pZBgDIAEoCRI3CgRmb3JtGAQgASgOMikuZG90Y29yZS5jYXRhbG9nLnYxLkFwcGxpY2F0aW9uU2VjcmV0Rm9ybSKIAQoaQXBwbGljYXRpb25QcmltYXJ5RW5kcG9pbnQSFAoMc2VydmljZV9uYW1lGAEgASgJEhEKCXBvcnRfbmFtZRgCIAEoCRJBCghwcm90b2NvbBgDIAEoDjIvLmRvdGNvcmUuY2F0YWxvZy52MS5BcHBsaWNhdGlvbkVuZHBvaW50UHJvdG9jb2wqlAEKI0FwcGxpY2F0aW9uSW5mZXJlbmNlUm91dGVQcm9qZWN0aW9uEjYKMkFQUExJQ0FUSU9OX0lORkVSRU5DRV9ST1VURV9QUk9KRUNUSU9OX1VOU1BFQ0lGSUVEEAASNQoxQVBQTElDQVRJT05fSU5GRVJFTkNFX1JPVVRFX1BST0pFQ1RJT05fUk9VVEVfTkFNRRABKocBChVBcHBsaWNhdGlvblNlY3JldEZvcm0SJwojQVBQTElDQVRJT05fU0VDUkVUX0ZPUk1fVU5TUEVDSUZJRUQQABIhCh1BUFBMSUNBVElPTl9TRUNSRVRfRk9STV9QTEFJThABEiIKHkFQUExJQ0FUSU9OX1NFQ1JFVF9GT1JNX0JDUllQVBACKqwBCh5BcHBsaWNhdGlvbkRlcGVuZGVuY3lDb25kaXRpb24SMAosQVBQTElDQVRJT05fREVQRU5ERU5DWV9DT05ESVRJT05fVU5TUEVDSUZJRUQQABIsCihBUFBMSUNBVElPTl9ERVBFTkRFTkNZX0NPTkRJVElPTl9TVEFSVEVEEAESKgomQVBQTElDQVRJT05fREVQRU5ERU5DWV9DT05ESVRJT05fUkVBRFkQAip0ChtBcHBsaWNhdGlvbkVuZHBvaW50UHJvdG9jb2wSLQopQVBQTElDQVRJT05fRU5EUE9JTlRfUFJPVE9DT0xfVU5TUEVDSUZJRUQQABImCiJBUFBMSUNBVElPTl9FTkRQT0lOVF9QUk9UT0NPTF9IVFRQEAFiBnByb3RvMw", [file_dotcore_catalog_v1_nix, file_dotcore_catalog_v1_oci, file_dotcore_catalog_v1_parameter, file_dotcore_catalog_v1_storage]);
/**
* An immutable artifact-manager catalog declaration for an approved application release.
@@ -587,6 +587,13 @@ export enum ApplicationSecretForm {
* @generated from enum value: APPLICATION_SECRET_FORM_PLAIN = 1;
*/
PLAIN = 1,
+
+ /**
+ * A bcrypt hash of the value, computed once with a random salt when the deployment is created.
+ *
+ * @generated from enum value: APPLICATION_SECRET_FORM_BCRYPT = 2;
+ */
+ BCRYPT = 2,
}
/**
diff --git a/proto/dotcore/catalog/v1/application.proto b/proto/dotcore/catalog/v1/application.proto
index aa1fe140..ac4e240b 100644
--- a/proto/dotcore/catalog/v1/application.proto
+++ b/proto/dotcore/catalog/v1/application.proto
@@ -205,6 +205,9 @@ enum ApplicationSecretForm {
// The value exactly as retained.
APPLICATION_SECRET_FORM_PLAIN = 1;
+
+ // A bcrypt hash of the value, computed once with a random salt when the deployment is created.
+ APPLICATION_SECRET_FORM_BCRYPT = 2;
}
// The condition that a dependency service must meet before a dependent service starts.
From d8fe156e412a20e6d7adf3419224927e402386af Mon Sep 17 00:00:00 2001
From: Andre-Philippe Paquet
Date: Thu, 12 Nov 2026 18:40:14 -0500
Subject: [PATCH 21/40] unreviewed: feat(backend): add the bcrypt secret form
---
backend/Cargo.lock | 49 +++++++++++
backend/catalog/src/validation/application.rs | 77 +++++++++++++-----
backend/catalog/src/validation/service.rs | 2 +-
backend/core/Cargo.toml | 1 +
.../core/src/application/controller/launch.rs | 23 +++---
.../core/src/application/controller/logs.rs | 37 ++++++++-
.../core/src/application/controller/node.rs | 34 ++++++--
.../src/application/controller/operations.rs | 34 ++++++--
.../application/controller/test_support.rs | 22 +++--
.../core/src/application/controller/tests.rs | 34 +++++---
backend/core/src/application/mod.rs | 1 +
backend/core/src/application/repository.rs | 81 ++++++++++++-------
backend/core/src/application/secret_form.rs | 79 ++++++++++++++++++
13 files changed, 381 insertions(+), 93 deletions(-)
create mode 100644 backend/core/src/application/secret_form.rs
diff --git a/backend/Cargo.lock b/backend/Cargo.lock
index b3a26573..293b962d 100644
--- a/backend/Cargo.lock
+++ b/backend/Cargo.lock
@@ -288,6 +288,19 @@ version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
+[[package]]
+name = "bcrypt"
+version = "0.19.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a0cd0bd35a28836d528d2b58ad499bc3c5641d59379421b1be9eeb0c2f2b912a"
+dependencies = [
+ "base64 0.23.1",
+ "blowfish",
+ "getrandom 0.4.3",
+ "subtle",
+ "zeroize",
+]
+
[[package]]
name = "bit-vec"
version = "0.9.1"
@@ -325,6 +338,16 @@ dependencies = [
"hybrid-array",
]
+[[package]]
+name = "blowfish"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "62ce3946557b35e71d1bbe07ec385073ce9eda05043f95de134eb578fcf1a298"
+dependencies = [
+ "byteorder",
+ "cipher",
+]
+
[[package]]
name = "bollard"
version = "0.21.1"
@@ -420,6 +443,12 @@ version = "1.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
[[package]]
name = "byteorder-lite"
version = "0.1.0"
@@ -489,6 +518,16 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "cipher"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
+dependencies = [
+ "crypto-common",
+ "inout",
+]
+
[[package]]
name = "clap"
version = "4.6.7"
@@ -981,6 +1020,7 @@ dependencies = [
"anyhow",
"async-openai",
"base64 0.23.1",
+ "bcrypt",
"bollard",
"bytes",
"dotcore",
@@ -2019,6 +2059,15 @@ dependencies = [
"rustversion",
]
+[[package]]
+name = "inout"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a580c13f0ffc335ef42dd3f6b1a41b726051c5e1656d9a3aa6fddb3c5dfbbf9"
+dependencies = [
+ "hybrid-array",
+]
+
[[package]]
name = "instability"
version = "0.3.14"
diff --git a/backend/catalog/src/validation/application.rs b/backend/catalog/src/validation/application.rs
index 66d1ea75..cd245dab 100644
--- a/backend/catalog/src/validation/application.rs
+++ b/backend/catalog/src/validation/application.rs
@@ -681,17 +681,37 @@ mod tests {
],
&["username"],
);
- bind_secret(&mut application, "password", "password");
+ bind_secret(
+ &mut application,
+ "password",
+ "password",
+ ApplicationSecretForm::Plain,
+ );
+ bind_secret(
+ &mut application,
+ "password-hash",
+ "password",
+ ApplicationSecretForm::Bcrypt,
+ );
assert!(validate_application(&application).is_empty());
}
#[test]
fn application_validation_rejects_invalid_text_and_secret_parameters() {
+ let with_form_binding =
+ |parameters, bound_ids: &[&str], secret_id: &str, form: ApplicationSecretForm| {
+ let mut application = parameterized_application(parameters, bound_ids);
+ bind_secret(&mut application, "password", secret_id, form);
+ application
+ };
let with_secret_binding = |parameters, bound_ids: &[&str], secret_id: &str| {
- let mut application = parameterized_application(parameters, bound_ids);
- bind_secret(&mut application, "password", secret_id);
- application
+ with_form_binding(
+ parameters,
+ bound_ids,
+ secret_id,
+ ApplicationSecretForm::Plain,
+ )
};
let cases = [
(
@@ -765,24 +785,32 @@ mod tests {
);
}
- let not_secret = with_secret_binding(
- vec![parameter("username", text(1, 64, Some("admin")))],
- &[],
- "username",
- );
- assert_eq!(
- validate_application(¬_secret)
- .into_iter()
- .map(|error| error.to_string())
- .collect::>(),
- [
- "application secret binding \"password\" on service \"web\" references parameter \"username\", which is not a secret"
- ]
- );
+ for form in [ApplicationSecretForm::Plain, ApplicationSecretForm::Bcrypt] {
+ let not_secret = with_form_binding(
+ vec![parameter("username", text(1, 64, Some("admin")))],
+ &[],
+ "username",
+ form,
+ );
+ assert_eq!(
+ validate_application(¬_secret)
+ .into_iter()
+ .map(|error| error.to_string())
+ .collect::>(),
+ [
+ "application secret binding \"password\" on service \"web\" references parameter \"username\", which is not a secret"
+ ]
+ );
+ }
let mut unknown_form =
parameterized_application(vec![parameter("password", secret(32, 12))], &[]);
- bind_secret(&mut unknown_form, "password", "password");
+ bind_secret(
+ &mut unknown_form,
+ "password",
+ "password",
+ ApplicationSecretForm::Plain,
+ );
unknown_form.graph.as_mut().expect("graph").services[1].secret_bindings[0].form =
ApplicationSecretForm::Unspecified as i32;
assert_eq!(
@@ -958,14 +986,19 @@ services:
application
}
- fn bind_secret(application: &mut Application, binding_name: &str, parameter_id: &str) {
+ fn bind_secret(
+ application: &mut Application,
+ binding_name: &str,
+ parameter_id: &str,
+ form: ApplicationSecretForm,
+ ) {
application.graph.as_mut().expect("graph").services[1]
.secret_bindings
.push(ApplicationSecretBinding {
binding_name: binding_name.to_owned(),
- environment_variable: binding_name.to_uppercase(),
+ environment_variable: binding_name.to_uppercase().replace('-', "_"),
parameter_id: parameter_id.to_owned(),
- form: ApplicationSecretForm::Plain as i32,
+ form: form as i32,
});
}
diff --git a/backend/catalog/src/validation/service.rs b/backend/catalog/src/validation/service.rs
index 113a1191..55a192ff 100644
--- a/backend/catalog/src/validation/service.rs
+++ b/backend/catalog/src/validation/service.rs
@@ -148,7 +148,7 @@ fn validate_bindings(service: &ApplicationService, errors: &mut Vec Result, ApplicationLifecycleError> {
- let graph = application
- .graph
- .as_ref()
- .ok_or_else(|| configuration("Application graph is missing"))?;
graph
.services
.iter()
@@ -740,6 +739,12 @@ pub(super) fn secret_attachments(
})?;
let value = match ApplicationSecretForm::try_from(binding.form) {
Ok(ApplicationSecretForm::Plain) => value,
+ Ok(ApplicationSecretForm::Bcrypt) => bcrypt_hash(&value).map_err(|error| {
+ configuration_with_source(
+ "Application secret form cannot be computed",
+ error,
+ )
+ })?,
Ok(ApplicationSecretForm::Unspecified) | Err(_) => {
return Err(configuration("Application secret binding form is invalid"));
}
diff --git a/backend/core/src/application/controller/logs.rs b/backend/core/src/application/controller/logs.rs
index f5476716..0cf0d6a2 100644
--- a/backend/core/src/application/controller/logs.rs
+++ b/backend/core/src/application/controller/logs.rs
@@ -4,12 +4,14 @@
//! its process and over the private deployment-log service otherwise. The node serves the output
//! of each preparation step it ran for the deployment's services, resolves its own group from the
//! installed assignment and its pinned launch identities, exactly as it launches it, and redacts
-//! the launch environment values from both before any entry leaves it.
+//! the launch environment values and the secret parameter values from both before any entry leaves
+//! it, so a secret is redacted in its plain form as well as in the form its service receives.
use std::{future::Future, net::Ipv4Addr, sync::Arc};
use anyhow::anyhow;
use dotcore_artifact::PreparationStep;
+use dotcore_catalog::typed_parameters::{canonical_value_text, is_secret};
use dotcore_proto::dotcore::{
application::v1::{ApplicationDeploymentDeclaration, ApplicationDeploymentDesiredState},
deployment::private::v1::DeploymentOwnerKind,
@@ -221,7 +223,7 @@ impl NodeApplicationLogs {
steps: split.steps.clone(),
known_steps: known_steps.clone(),
workload: NodeGroupSource::NotCreated(selection),
- protected: ProtectedLogValues::new(attachment_values(declaration)),
+ protected: protected_values(declaration, attachment_values(declaration)),
};
let Some(workload_names) = &split.workload else {
return Ok(not_created(
@@ -486,7 +488,8 @@ fn local_group(
.clone();
let group = workload_group_launch(deployment, publication_host, inference_route_resolver)
.map_err(map_lifecycle_error)?;
- let protected = ProtectedLogValues::new(
+ let protected = protected_values(
+ deployment,
group
.units
.values()
@@ -586,6 +589,34 @@ fn attachment_values(
)
}
+/// Protects `launch_values` and the deployment's secret parameter values. A secret bound in a
+/// derived form, such as a hash, never appears among the launch values as typed, yet stays
+/// protected.
+fn protected_values(
+ deployment: &ApplicationDeploymentDeclaration,
+ launch_values: impl IntoIterator- ,
+) -> ProtectedLogValues {
+ let mut protected = ProtectedLogValues::new(launch_values);
+ protected.extend(ProtectedLogValues::new(secret_parameter_values(deployment)));
+ protected
+}
+
+/// Returns the retained values of the deployment's secret parameters.
+fn secret_parameter_values(
+ deployment: &ApplicationDeploymentDeclaration,
+) -> impl Iterator
- {
+ deployment.snapshot.iter().flat_map(|snapshot| {
+ snapshot
+ .graph
+ .iter()
+ .flat_map(|graph| &graph.parameters)
+ .filter(|definition| is_secret(definition))
+ .filter_map(|definition| {
+ canonical_value_text(&snapshot.parameter_values, &definition.parameter_id)
+ })
+ })
+}
+
/// Selects the requested sources among `declared`, or all of them.
fn select_sources(
declared: &[RuntimeLogSource],
diff --git a/backend/core/src/application/controller/node.rs b/backend/core/src/application/controller/node.rs
index d9af2289..f9f324fd 100644
--- a/backend/core/src/application/controller/node.rs
+++ b/backend/core/src/application/controller/node.rs
@@ -929,8 +929,8 @@ mod tests {
},
},
catalog::v1::{
- ApplicationHttpReadiness, ApplicationServiceReadiness, StorageBinding, StorageKind,
- StorageScope, application_service_readiness,
+ ApplicationHttpReadiness, ApplicationServiceReadiness, ParameterValue, StorageBinding,
+ StorageKind, StorageScope, application_service_readiness, parameter_value,
},
deployment::private::v1::{
DeploymentLogSourceOpening, DeploymentLogTarget, DeploymentLogUnavailableReason,
@@ -986,7 +986,7 @@ mod tests {
application::{
controller::test_support::{
application_execution_target, application_with_route_input, deployment,
- resolved_snapshot, start_steps,
+ resolved_snapshot, secret_parameter, start_steps,
},
repository::CurrentApplicationRepository as _,
},
@@ -1010,6 +1010,7 @@ mod tests {
"sha256:1111111111111111111111111111111111111111111111111111111111111111";
const REBUILT_IMAGE: &str =
"sha256:2222222222222222222222222222222222222222222222222222222222222222";
+ const PASSWORD_HASH: &str = "$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy";
#[tokio::test]
async fn node_realizes_its_valid_assignments_and_reports_each_typed_status() {
@@ -2481,13 +2482,26 @@ mod tests {
environment_variable: "TOKEN".to_owned(),
value: "secret-token".to_owned(),
});
+ // The service receives the password as a hash; the retained password itself is never
+ // part of the launch, yet stays protected.
+ let snapshot = preparing.snapshot.as_mut().expect("snapshot");
+ snapshot
+ .graph
+ .as_mut()
+ .expect("graph")
+ .parameters
+ .push(secret_parameter("password"));
+ snapshot.parameter_values.push(ParameterValue {
+ parameter_id: "password".to_owned(),
+ value: Some(parameter_value::Value::String("secret-password".to_owned())),
+ });
preparing
.secret_attachments
.push(ApplicationSecretAttachment {
service_name: "web".to_owned(),
binding_name: "password".to_owned(),
- environment_variable: "PASSWORD".to_owned(),
- value: "secret-password".to_owned(),
+ environment_variable: "PASSWORD_HASH".to_owned(),
+ value: PASSWORD_HASH.to_owned(),
});
fixture.install(1, vec![assignment(preparing, None)]);
// Never reconciled, so the deployment's group does not exist yet.
@@ -2504,7 +2518,7 @@ mod tests {
build.append(
time(1),
RuntimeLogStreamKind::Stderr,
- b"building with secret-token and secret-password",
+ format!("building with secret-token, secret-password and {PASSWORD_HASH}").as_bytes(),
);
preparation
.writer(
@@ -2564,7 +2578,11 @@ mod tests {
));
assert_eq!(
all.protected,
- ProtectedLogValues::new(["secret-token".to_owned(), "secret-password".to_owned()])
+ ProtectedLogValues::new([
+ "secret-token".to_owned(),
+ PASSWORD_HASH.to_owned(),
+ "secret-password".to_owned(),
+ ])
);
let build_entry = all.sources[0]
.result
@@ -2574,7 +2592,7 @@ mod tests {
.clone();
assert_eq!(
filter_entry(build_entry, &all.protected).content,
- b"building with [REDACTED] and [REDACTED]"
+ b"building with [REDACTED], [REDACTED] and [REDACTED]"
);
assert_eq!(
filtered
diff --git a/backend/core/src/application/controller/operations.rs b/backend/core/src/application/controller/operations.rs
index 8f77646b..1c3c9844 100644
--- a/backend/core/src/application/controller/operations.rs
+++ b/backend/core/src/application/controller/operations.rs
@@ -6,7 +6,7 @@ use dotcore_domain::{SiteNodeId, real_time};
use dotcore_proto::dotcore::{
application::v1::{
ApplicationDeploymentDeclaration, ApplicationDeploymentDesiredState,
- ApplicationLifecycleState, CreateApplicationDeploymentRequest,
+ ApplicationLifecycleState, ApplicationSecretAttachment, CreateApplicationDeploymentRequest,
CurrentApplicationDeployment, ResolvedApplicationDeploymentSnapshot,
ResolvedApplicationServiceSelection,
},
@@ -55,6 +55,7 @@ where
&site,
&declaration_snapshot,
)?;
+ declaration.secret_attachments = secret_attachments(&declaration).await?;
declaration.lifecycle_generation =
owner.reserve_lifecycle_generation(&declaration.application_deployment_id)?;
persist_create(owner, &declaration, declaration_snapshot.storage_revision)?;
@@ -526,11 +527,33 @@ fn resolve_application
(
.ok_or(ApplicationLifecycleError::NotFound)
}
+/// Resolves a new declaration's secret attachments on a blocking thread, since a bcrypt form
+/// spends a deliberate amount of CPU time per hash.
+async fn secret_attachments(
+ declaration: &ApplicationDeploymentDeclaration,
+) -> Result, ApplicationLifecycleError> {
+ let snapshot = declaration
+ .snapshot
+ .as_ref()
+ .ok_or(ApplicationLifecycleError::FailedPrecondition)?;
+ let graph = snapshot
+ .graph
+ .clone()
+ .ok_or(ApplicationLifecycleError::FailedPrecondition)?;
+ let values = snapshot.parameter_values.clone();
+ tokio::task::spawn_blocking(move || super::launch::secret_attachments(&graph, &values))
+ .await
+ .map_err(|error| {
+ configuration_with_source("Application secret resolution stopped", error)
+ })?
+}
+
/// Builds the declaration of a new deployment on `placement`, with its immutable snapshot for
/// the selected `platform`, the parameter values resolved from the request with missing secrets
-/// generated, the secret attachments derived from them once, and a published host
-/// port no other retained deployment holds, even one still tearing down. Local-development launch
-/// identities are left empty for the hosting node to pin once on its own image store.
+/// generated, and a published host port no other retained deployment holds, even one still
+/// tearing down. Its secret attachments are left for the caller to derive once, off the async
+/// executor. Local-development launch identities are left empty for the hosting node to pin once
+/// on its own image store.
fn declaration(
owner: &Arc>,
placement: &ExactPlacement,
@@ -591,7 +614,6 @@ where
¶meters.values,
request.inference_route_id.is_some(),
)?;
- let secret_attachments = super::launch::secret_attachments(&application, ¶meters.values)?;
let storage_attachments = super::launch::storage_attachments(&application)?;
Ok(ApplicationDeploymentDeclaration {
@@ -610,7 +632,7 @@ where
desired_state: ApplicationDeploymentDesiredState::Present as i32,
storage_attachments,
configuration_attachments,
- secret_attachments,
+ secret_attachments: Vec::new(),
inference_route_id: request.inference_route_id,
lifecycle_generation: 0,
node_resolved_launch_identities: Vec::new(),
diff --git a/backend/core/src/application/controller/test_support.rs b/backend/core/src/application/controller/test_support.rs
index 99ebf16e..6e923161 100644
--- a/backend/core/src/application/controller/test_support.rs
+++ b/backend/core/src/application/controller/test_support.rs
@@ -3027,7 +3027,7 @@ pub(super) fn application_with_parameters() -> Application {
}
/// The test application with a text `username` parameter bound to `USERNAME` and a secret
-/// `password` parameter bound in plain form to `PASSWORD`.
+/// `password` parameter bound in plain form to `PASSWORD` and in bcrypt form to `PASSWORD_HASH`.
pub(super) fn application_with_secret_parameters() -> Application {
use dotcore_proto::dotcore::catalog::v1::{
ApplicationSecretBinding, ApplicationSecretForm, ParameterDefinition, ParameterVisibility,
@@ -3059,12 +3059,20 @@ pub(super) fn application_with_secret_parameters() -> Application {
)),
input: None,
}];
- web.secret_bindings = vec![ApplicationSecretBinding {
- binding_name: "password".to_owned(),
- environment_variable: "PASSWORD".to_owned(),
- parameter_id: "password".to_owned(),
- form: ApplicationSecretForm::Plain as i32,
- }];
+ web.secret_bindings = vec![
+ ApplicationSecretBinding {
+ binding_name: "password".to_owned(),
+ environment_variable: "PASSWORD".to_owned(),
+ parameter_id: "password".to_owned(),
+ form: ApplicationSecretForm::Plain as i32,
+ },
+ ApplicationSecretBinding {
+ binding_name: "password-hash".to_owned(),
+ environment_variable: "PASSWORD_HASH".to_owned(),
+ parameter_id: "password".to_owned(),
+ form: ApplicationSecretForm::Bcrypt as i32,
+ },
+ ];
application
}
diff --git a/backend/core/src/application/controller/tests.rs b/backend/core/src/application/controller/tests.rs
index 396c6e30..409d7817 100644
--- a/backend/core/src/application/controller/tests.rs
+++ b/backend/core/src/application/controller/tests.rs
@@ -4139,7 +4139,7 @@ fn secret_bindings_attach_apart_from_configuration() {
[]
);
assert_eq!(
- secret_attachments(&application, &values).expect("secrets"),
+ secret_attachments(application.graph.as_ref().expect("graph"), &values).expect("secrets"),
[ApplicationSecretAttachment {
service_name: "web".to_owned(),
binding_name: "password".to_owned(),
@@ -8045,14 +8045,26 @@ async fn deployment_creation_generates_missing_secrets_once_and_keeps_typed_ones
})
.expect("generated declaration")
.clone();
+ // The bcrypt form is a hash computed at create that verifies against the generated value.
+ let password_hash = declaration.secret_attachments[1].value.clone();
+ assert!(password_hash.starts_with("$2b$10$"));
+ assert!(bcrypt::verify(&generated_password, &password_hash).expect("verifiable hash"));
assert_eq!(
declaration.secret_attachments,
- [ApplicationSecretAttachment {
- service_name: "web".to_owned(),
- binding_name: "password".to_owned(),
- environment_variable: "PASSWORD".to_owned(),
- value: generated_password.clone(),
- }]
+ [
+ ApplicationSecretAttachment {
+ service_name: "web".to_owned(),
+ binding_name: "password".to_owned(),
+ environment_variable: "PASSWORD".to_owned(),
+ value: generated_password.clone(),
+ },
+ ApplicationSecretAttachment {
+ service_name: "web".to_owned(),
+ binding_name: "password-hash".to_owned(),
+ environment_variable: "PASSWORD_HASH".to_owned(),
+ value: password_hash.clone(),
+ },
+ ]
);
assert_eq!(
declaration
@@ -8078,15 +8090,19 @@ async fn deployment_creation_generates_missing_secrets_once_and_keeps_typed_ones
first_launch.units[&WorkloadUnitName("web".to_owned())].environment,
BTreeMap::from([
("PASSWORD".to_owned(), generated_password.clone()),
+ ("PASSWORD_HASH".to_owned(), password_hash.clone()),
("USERNAME".to_owned(), "admin".to_owned()),
])
);
assert_eq!(launch(&declaration), first_launch);
// Launch types never show a secret in their `Debug` form.
- assert!(!format!("{first_launch:?}").contains(&generated_password));
+ let launch_debug = format!("{first_launch:?}");
+ assert!(!launch_debug.contains(&generated_password));
+ assert!(!launch_debug.contains(&password_hash));
drop(controller);
- // A restarted control plane reads the retained value back and never generates again.
+ // A restarted control plane reads the retained value and hash back, and never generates or
+ // hashes again.
let path = directory.join("site.yaml");
let mut persisted_site = site();
persisted_site.application_deployments = declarations.clone();
diff --git a/backend/core/src/application/mod.rs b/backend/core/src/application/mod.rs
index 7bc0cfbc..cf53ec62 100644
--- a/backend/core/src/application/mod.rs
+++ b/backend/core/src/application/mod.rs
@@ -3,6 +3,7 @@
mod controller;
mod published_port;
pub mod repository;
+mod secret_form;
pub mod service;
pub(crate) mod workloads;
diff --git a/backend/core/src/application/repository.rs b/backend/core/src/application/repository.rs
index 84f3d005..d5f89435 100644
--- a/backend/core/src/application/repository.rs
+++ b/backend/core/src/application/repository.rs
@@ -1,6 +1,6 @@
//! Owner-facing repositories for application declarations and current deployment state.
-use std::collections::BTreeSet;
+use std::collections::{BTreeMap, BTreeSet};
use dotcore_catalog::{
OciArtifactPolicy,
@@ -32,7 +32,7 @@ use dotcore_site::{
store::{CurrentStateStore, StoreError, StoredCurrentState, reserve_lifecycle_generation},
};
-use super::published_port::valid_published_port;
+use super::{published_port::valid_published_port, secret_form::is_bcrypt_hash};
/// An isolated read view of apps-controller declarations.
#[derive(Clone, Debug, PartialEq)]
@@ -629,7 +629,7 @@ fn validate_resolved_snapshot(
.services
.iter()
.map(|service| (service.service_name.as_str(), service))
- .collect::>();
+ .collect::>();
let mut selected = BTreeSet::new();
for selection in &snapshot.service_selections {
let service = services
@@ -834,6 +834,8 @@ fn validate_configuration_attachments(
/// Checks that every secret binding has exactly one attachment carrying its parameter's retained
/// value in the binding's form, and that the generated parameters are distinct retained secrets.
+/// A bcrypt attachment is the authoritative hash computed at create: it must be well formed, and
+/// is never verified against the value here.
fn validate_secret_attachments(
deployment: &ApplicationDeploymentDeclaration,
) -> Result<(), ApplicationRepositoryError> {
@@ -845,45 +847,41 @@ fn validate_secret_attachments(
.graph
.as_ref()
.expect("Application snapshot was validated");
- let mut expected = BTreeSet::new();
+ let mut expected = BTreeMap::new();
for service in &graph.services {
for binding in &service.secret_bindings {
let value = canonical_value_text(&snapshot.parameter_values, &binding.parameter_id)
.ok_or(ApplicationRepositoryError::InvalidState)?;
- let value = match ApplicationSecretForm::try_from(binding.form) {
- Ok(ApplicationSecretForm::Plain) => value,
- Ok(ApplicationSecretForm::Unspecified) | Err(_) => {
- return Err(ApplicationRepositoryError::InvalidState);
- }
- };
- expected.insert((
- service.service_name.as_str(),
- binding.binding_name.as_str(),
- binding.environment_variable.as_str(),
- value,
- ));
+ let form = ApplicationSecretForm::try_from(binding.form)
+ .map_err(|_| ApplicationRepositoryError::InvalidState)?;
+ expected.insert(
+ (service.service_name.as_str(), binding.binding_name.as_str()),
+ (binding.environment_variable.as_str(), form, value),
+ );
}
}
- let mut actual = BTreeSet::new();
+ let mut attached = BTreeSet::new();
for ApplicationSecretAttachment {
service_name,
binding_name,
environment_variable,
- value,
+ value: attachment,
} in &deployment.secret_attachments
{
- if !valid_configuration_value(value)
- || !actual.insert((
- service_name.as_str(),
- binding_name.as_str(),
- environment_variable.as_str(),
- value.clone(),
- ))
- {
+ let key = (service_name.as_str(), binding_name.as_str());
+ let carries_its_form = expected.get(&key).is_some_and(|(variable, form, value)| {
+ variable == environment_variable
+ && match form {
+ ApplicationSecretForm::Plain => attachment == value,
+ ApplicationSecretForm::Bcrypt => is_bcrypt_hash(attachment),
+ ApplicationSecretForm::Unspecified => false,
+ }
+ });
+ if !carries_its_form || !valid_configuration_value(attachment) || !attached.insert(key) {
return Err(ApplicationRepositoryError::InvalidState);
}
}
- if actual != expected {
+ if attached.len() != expected.len() {
return Err(ApplicationRepositoryError::InvalidState);
}
@@ -1349,7 +1347,7 @@ mod tests {
}
#[test]
- fn secret_attachment_must_carry_its_retained_value_and_generated_ids_name_secrets() {
+ fn secret_attachment_carries_its_form_and_generated_ids_name_secrets() {
let with_secret = |attachment_value: &str, generated: &[&str]| {
let mut deployment = valid_deployment();
let snapshot = deployment.snapshot.as_mut().expect("snapshot");
@@ -1437,6 +1435,33 @@ mod tests {
Err(ApplicationRepositoryError::InvalidState)
));
}
+
+ // A bcrypt attachment only needs to be a well-formed hash: this one was computed from
+ // another value and still reads back, since reads never verify it.
+ let with_hash = |attachment_value: &str| {
+ let mut deployment = with_secret(attachment_value, &[]);
+ deployment
+ .snapshot
+ .as_mut()
+ .and_then(|snapshot| snapshot.graph.as_mut())
+ .expect("graph")
+ .services[0]
+ .secret_bindings[0]
+ .form = ApplicationSecretForm::Bcrypt as i32;
+ deployment
+ };
+ let foreign_hash = "$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy";
+ assert!(!bcrypt::verify("retained-password", foreign_hash).expect("verifiable hash"));
+ assert!(validate_declarations(&[with_hash(foreign_hash)]).is_ok());
+ for invalid in [
+ "retained-password",
+ "$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhW",
+ ] {
+ assert!(matches!(
+ validate_declarations(&[with_hash(invalid)]),
+ Err(ApplicationRepositoryError::InvalidState)
+ ));
+ }
}
#[test]
diff --git a/backend/core/src/application/secret_form.rs b/backend/core/src/application/secret_form.rs
new file mode 100644
index 00000000..1fbca3d8
--- /dev/null
+++ b/backend/core/src/application/secret_form.rs
@@ -0,0 +1,79 @@
+//! Bcrypt form of a secret parameter's value, for services that take a password hash.
+//!
+//! The hash is computed once, when a deployment is created, with a fresh random salt, and is
+//! retained as the authoritative form; it is never recomputed, so relaunches see the same
+//! environment. Reading a retained hash back only checks that it is well formed and never verifies
+//! it against the value, which would cost a full hash on every read.
+
+use std::str::FromStr;
+
+use bcrypt::HashParts;
+
+/// Work factor of a new hash; the one services such as n8n use for their own hashes.
+const COST: u32 = 10;
+
+/// Version prefix of every hash this form computes.
+const VERSION_PREFIX: &str = "$2b$";
+
+/// Lowest and highest work factors a well-formed hash may carry.
+const VALID_COSTS: std::ops::RangeInclusive = 4..=31;
+
+/// Failure to compute the bcrypt form of a value.
+#[derive(Debug, thiserror::Error)]
+pub(super) enum BcryptFormError {
+ #[error("Bcrypt hashing failed")]
+ Hash(#[source] bcrypt::BcryptError),
+
+ #[error("Bcrypt hash does not verify against its value")]
+ Mismatch,
+}
+
+/// Returns a bcrypt hash of `value` with a fresh random salt, verified against `value` once.
+pub(super) fn bcrypt_hash(value: &str) -> Result {
+ let hash = bcrypt::hash(value, COST).map_err(BcryptFormError::Hash)?;
+ match bcrypt::verify(value, &hash).map_err(BcryptFormError::Hash)? {
+ true => Ok(hash),
+ false => Err(BcryptFormError::Mismatch),
+ }
+}
+
+/// Reports whether `text` is a well-formed bcrypt hash in this form's version, without verifying
+/// it against any value.
+pub(super) fn is_bcrypt_hash(text: &str) -> bool {
+ text.starts_with(VERSION_PREFIX)
+ && HashParts::from_str(text).is_ok_and(|parts| VALID_COSTS.contains(&parts.get_cost()))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn bcrypt_hash_verifies_against_its_value_with_a_fresh_salt() {
+ let first = bcrypt_hash("retained-password").expect("first hash");
+ let second = bcrypt_hash("retained-password").expect("second hash");
+
+ assert!(first.starts_with("$2b$10$"));
+ assert!(is_bcrypt_hash(&first));
+ assert!(bcrypt::verify("retained-password", &first).expect("verify"));
+ assert!(!bcrypt::verify("other-password", &first).expect("verify"));
+ assert_ne!(first, second);
+ }
+
+ #[test]
+ fn well_formed_hashes_are_recognized_without_their_value() {
+ let valid = "$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy";
+
+ assert!(is_bcrypt_hash(valid));
+ for invalid in [
+ "",
+ "retained-password",
+ "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy",
+ "$2b$99$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy",
+ "$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhW",
+ "$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lh!y",
+ ] {
+ assert!(!is_bcrypt_hash(invalid), "{invalid:?} is not a hash");
+ }
+ }
+}
From 0cbac168bd71118ae8acb8c3b25bf98a6ed6b2ce Mon Sep 17 00:00:00 2001
From: Andre-Philippe Paquet
Date: Thu, 12 Nov 2026 18:40:14 -0500
Subject: [PATCH 22/40] unreviewed: feat(catalog): make the n8n login
deployment parameters
---
apps/n8n/AGENTS.md | 4 ++--
apps/n8n/README.md | 4 ++--
catalog/applications/n8n.yaml | 32 +++++++++++++++++++++++++-------
3 files changed, 29 insertions(+), 11 deletions(-)
diff --git a/apps/n8n/AGENTS.md b/apps/n8n/AGENTS.md
index b28cabfe..6e4017cc 100644
--- a/apps/n8n/AGENTS.md
+++ b/apps/n8n/AGENTS.md
@@ -4,7 +4,7 @@
* [`docker-image.lock.json`](docker-image.lock.json) pins the upstream image; nothing is built here.
* The catalog declaration owns the start command, which imports the `Dotcore Inference` credential,
- the fixed owner login and n8n's settings. Read it before changing them. The command stays on one
+ n8n's settings and the owner login (email and password parameters; the password is bound in bcrypt form because n8n accepts only a hash). Read it before changing them. The command stays on one
line because the Docker runtime rejects control characters.
* Webhook and editor URLs are fixed from the node's host and port when the deployment is created.
* A deployment keeps the declaration `notes` it was created with.
@@ -17,7 +17,7 @@
## Validate
-1. On a Docker apps site, deploy a model and n8n, open the endpoint the console shows, and sign in.
+1. On a Docker apps site, deploy a model and n8n, open the endpoint the console shows, and sign in with the sidebar login; a second deployment gets a different generated password and `admin` is refused.
2. Credentials lists one `Dotcore Inference`; an OpenAI Chat Model node using it lists the site's
routes, and one execution reaches a route (backend log `POST /v1/chat/completions` with that
route as `model_id`).
diff --git a/apps/n8n/README.md b/apps/n8n/README.md
index 8ffd1583..85e5ed44 100644
--- a/apps/n8n/README.md
+++ b/apps/n8n/README.md
@@ -4,7 +4,8 @@
## Using it
-* Open it from the console and sign in with the login it shows (`admin@dotcore.local` / `admin`).
+* Open it from the console and sign in with the email and password in the deployment's sidebar. Set them when you deploy, or
+ leave the password empty to get a generated one.
* A ready-made `Dotcore Inference` credential connects n8n's OpenAI nodes to your models.
* In the OpenAI node's "Message a model", turn off the "Store" option. Dotcore doesn't store
responses, so the request fails with it on.
@@ -17,7 +18,6 @@
## Good to know
-* The login is fixed and public, so keep the app on a trusted network.
* Workflows, credentials and settings live in the deployment's data. Losing it loses them.
* Telemetry, version notifications and the AI Assistant are off.
diff --git a/catalog/applications/n8n.yaml b/catalog/applications/n8n.yaml
index 89a47d90..c934a49e 100644
--- a/catalog/applications/n8n.yaml
+++ b/catalog/applications/n8n.yaml
@@ -1,16 +1,32 @@
# Immutable n8n graph running the unmodified upstream image. The command seeds
# one OpenAI-compatible credential that points at the configured Inference
# endpoint, then starts n8n. The owner account is created from environment
-# values with a fixed POC login (admin@dotcore.local / admin).
+# values: the email and password come from parameters, and n8n takes the password as a bcrypt hash.
applicationId: "n8n"
displayName: "n8n"
description: "Workflow automation with an Inference endpoint credential already configured."
notes: |-
- Sign in with admin@dotcore.local / admin (fixed POC login).
+ Sign in with the email and password in this deployment's sidebar.
A Dotcore Inference credential points at this Site's Inference endpoint; pick it in OpenAI nodes.
OpenAI node "Message a model": under Options, add Store and turn it off. Dotcore does not keep responses.
version: "2.43.5"
graph:
+ parameters:
+ - parameterId: "owner-email"
+ displayName: "Owner email"
+ visibility: "PARAMETER_VISIBILITY_BASIC"
+ description: "The email address for logging in to n8n as its owner."
+ text:
+ minimumLength: 3
+ maximumLength: 254
+ defaultValue: "admin@dotcore.local"
+ - parameterId: "owner-password"
+ displayName: "Owner password"
+ visibility: "PARAMETER_VISIBILITY_BASIC"
+ description: "The password for logging in to n8n as its owner. Leave it empty to generate a random one, which you can read in the deployment's sidebar."
+ secret:
+ generatedLength: 32
+ minimumLength: 12
services:
- serviceName: "web"
# The image and its digests are pinned by `apps/n8n/docker-image.nix`.
@@ -51,17 +67,13 @@ graph:
literalValue: "true"
- bindingName: "owner-email"
environmentVariable: "N8N_INSTANCE_OWNER_EMAIL"
- literalValue: "admin@dotcore.local"
+ parameterId: "owner-email"
- bindingName: "owner-first-name"
environmentVariable: "N8N_INSTANCE_OWNER_FIRST_NAME"
literalValue: "Admin"
- bindingName: "owner-last-name"
environmentVariable: "N8N_INSTANCE_OWNER_LAST_NAME"
literalValue: "Dotcore"
- # bcrypt (cost 10) of the fixed POC password "admin"; not a secret.
- - bindingName: "owner-password-hash"
- environmentVariable: "N8N_INSTANCE_OWNER_PASSWORD_HASH"
- literalValue: "$2a$10$lb16z8Ce.cqruLzF5coJ1OsaP9zYd7ZZbBgjj6it/cWwnRcKWcjPm"
- bindingName: "secure-cookie"
environmentVariable: "N8N_SECURE_COOKIE"
literalValue: "false"
@@ -90,6 +102,12 @@ graph:
- bindingName: "editor-base-url"
environmentVariable: "N8N_EDITOR_BASE_URL"
resolvedValueName: "published-endpoint-url"
+ secretBindings:
+ # n8n accepts only a bcrypt hash of the owner password.
+ - bindingName: "owner-password-hash"
+ environmentVariable: "N8N_INSTANCE_OWNER_PASSWORD_HASH"
+ parameterId: "owner-password"
+ form: "APPLICATION_SECRET_FORM_BCRYPT"
storageBindings:
- bindingName: "data"
mountPath: "/home/node/.n8n"
From a9da00051f02d0b3db7d1c8aaa39f12e0d4984f8 Mon Sep 17 00:00:00 2001
From: Andre-Philippe Paquet
Date: Thu, 12 Nov 2026 18:40:14 -0500
Subject: [PATCH 23/40] unreviewed: docs(*): state the deployment secret rule
in the specs
---
docs/specs/access.md | 9 ++++++++-
docs/specs/apps.md | 8 ++++++--
docs/specs/catalog.md | 5 ++++-
3 files changed, 18 insertions(+), 4 deletions(-)
diff --git a/docs/specs/access.md b/docs/specs/access.md
index 69998fbb..a05cf596 100644
--- a/docs/specs/access.md
+++ b/docs/specs/access.md
@@ -114,7 +114,14 @@ flowchart TB
### Secrets
-- Stored as references, never as durable plaintext values.
+- Platform and provider credentials are stored as references, never as durable plaintext values.
+- A deployment's secret parameters, such as an app login or an engine key, belong to that
+ deployment. Its controller generates or accepts them at creation, keeps them with the
+ deployment, and gives each workload only the value or derived form it needs.
+- Clients show secret parameters masked until asked. Logs, diagnostics and telemetry never carry
+ them.
+- Current trade-offs: a deployment's secret values sit in plain text in the site's state file and
+ are visible through the container runtime's inspect output. The management API has no login.
- Verification and serving each receive only the credential capability they need.
- Private provider credentials stay apart from inference client credentials and public connection
information.
diff --git a/docs/specs/apps.md b/docs/specs/apps.md
index 3441174a..77406c49 100644
--- a/docs/specs/apps.md
+++ b/docs/specs/apps.md
@@ -11,7 +11,8 @@ Code: `backend/core/src/application/` (apps controller).
- Deploy a configured catalog app on a target you choose, on a managed machine, and inspect its
retained configuration.
- Pause, resume and tear it down. Teardown stays available while paused.
-- Set the app's typed parameters, basic or advanced, when you create a deployment.
+- Set the app's typed parameters, basic or advanced, when you create a deployment. Secret
+ parameters, such as an app login, are generated unless you supply them.
- Link the app to an inference route when it declares that dependency, so it uses the site's models.
- Inspect its lifecycle, readiness, access information, resource use and workload diagnostics.
- Run GPU apps, such as ComfyUI, on Linux with NVIDIA GPUs, and see their GPUs and measured GPU
@@ -113,6 +114,9 @@ What an application deployment keeps from creation:
- The exact, immutable launch identity of each service.
- The resolved typed parameters: catalog defaults, then operator overrides at creation, projected
into service configuration.
+- Its secret parameters: generated at creation unless you supply them, and fixed from then on.
+ Redeploy to change one. A secret binding hands a service the value in plain form, or in a bcrypt
+ form computed once at creation. Access rules: [access](access.md).
- The inference base URL at which the hosting node reaches the site manager, composed at creation.
- Its own published URL and a site-unique published host port, allocated at creation. The port
belongs to the deployment, not to its snapshot or launch plan.
@@ -136,7 +140,7 @@ What an application deployment keeps from creation:
policy and status.
- Own app configuration resolution and endpoint projection, including the site inference base URL
and optional route links.
-- Own typed app parameter resolution.
+- Own typed app parameter resolution, including generating secret parameters at creation.
- Keep the target's GPU set in the snapshot at creation and pass that whole set to every unit at
each launch.
- Before compiling any launch, recheck the kept GPU set against the target's current configured
diff --git a/docs/specs/catalog.md b/docs/specs/catalog.md
index 52f7e665..86ebab00 100644
--- a/docs/specs/catalog.md
+++ b/docs/specs/catalog.md
@@ -97,6 +97,8 @@ option effects itself.
omits and the engine release doesn't default.
- Sizing equations must be linear in the total context, so the largest fitting total context follows
from the estimates.
+- Repository checks reject a non-empty literal bound to a credential-like variable name in the
+ master catalog; such values come from secret parameters.
- Repository checks keep the master catalog pointing only at Dotcore's own repository. The backend
accepts any locked source in site and development catalogs.
@@ -186,7 +188,8 @@ The artifact manager also owns artifact bytes ([storage](storage.md)).
- Own the immutable catalog declarations and their lifecycle.
- Own the typed parameter definition engine releases and apps share, with its basic, advanced or
- hidden visibility, and the shape of the shared engine parameters.
+ hidden visibility, and its types (boolean, integer, text and secret), and the shape of the shared
+ engine parameters. Apps use any type; engines use boolean, integer and secret.
- Validate catalogs as described above.
#### Boundaries
From 39e0d2c455307ee0ea772b289ab162144e848069 Mon Sep 17 00:00:00 2001
From: Andre-Philippe Paquet
Date: Thu, 12 Nov 2026 18:41:25 -0500
Subject: [PATCH 24/40] unreviewed: feat(backend): reject credential literals
in the master catalog
---
backend/catalog/src/lib.rs | 47 +++++
backend/catalog/src/source.rs | 24 ++-
backend/catalog/src/validation/mod.rs | 2 +
.../src/validation/sensitive_literal.rs | 194 ++++++++++++++++++
4 files changed, 264 insertions(+), 3 deletions(-)
create mode 100644 backend/catalog/src/validation/sensitive_literal.rs
diff --git a/backend/catalog/src/lib.rs b/backend/catalog/src/lib.rs
index d510413c..7e518c85 100644
--- a/backend/catalog/src/lib.rs
+++ b/backend/catalog/src/lib.rs
@@ -2563,6 +2563,53 @@ useCases:
fs::remove_dir_all(root).expect("Catalog fixture cleanup");
}
+ #[test]
+ fn only_the_master_catalog_rejects_literals_on_sensitive_variables() {
+ let root = temporary_catalog_root();
+ let master = root.join("master");
+ let layer = root.join("layer");
+ create_empty_catalog_source(&master);
+ create_empty_catalog_source(&layer);
+ let mut application = super::test_support::application();
+ let binding =
+ &mut application.graph.as_mut().expect("graph").services[0].configuration_bindings[0];
+ binding.environment_variable = "FIXTURE_TOKEN".to_owned();
+ binding.source = Some(
+ dotcore_proto::dotcore::catalog::v1::application_configuration_binding::Source::LiteralValue(
+ "fixture-literal".to_owned(),
+ ),
+ );
+ write_application(&layer, "layer.yaml", &application);
+
+ let development =
+ Catalog::load_development_sources(&master, &layer, &ACCEPT_ENGINE_DECLARATIONS)
+ .expect("a development catalog may bind a literal to a sensitive variable");
+ let site = Catalog::load_sources_with_policy(
+ &master,
+ &layer,
+ OciArtifactPolicy::Development,
+ &ACCEPT_ENGINE_DECLARATIONS,
+ )
+ .expect("a site catalog may bind a literal to a sensitive variable");
+ write_application(&master, "master.yaml", &application);
+ let error = Catalog::load_with_policy(
+ &master,
+ OciArtifactPolicy::Development,
+ &ACCEPT_ENGINE_DECLARATIONS,
+ )
+ .expect_err("the master catalog must not bind a literal to a sensitive variable")
+ .to_string();
+
+ assert_eq!(development.list_application_releases().len(), 1);
+ assert_eq!(site.list_application_releases().len(), 1);
+ assert_eq!(
+ error,
+ "Catalog diagnostics: applications/master.yaml: application \"fixture-application\" service \"web\" configuration binding \"fixture-flag\" sets a catalog value on sensitive variable \"FIXTURE_TOKEN\"; bind a secret parameter instead"
+ );
+ assert!(!error.contains("fixture-literal"));
+ fs::remove_dir_all(root).expect("Catalog fixture cleanup");
+ }
+
#[test]
fn complete_validation_prevents_partial_catalog_publication() {
let valid = super::test_support::application();
diff --git a/backend/catalog/src/source.rs b/backend/catalog/src/source.rs
index 75a2ee65..f0f65c1c 100644
--- a/backend/catalog/src/source.rs
+++ b/backend/catalog/src/source.rs
@@ -23,7 +23,7 @@ use serde_json::{Map as JsonMap, Value as JsonValue};
use super::{
sizing::{CatalogSizingSource, ConfigurationSizingSource, SupportSizingSource},
- validation::CatalogDiagnostic,
+ validation::{CatalogDiagnostic, validate_sensitive_literals},
};
pub(super) fn load_catalog_source(
@@ -57,7 +57,13 @@ pub(super) fn load_catalog_source(
&mut records.recommendations,
diagnostics,
);
- load_applications(catalog_root, source, &mut records.applications, diagnostics);
+ load_applications(
+ catalog_root,
+ source,
+ role,
+ &mut records.applications,
+ diagnostics,
+ );
load_upstream_model_templates(
catalog_root,
source,
@@ -297,12 +303,24 @@ fn load_recommendations(
fn load_applications(
catalog_root: &Path,
source: &str,
+ role: CatalogSourceRole,
applications: &mut Vec<(String, Application)>,
diagnostics: &mut Vec,
) {
for file in optional_catalog_files(catalog_root, source, APPLICATION_DIRECTORY, diagnostics) {
match yaml::from_file(&file.path) {
- Ok(application) => applications.push((file.location, application)),
+ Ok(application) => {
+ // Site and development additions belong to their operators; only the master
+ // catalog ships to every site.
+ if role == CatalogSourceRole::Master {
+ diagnostics.extend(
+ validate_sensitive_literals(&application)
+ .into_iter()
+ .map(|error| CatalogDiagnostic::new(file.location.clone(), error)),
+ );
+ }
+ applications.push((file.location, application));
+ }
Err(error) => diagnostics.push(CatalogDiagnostic::new(
file.location,
anyhow::Error::new(error),
diff --git a/backend/catalog/src/validation/mod.rs b/backend/catalog/src/validation/mod.rs
index b02129d2..6979fa79 100644
--- a/backend/catalog/src/validation/mod.rs
+++ b/backend/catalog/src/validation/mod.rs
@@ -27,6 +27,7 @@ pub use model::{
overlay_parameter_defaults, valid_pinned_hugging_face_artifact, validate_parameter_values,
validate_supplied_parameter_values,
};
+pub(super) use sensitive_literal::validate_sensitive_literals;
pub use upstream_model_template::validate_upstream_model_template;
mod application;
@@ -37,6 +38,7 @@ mod hardware_class;
mod model;
mod nix;
mod recommendation;
+mod sensitive_literal;
mod service;
mod storage;
mod upstream_model_template;
diff --git a/backend/catalog/src/validation/sensitive_literal.rs b/backend/catalog/src/validation/sensitive_literal.rs
new file mode 100644
index 00000000..d75bb470
--- /dev/null
+++ b/backend/catalog/src/validation/sensitive_literal.rs
@@ -0,0 +1,194 @@
+//! Keeps secrets out of the master catalog's application declarations.
+//!
+//! A value the catalog itself sets on a service's environment ships to every site, so a variable
+//! whose name marks it as a secret must get its value from a secret parameter instead. An empty
+//! value stays allowed: it only tells the service that no secret is set.
+
+use std::collections::BTreeMap;
+
+use anyhow::anyhow;
+use dotcore_proto::dotcore::catalog::v1::{
+ Application, ApplicationConfigurationBinding, ParameterDefinition,
+ application_configuration_binding::Source,
+};
+
+use crate::{
+ is_sensitive_name,
+ typed_parameters::{canonical_text, default_value},
+};
+
+/// Returns one error per configuration binding that sets a non-empty catalog value, either a
+/// literal or a parameter default, on an environment variable with a sensitive name. Errors name
+/// the application, service, binding and variable, never the value.
+pub(crate) fn validate_sensitive_literals(application: &Application) -> Vec {
+ let Some(graph) = application.graph.as_ref() else {
+ return Vec::new();
+ };
+ let parameters = graph
+ .parameters
+ .iter()
+ .map(|definition| (definition.parameter_id.as_str(), definition))
+ .collect::>();
+
+ let mut errors = Vec::new();
+ for service in &graph.services {
+ for binding in &service.configuration_bindings {
+ if !is_sensitive_name(&binding.environment_variable)
+ || !binds_catalog_value(binding, ¶meters)
+ {
+ continue;
+ }
+ errors.push(anyhow!(
+ "application {:?} service {:?} configuration binding {:?} sets a catalog value on sensitive variable {:?}; bind a secret parameter instead",
+ application.application_id,
+ service.service_name,
+ binding.binding_name,
+ binding.environment_variable
+ ));
+ }
+ }
+ errors
+}
+
+/// Whether the binding's environment value comes from the catalog and is not empty.
+fn binds_catalog_value(
+ binding: &ApplicationConfigurationBinding,
+ parameters: &BTreeMap<&str, &ParameterDefinition>,
+) -> bool {
+ match binding.source.as_ref() {
+ Some(Source::LiteralValue(value)) => !value.is_empty(),
+ Some(Source::ParameterId(id)) => parameters
+ .get(id.as_str())
+ .and_then(|definition| default_value(definition))
+ .is_some_and(|value| !canonical_text(&value).is_empty()),
+ Some(Source::ResolvedValueName(_)) | None => false,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use dotcore_proto::dotcore::catalog::v1::{
+ ApplicationConfigurationBinding, ParameterDefinition, ParameterVisibility,
+ TextParameterSchema, application_configuration_binding::Source,
+ parameter_definition::ParameterType,
+ };
+
+ use super::validate_sensitive_literals;
+ use crate::test_support::application;
+
+ #[test]
+ fn non_empty_literal_on_a_sensitive_variable_is_rejected_without_its_value() {
+ let mut application = application();
+ add_binding(
+ &mut application,
+ "api-key",
+ "SERVICE_API_KEY",
+ Source::LiteralValue("hunter2".to_owned()),
+ );
+
+ let errors = messages(&application);
+
+ assert_eq!(
+ errors,
+ [
+ "application \"fixture-application\" service \"web\" configuration binding \"api-key\" sets a catalog value on sensitive variable \"SERVICE_API_KEY\"; bind a secret parameter instead"
+ ]
+ );
+ assert!(!errors[0].contains("hunter2"));
+ }
+
+ #[test]
+ fn empty_literals_and_non_sensitive_variables_are_allowed() {
+ let mut application = application();
+ add_binding(
+ &mut application,
+ "openai-api-key",
+ "OPENAI_API_KEY",
+ Source::LiteralValue(String::new()),
+ );
+ add_binding(
+ &mut application,
+ "tokenizer",
+ "TOKENIZER_PATH",
+ Source::LiteralValue("/models/tokenizer".to_owned()),
+ );
+ add_binding(
+ &mut application,
+ "endpoint-token",
+ "ENDPOINT_TOKEN",
+ Source::ResolvedValueName("published-endpoint-url".to_owned()),
+ );
+
+ assert_eq!(messages(&application), Vec::::new());
+ }
+
+ #[test]
+ fn parameter_default_on_a_sensitive_variable_is_rejected_unless_empty() {
+ let mut application = application();
+ add_text_parameter(&mut application, "admin-password", "changeme");
+ add_text_parameter(&mut application, "upstream-token", "");
+ add_binding(
+ &mut application,
+ "admin-password",
+ "ADMIN_PASSWORD",
+ Source::ParameterId("admin-password".to_owned()),
+ );
+ add_binding(
+ &mut application,
+ "upstream-token",
+ "UPSTREAM_TOKEN",
+ Source::ParameterId("upstream-token".to_owned()),
+ );
+
+ assert_eq!(
+ messages(&application),
+ [
+ "application \"fixture-application\" service \"web\" configuration binding \"admin-password\" sets a catalog value on sensitive variable \"ADMIN_PASSWORD\"; bind a secret parameter instead"
+ ]
+ );
+ }
+
+ fn messages(application: &dotcore_proto::dotcore::catalog::v1::Application) -> Vec {
+ validate_sensitive_literals(application)
+ .into_iter()
+ .map(|error| error.to_string())
+ .collect()
+ }
+
+ fn add_binding(
+ application: &mut dotcore_proto::dotcore::catalog::v1::Application,
+ binding_name: &str,
+ environment_variable: &str,
+ source: Source,
+ ) {
+ application.graph.as_mut().expect("graph").services[0]
+ .configuration_bindings
+ .push(ApplicationConfigurationBinding {
+ binding_name: binding_name.to_owned(),
+ environment_variable: environment_variable.to_owned(),
+ source: Some(source),
+ input: None,
+ });
+ }
+
+ fn add_text_parameter(
+ application: &mut dotcore_proto::dotcore::catalog::v1::Application,
+ parameter_id: &str,
+ default_value: &str,
+ ) {
+ application
+ .graph
+ .as_mut()
+ .expect("graph")
+ .parameters
+ .push(ParameterDefinition {
+ parameter_id: parameter_id.to_owned(),
+ visibility: ParameterVisibility::Basic as i32,
+ parameter_type: Some(ParameterType::Text(TextParameterSchema {
+ default_value: Some(default_value.to_owned()),
+ ..Default::default()
+ })),
+ ..Default::default()
+ });
+ }
+}
From f581af361937eab8fd7fbc9ec96cfe0431237c26 Mon Sep 17 00:00:00 2001
From: Andre-Philippe Paquet
Date: Thu, 12 Nov 2026 18:44:28 -0500
Subject: [PATCH 25/40] unreviewed: feat(frontend): mask the upstream model api
key
---
.../upstream/upstream-edit-inspector.tsx | 1 +
.../upstream/upstream-endpoint-fields.tsx | 19 +++++++
.../models/upstream/upstream-model.test.tsx | 56 +++++++++++++++++++
3 files changed, 76 insertions(+)
diff --git a/frontend/src/features/models/upstream/upstream-edit-inspector.tsx b/frontend/src/features/models/upstream/upstream-edit-inspector.tsx
index 2eb9cb77..5f48f641 100644
--- a/frontend/src/features/models/upstream/upstream-edit-inspector.tsx
+++ b/frontend/src/features/models/upstream/upstream-edit-inspector.tsx
@@ -254,6 +254,7 @@ export function UpstreamEditInspector({
>
void;
error?: string;
editing?: boolean;
+ /** The stored API key shown masked above the replace field; blank when none is configured. */
+ storedApiKey?: string;
fieldErrors?: FieldErrors;
onFieldBlur?: (field: "baseUrl" | "upstreamModelId") => void;
baseUrlRef?: RefObject;
@@ -71,6 +76,20 @@ export function UpstreamEndpointFields({
/>
)}
+ {editing && storedApiKey !== undefined ? (
+
+
+ ) : (
+ Not configured
+ )
+ }
+ />
+
+ ) : null}
{(control) => (
{
);
});
+ it("shows the stored API key masked above the replace field, revealed on request", async () => {
+ const user = setupUser();
+ render(
+
+ Promise.resolve({ displayName: "Upstream model" })}
+ open
+ openerRef={createRef()}
+ />
+ ,
+ );
+ const inspector = screen.getByRole("complementary", { name: "Upstream model · Edit" });
+ await waitForInspectorFocus(inspector);
+ expect(within(inspector).getByText("Current API key")).toBeVisible();
+ expect(within(inspector).getByText("••••••••")).toBeVisible();
+ expect(inspector).not.toHaveTextContent("stored-secret");
+ const replace = within(inspector).getByLabelText("API key");
+ expect(replace).toHaveValue("");
+ expect(
+ within(inspector).getByText("Current API key").compareDocumentPosition(replace) &
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+
+ await user.click(within(inspector).getByRole("button", { name: "Reveal API key" }));
+ expect(within(inspector).getByText("stored-secret")).toBeVisible();
+ });
+
+ it("shows Not configured when the upstream deployment has no API key", async () => {
+ const deployment = upstreamDeployment();
+ deployment.upstreamDesiredConfiguration!.apiKey = "";
+ render(
+
+ Promise.resolve({ displayName: "Upstream model" })}
+ open
+ openerRef={createRef()}
+ />
+ ,
+ );
+ const inspector = screen.getByRole("complementary", { name: "Upstream model · Edit" });
+ await waitForInspectorFocus(inspector);
+ expect(within(inspector).getByText("Not configured")).toBeVisible();
+ expect(within(inspector).queryByText("••••••••")).not.toBeInTheDocument();
+ expect(within(inspector).queryByRole("button", { name: "Reveal API key" })).toBeNull();
+ });
+
it("submits the endpoint form once on Enter without saving Name metadata", async () => {
const user = setupUser();
const onSaveEndpoint = vi.fn().mockResolvedValue(true);
From 19a780154260d9290ecd1ba9bfa56b842fe9c86c Mon Sep 17 00:00:00 2001
From: Andre-Philippe Paquet
Date: Thu, 12 Nov 2026 19:02:05 -0500
Subject: [PATCH 26/40] unreviewed: feat(catalog): turn on the open webui login
with deployment parameters
---
apps/open-webui/AGENTS.md | 12 ++++++--
apps/open-webui/README.md | 7 +++--
.../core/tests/backend/backend_integration.rs | 4 +--
catalog/applications/open-webui.yaml | 29 +++++++++++++++++++
4 files changed, 45 insertions(+), 7 deletions(-)
diff --git a/apps/open-webui/AGENTS.md b/apps/open-webui/AGENTS.md
index 5ea14fa4..8c667274 100644
--- a/apps/open-webui/AGENTS.md
+++ b/apps/open-webui/AGENTS.md
@@ -3,8 +3,13 @@
## How it is built
* [`docker-image.lock.json`](docker-image.lock.json) pins the upstream image; nothing is built here.
-* `WEBUI_AUTH` is off. Configuration bindings set every Open WebUI environment value, persistent
- configuration is off, and `OPENAI_API_BASE_URL` gets the site's inference endpoint. Data lives at
+* Login is on and sign-up is off. The admin email and password are parameters; Open WebUI creates
+ that admin only when its data folder has no user, so login needs a fresh data folder, which each
+ deployment has. A data folder opened with login off holds a default admin that stays valid and
+ skips the parameters' admin. `WEBUI_ADMIN_NAME`, `WEBUI_SECRET_KEY` and `DEFAULT_USER_ROLE` stay
+ unbound.
+* Configuration bindings set every Open WebUI environment value, persistent configuration is off,
+ and `OPENAI_API_BASE_URL` gets the site's inference endpoint. Data lives at
`/app/backend/data`.
## Update
@@ -15,4 +20,5 @@
## Validate
1. On a Docker apps site, deploy a model and Open WebUI, open the endpoint the console shows.
-2. The model list shows the site's routes; one chat returns an answer.
+2. Sign in with the sidebar's email and password; unauthenticated access is refused.
+3. The model list shows the site's routes; one chat returns an answer.
diff --git a/apps/open-webui/README.md b/apps/open-webui/README.md
index a97b4a3d..5fdc68a6 100644
--- a/apps/open-webui/README.md
+++ b/apps/open-webui/README.md
@@ -4,11 +4,14 @@
## Using it
-* Open it from the console. It lists the site's inference routes as models.
+* Open it from the console and sign in with the email and password in the deployment's sidebar. It
+ lists the site's inference routes as models.
## Good to know
-* It has no login. Anyone who can reach it can use it.
+* Only the admin can sign in; sign-up is closed.
+* The password is the admin's initial one. If you change it inside Open WebUI, the sidebar still
+ shows the old one.
* Each start re-applies the settings Dotcore sets, so changes you make to them in the app don't
last.
* Chats and settings live in the deployment's data.
diff --git a/backend/core/tests/backend/backend_integration.rs b/backend/core/tests/backend/backend_integration.rs
index 8590b00d..09228248 100644
--- a/backend/core/tests/backend/backend_integration.rs
+++ b/backend/core/tests/backend/backend_integration.rs
@@ -2076,8 +2076,8 @@ fn configure_route_name_application(root: &Path) {
.replace("applicationId: \"open-webui\"", "applicationId: \"route-test\"")
.replace("displayName: \"Open WebUI\"", "displayName: \"Route Test\"")
.replace(
- " storageBindings:",
- " - bindingName: \"route-name\"\n environmentVariable: \"ROUTE_NAME\"\n literalValue: \"\"\n input:\n inferenceRoute:\n projection: \"APPLICATION_INFERENCE_ROUTE_PROJECTION_ROUTE_NAME\"\n storageBindings:",
+ " secretBindings:",
+ " - bindingName: \"route-name\"\n environmentVariable: \"ROUTE_NAME\"\n literalValue: \"\"\n input:\n inferenceRoute:\n projection: \"APPLICATION_INFERENCE_ROUTE_PROJECTION_ROUTE_NAME\"\n secretBindings:",
);
fs::create_dir_all(root.join("catalog/applications"))
.expect("route-name application directory");
diff --git a/catalog/applications/open-webui.yaml b/catalog/applications/open-webui.yaml
index fc316abc..aa7b76fd 100644
--- a/catalog/applications/open-webui.yaml
+++ b/catalog/applications/open-webui.yaml
@@ -4,7 +4,25 @@ applicationId: "open-webui"
displayName: "Open WebUI"
description: "Self-hosted chat interface for the models on this site."
version: "0.11.4"
+notes: |-
+ Sign in with the email and password in this deployment's sidebar.
graph:
+ parameters:
+ - parameterId: "admin-email"
+ displayName: "Admin email"
+ visibility: "PARAMETER_VISIBILITY_BASIC"
+ description: "The email address for logging in to Open WebUI as its admin."
+ text:
+ minimumLength: 3
+ maximumLength: 254
+ defaultValue: "admin@dotcore.local"
+ - parameterId: "admin-password"
+ displayName: "Admin password"
+ visibility: "PARAMETER_VISIBILITY_BASIC"
+ description: "The initial password for logging in to Open WebUI as its admin. Leave it empty to generate a random one, which you can read in the deployment's sidebar. If you change the password inside Open WebUI, the one shown here is no longer valid."
+ secret:
+ generatedLength: 32
+ minimumLength: 12
services:
- serviceName: "web"
# The image and its digests are pinned by `apps/open-webui/docker-image.nix`.
@@ -24,7 +42,13 @@ graph:
configurationBindings:
- bindingName: "webui-auth"
environmentVariable: "WEBUI_AUTH"
+ literalValue: "true"
+ - bindingName: "signup"
+ environmentVariable: "ENABLE_SIGNUP"
literalValue: "false"
+ - bindingName: "admin-email"
+ environmentVariable: "WEBUI_ADMIN_EMAIL"
+ parameterId: "admin-email"
- bindingName: "persistent-config"
environmentVariable: "ENABLE_PERSISTENT_CONFIG"
literalValue: "false"
@@ -40,6 +64,11 @@ graph:
- bindingName: "openai-api-base-url"
environmentVariable: "OPENAI_API_BASE_URL"
resolvedValueName: "configured-inference-openai-base-url"
+ secretBindings:
+ - bindingName: "admin-password"
+ environmentVariable: "WEBUI_ADMIN_PASSWORD"
+ parameterId: "admin-password"
+ form: "APPLICATION_SECRET_FORM_PLAIN"
storageBindings:
- bindingName: "data"
mountPath: "/app/backend/data"
From 6ea0884d929f5f9f171ba8b1e95bf10ea8795d66 Mon Sep 17 00:00:00 2001
From: Andre-Philippe Paquet
Date: Thu, 12 Nov 2026 19:09:11 -0500
Subject: [PATCH 27/40] unreviewed: feat(proto): retain engine secrets and
profile settings in model snapshots
---
backend/proto/tests/engine_contract.rs | 3 +++
backend/proto/tests/model_contract.rs | 6 +++++
backend/proto/tests/site_contract.rs | 3 +++
.../src/gen/dotcore/model/v1/deployment_pb.ts | 25 +++++++++++++++++--
proto/dotcore/model/v1/deployment.proto | 9 +++++++
5 files changed, 44 insertions(+), 2 deletions(-)
diff --git a/backend/proto/tests/engine_contract.rs b/backend/proto/tests/engine_contract.rs
index fe97e8b0..249bb550 100644
--- a/backend/proto/tests/engine_contract.rs
+++ b/backend/proto/tests/engine_contract.rs
@@ -282,6 +282,9 @@ fn descriptors_keep_engine_payloads_out_of_catalog_and_deployment_contracts() {
("engine_parameter_values", 13),
("engine_parameter_schema_id", 14),
("engine_parameter_schema", 15),
+ ("profile_engine_parameter_defaults", 23),
+ ("profile_engine_parameter_visibility_overrides", 24),
+ ("generated_engine_parameter_ids", 25),
("desired_launch_plan", 16),
("custom_argument_tokens", 17),
("resolved_options", 18),
diff --git a/backend/proto/tests/model_contract.rs b/backend/proto/tests/model_contract.rs
index 1accb42a..b3ddec38 100644
--- a/backend/proto/tests/model_contract.rs
+++ b/backend/proto/tests/model_contract.rs
@@ -352,6 +352,9 @@ fn generated_bindings_preserve_exact_ordered_custom_argument_tokens_in_preview_a
description: "Maximum prompt and generation context.".to_owned(),
}],
}),
+ profile_engine_parameter_defaults: None,
+ profile_engine_parameter_visibility_overrides: Vec::new(),
+ generated_engine_parameter_ids: Vec::new(),
custom_argument_tokens: vec![
"--reasoning-format".to_owned(),
"deepseek".to_owned(),
@@ -1556,6 +1559,9 @@ fn model_descriptors_define_typed_configuration_and_resolved_plan_contracts() {
("engine_parameter_values", 13),
("engine_parameter_schema_id", 14),
("engine_parameter_schema", 15),
+ ("profile_engine_parameter_defaults", 23),
+ ("profile_engine_parameter_visibility_overrides", 24),
+ ("generated_engine_parameter_ids", 25),
("desired_launch_plan", 16),
("custom_argument_tokens", 17),
("resolved_options", 18),
diff --git a/backend/proto/tests/site_contract.rs b/backend/proto/tests/site_contract.rs
index 91855074..bfa14f3e 100644
--- a/backend/proto/tests/site_contract.rs
+++ b/backend/proto/tests/site_contract.rs
@@ -478,6 +478,9 @@ fn complete_model_snapshot() -> ModelDeploymentSnapshot {
description: "Maximum prompt and generation context.".to_owned(),
}],
}),
+ profile_engine_parameter_defaults: None,
+ profile_engine_parameter_visibility_overrides: Vec::new(),
+ generated_engine_parameter_ids: Vec::new(),
custom_argument_tokens: Vec::new(),
resolved_options: Vec::new(),
selected_target: Some(dotcore_proto::dotcore::model::v1::ResolvedSelectedTarget {
diff --git a/frontend/src/gen/dotcore/model/v1/deployment_pb.ts b/frontend/src/gen/dotcore/model/v1/deployment_pb.ts
index a705e6f8..2910b89c 100644
--- a/frontend/src/gen/dotcore/model/v1/deployment_pb.ts
+++ b/frontend/src/gen/dotcore/model/v1/deployment_pb.ts
@@ -10,7 +10,7 @@ import type { ModelParallelShape } from "../../catalog/v1/execution_pb";
import { file_dotcore_catalog_v1_execution } from "../../catalog/v1/execution_pb";
import type { HardwareRequirements } from "../../catalog/v1/hardware_class_pb";
import { file_dotcore_catalog_v1_hardware_class } from "../../catalog/v1/hardware_class_pb";
-import type { ModelArtifact, ModelAuxiliaryArtifact, ModelExecutionCapabilities, ModelOptionValues } from "../../catalog/v1/model_pb";
+import type { ModelArtifact, ModelAuxiliaryArtifact, ModelExecutionCapabilities, ModelOptionValues, ParameterVisibilityOverride } from "../../catalog/v1/model_pb";
import { file_dotcore_catalog_v1_model } from "../../catalog/v1/model_pb";
import type { InferenceRequestKind, ModelInputCapabilities } from "../../catalog/v1/model_capabilities_pb";
import { file_dotcore_catalog_v1_model_capabilities } from "../../catalog/v1/model_capabilities_pb";
@@ -44,7 +44,7 @@ import type { Message } from "@bufbuild/protobuf";
* Describes the file dotcore/model/v1/deployment.proto.
*/
export const file_dotcore_model_v1_deployment: GenFile = /*@__PURE__*/
- fileDesc("CiFkb3Rjb3JlL21vZGVsL3YxL2RlcGxveW1lbnQucHJvdG8SEGRvdGNvcmUubW9kZWwudjEirwUKGk1vZGVsRGVwbG95bWVudERlY2xhcmF0aW9uEhsKE21vZGVsX2RlcGxveW1lbnRfaWQYASABKAkSOAoJcGxhY2VtZW50GAIgASgLMiUuZG90Y29yZS5kZXBsb3ltZW50LnYxLkV4YWN0UGxhY2VtZW50EkgKGHJlcXVlc3RlZF9wYXJhbGxlbF9zaGFwZRgDIAEoDjImLmRvdGNvcmUuY2F0YWxvZy52MS5Nb2RlbFBhcmFsbGVsU2hhcGUSOwoIc25hcHNob3QYBCABKAsyKS5kb3Rjb3JlLm1vZGVsLnYxLk1vZGVsRGVwbG95bWVudFNuYXBzaG90EkQKDWRlc2lyZWRfc3RhdGUYBSABKA4yLS5kb3Rjb3JlLm1vZGVsLnYxLk1vZGVsRGVwbG95bWVudERlc2lyZWRTdGF0ZRJbCh51cHN0cmVhbV9kZXNpcmVkX2NvbmZpZ3VyYXRpb24YBiABKAsyMy5kb3Rjb3JlLm1vZGVsLnYxLlVwc3RyZWFtTW9kZWxEZXNpcmVkQ29uZmlndXJhdGlvbhIdChVtb2RlbF9kZXBsb3ltZW50X25hbWUYByABKAkSHAoUbGlmZWN5Y2xlX2dlbmVyYXRpb24YCCABKAQSUgoUbGlmZWN5Y2xlX3RyYW5zaXRpb24YCSABKA4yNC5kb3Rjb3JlLm1vZGVsLnYxLk1vZGVsRGVwbG95bWVudExpZmVjeWNsZVRyYW5zaXRpb24SIwobbGlmZWN5Y2xlX3JlY292ZXJ5X3JlcXVpcmVkGAogASgIEloKH25vZGVfcmVzb2x2ZWRfbGF1bmNoX2lkZW50aXRpZXMYCyADKAsyMS5kb3Rjb3JlLmRlcGxveW1lbnQudjEuTm9kZVJlc29sdmVkTGF1bmNoSWRlbnRpdHkiygQKD01vZGVsRGVwbG95bWVudBIbChNtb2RlbF9kZXBsb3ltZW50X2lkGAEgASgJEjcKBmludGVudBgCIAEoCzInLmRvdGNvcmUubW9kZWwudjEuTW9kZWxEZXBsb3ltZW50SW50ZW50EjsKCHNuYXBzaG90GAMgASgLMikuZG90Y29yZS5tb2RlbC52MS5Nb2RlbERlcGxveW1lbnRTbmFwc2hvdBI1CgVzdGF0ZRgEIAEoCzImLmRvdGNvcmUubW9kZWwudjEuTW9kZWxEZXBsb3ltZW50U3RhdGUSRAoNZGVzaXJlZF9zdGF0ZRgFIAEoDjItLmRvdGNvcmUubW9kZWwudjEuTW9kZWxEZXBsb3ltZW50RGVzaXJlZFN0YXRlElsKHnVwc3RyZWFtX2Rlc2lyZWRfY29uZmlndXJhdGlvbhgGIAEoCzIzLmRvdGNvcmUubW9kZWwudjEuVXBzdHJlYW1Nb2RlbERlc2lyZWRDb25maWd1cmF0aW9uEh0KFW1vZGVsX2RlcGxveW1lbnRfbmFtZRgHIAEoCRJPChJhY2NlbGVyYXRvcl9tZW1vcnkYCCABKAsyMy5kb3Rjb3JlLmRlcGxveW1lbnQudjEuQWNjZWxlcmF0b3JNZW1vcnlPYnNlcnZhdGlvbhJaCh9ub2RlX3Jlc29sdmVkX2xhdW5jaF9pZGVudGl0aWVzGAkgAygLMjEuZG90Y29yZS5kZXBsb3ltZW50LnYxLk5vZGVSZXNvbHZlZExhdW5jaElkZW50aXR5IpsBChVNb2RlbERlcGxveW1lbnRJbnRlbnQSOAoJcGxhY2VtZW50GAEgASgLMiUuZG90Y29yZS5kZXBsb3ltZW50LnYxLkV4YWN0UGxhY2VtZW50EkgKGHJlcXVlc3RlZF9wYXJhbGxlbF9zaGFwZRgCIAEoDjImLmRvdGNvcmUuY2F0YWxvZy52MS5Nb2RlbFBhcmFsbGVsU2hhcGUiswEKF01vZGVsRGVwbG95bWVudFNuYXBzaG90EkcKBWxvY2FsGAEgASgLMjYuZG90Y29yZS5tb2RlbC52MS5SZXNvbHZlZExvY2FsTW9kZWxEZXBsb3ltZW50U25hcHNob3RIABJFCgh1cHN0cmVhbRgEIAEoCzIxLmRvdGNvcmUubW9kZWwudjEuVXBzdHJlYW1Nb2RlbERlcGxveW1lbnRTbmFwc2hvdEgAQggKBnNvdXJjZSKeCQokUmVzb2x2ZWRMb2NhbE1vZGVsRGVwbG95bWVudFNuYXBzaG90EhAKCG1vZGVsX2lkGAEgASgJEhoKEm1vZGVsX2Rpc3BsYXlfbmFtZRgCIAEoCRIVCg1tb2RlbF92ZXJzaW9uGAMgASgJEhgKEGNvbmZpZ3VyYXRpb25faWQYBCABKAkSEgoKcHJvZmlsZV9pZBgFIAEoCRJBCg9lbmdpbmVfYXJ0aWZhY3QYBiABKAsyKC5kb3Rjb3JlLm1vZGVsLnYxLlJlc29sdmVkRW5naW5lQXJ0aWZhY3QSOQoObW9kZWxfYXJ0aWZhY3QYByABKAsyIS5kb3Rjb3JlLmNhdGFsb2cudjEuTW9kZWxBcnRpZmFjdBJHChNhdXhpbGlhcnlfYXJ0aWZhY3RzGAggAygLMiouZG90Y29yZS5jYXRhbG9nLnYxLk1vZGVsQXV4aWxpYXJ5QXJ0aWZhY3QSQwoQaGFyZHdhcmVfc3VwcG9ydBgVIAEoCzIpLmRvdGNvcmUubW9kZWwudjEuUmVzb2x2ZWRIYXJkd2FyZVN1cHBvcnQSSgoVcmVzb2x2ZWRfZW5naW5lX3N0YXRlGAogASgLMisuZG90Y29yZS5lbmdpbmUudjEuUmVzb2x2ZWRFbmdpbmVNb2RlbFN0YXRlEk4KFmV4ZWN1dGlvbl9jYXBhYmlsaXRpZXMYCyABKAsyLi5kb3Rjb3JlLmNhdGFsb2cudjEuTW9kZWxFeGVjdXRpb25DYXBhYmlsaXRpZXMSRgoSaW5wdXRfY2FwYWJpbGl0aWVzGAwgASgLMiouZG90Y29yZS5jYXRhbG9nLnYxLk1vZGVsSW5wdXRDYXBhYmlsaXRpZXMSPwoNcmVxdWVzdF9raW5kcxgWIAMoDjIoLmRvdGNvcmUuY2F0YWxvZy52MS5JbmZlcmVuY2VSZXF1ZXN0S2luZBJEChdlbmdpbmVfcGFyYW1ldGVyX3ZhbHVlcxgNIAEoCzIjLmRvdGNvcmUuY2F0YWxvZy52MS5QYXJhbWV0ZXJWYWx1ZXMSIgoaZW5naW5lX3BhcmFtZXRlcl9zY2hlbWFfaWQYDiABKAkSSgoXZW5naW5lX3BhcmFtZXRlcl9zY2hlbWEYDyABKAsyKS5kb3Rjb3JlLmNhdGFsb2cudjEuRW5naW5lUGFyYW1ldGVyU2NoZW1hEkYKE2Rlc2lyZWRfbGF1bmNoX3BsYW4YECABKAsyKS5kb3Rjb3JlLm1vZGVsLnYxLlBvcnRhYmxlTW9kZWxMYXVuY2hQbGFuEh4KFmN1c3RvbV9hcmd1bWVudF90b2tlbnMYESADKAkSPwoQcmVzb2x2ZWRfb3B0aW9ucxgSIAMoCzIlLmRvdGNvcmUubW9kZWwudjEuUmVzb2x2ZWRNb2RlbE9wdGlvbhJBCg9zZWxlY3RlZF90YXJnZXQYEyABKAsyKC5kb3Rjb3JlLm1vZGVsLnYxLlJlc29sdmVkU2VsZWN0ZWRUYXJnZXQSMAoGc2l6aW5nGBQgASgLMiAuZG90Y29yZS5tb2RlbC52MS5SZXNvbHZlZFNpemluZyJMChNSZXNvbHZlZE1vZGVsT3B0aW9uEjUKBnZhbHVlcxgBIAEoCzIlLmRvdGNvcmUuY2F0YWxvZy52MS5Nb2RlbE9wdGlvblZhbHVlcyKtAQoXUmVzb2x2ZWRIYXJkd2FyZVN1cHBvcnQSGQoRaGFyZHdhcmVfY2xhc3NfaWQYASABKAkSLgoIc2VsZWN0b3IYAiABKAsyHC5kb3Rjb3JlLmNhdGFsb2cudjEuU2VsZWN0b3ISRwoVaGFyZHdhcmVfcmVxdWlyZW1lbnRzGAMgASgLMiguZG90Y29yZS5jYXRhbG9nLnYxLkhhcmR3YXJlUmVxdWlyZW1lbnRzItIBChZSZXNvbHZlZFNlbGVjdGVkVGFyZ2V0El4KH2FjY2VsZXJhdG9yX21lbW9yeV9hcmNoaXRlY3R1cmUYASABKA4yNS5kb3Rjb3JlLm9ic2VydmF0aW9uLnYxLkFjY2VsZXJhdG9yTWVtb3J5QXJjaGl0ZWN0dXJlEjAKI2FjY2VsZXJhdG9yX21lbW9yeV9wb29sX3RvdGFsX2J5dGVzGAIgASgESACIAQFCJgokX2FjY2VsZXJhdG9yX21lbW9yeV9wb29sX3RvdGFsX2J5dGVzIrABCg5SZXNvbHZlZFNpemluZxIlChhhY2NlbGVyYXRvcl9tZW1vcnlfYnl0ZXMYASABKARIAIgBARIkChxkZXJpdmVkX2VuZ2luZV9wYXJhbWV0ZXJfaWRzGAIgAygJEh4KEWhvc3RfbWVtb3J5X2J5dGVzGAMgASgESAGIAQFCGwoZX2FjY2VsZXJhdG9yX21lbW9yeV9ieXRlc0IUChJfaG9zdF9tZW1vcnlfYnl0ZXMizwMKFlJlc29sdmVkRW5naW5lQXJ0aWZhY3QSGQoRZW5naW5lX3JlbGVhc2VfaWQYASABKAkSMgoLZW5naW5lX2tpbmQYAiABKA4yHS5kb3Rjb3JlLmVuZ2luZS52MS5FbmdpbmVLaW5kEhYKDmVuZ2luZV92ZXJzaW9uGAMgASgJEjQKCGFydGlmYWN0GAQgASgLMiIuZG90Y29yZS5jYXRhbG9nLnYxLkVuZ2luZUFydGlmYWN0EjEKCHBsYXRmb3JtGAcgASgLMh8uZG90Y29yZS5jYXRhbG9nLnYxLk9jaVBsYXRmb3JtEhMKC2FjY2VsZXJhdG9yGAggASgJEisKI21pbmltdW1fbnZpZGlhX2RyaXZlcl9tYWpvcl92ZXJzaW9uGAkgASgNEkwKEnByZXBhcmF0aW9uX3BvbGljeRgFIAEoDjIwLmRvdGNvcmUuZGVwbG95bWVudC52MS5BcnRpZmFjdFByZXBhcmF0aW9uUG9saWN5EhcKD2xhdW5jaF9pZGVudGl0eRgGIAEoCRI8ChBzdG9yYWdlX2JpbmRpbmdzGAogAygLMiIuZG90Y29yZS5jYXRhbG9nLnYxLlN0b3JhZ2VCaW5kaW5nIsMBChdQb3J0YWJsZU1vZGVsTGF1bmNoUGxhbhJFCglhcmd1bWVudHMYASADKAsyMi5kb3Rjb3JlLm1vZGVsLnYxLlBvcnRhYmxlTW9kZWxMYXVuY2hBcmd1bWVudFRva2VuEhYKDmZvcm1hdF92ZXJzaW9uGAIgASgNEkkKEWFydGlmYWN0X2JpbmRpbmdzGAMgAygLMi4uZG90Y29yZS5tb2RlbC52MS5Qb3J0YWJsZU1vZGVsQXJ0aWZhY3RCaW5kaW5nIsIBCiBQb3J0YWJsZU1vZGVsTGF1bmNoQXJndW1lbnRUb2tlbhIRCgdsaXRlcmFsGAEgASgJSAASHQoTYXJ0aWZhY3RfYmluZGluZ19pZBgCIAEoCUgAEjMKEWVuZ2luZV9leGVjdXRhYmxlGAMgASgLMhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5SAASFgoMc2VydmljZV9ob3N0GAQgASgJSAASFgoMc2VydmljZV9wb3J0GAUgASgJSABCBwoFdmFsdWUijwEKHFBvcnRhYmxlTW9kZWxBcnRpZmFjdEJpbmRpbmcSEgoKYmluZGluZ19pZBgBIAEoCRIbChFtb2RlbF9hcnRpZmFjdF9pZBgCIAEoCUgAEh8KFWF1eGlsaWFyeV9hcnRpZmFjdF9pZBgDIAEoCUgAEhEKCXJlYWRfb25seRgFIAEoCEIKCghhcnRpZmFjdCJtCh9VcHN0cmVhbU1vZGVsRGVwbG95bWVudFNuYXBzaG90EkoKF3Vwc3RyZWFtX21vZGVsX3RlbXBsYXRlGAEgASgLMikuZG90Y29yZS5jYXRhbG9nLnYxLlVwc3RyZWFtTW9kZWxUZW1wbGF0ZSKlAgohVXBzdHJlYW1Nb2RlbERlc2lyZWRDb25maWd1cmF0aW9uEhkKEXVwc3RyZWFtX21vZGVsX2lkGAEgASgJEg8KB2FwaV9rZXkYAiABKAkSTAoSaW5wdXRfY2FwYWJpbGl0aWVzGAQgASgLMjAuZG90Y29yZS5tb2RlbC52MS5VcHN0cmVhbU1vZGVsSW5wdXRDYXBhYmlsaXRpZXMSSQoIZW5kcG9pbnQYBSABKAsyNy5kb3Rjb3JlLm1vZGVsLnYxLk9wZW5BaUNvbXBhdGlibGVFbmRwb2ludENvbmZpZ3VyYXRpb24SOwoIYm91bmRhcnkYBiABKAsyKS5kb3Rjb3JlLmNhdGFsb2cudjEuVXBzdHJlYW1Nb2RlbEJvdW5kYXJ5Ik8KHlVwc3RyZWFtTW9kZWxJbnB1dENhcGFiaWxpdGllcxIVCg1zdXBwb3J0c190ZXh0GAEgASgIEhYKDnN1cHBvcnRzX2ltYWdlGAIgASgIIlYKJU9wZW5BaUNvbXBhdGlibGVFbmRwb2ludENvbmZpZ3VyYXRpb24SEAoIYmFzZV91cmwYASABKAkSGwoTYWxsb3dfaW5zZWN1cmVfaHR0cBgCIAEoCCqkAgoiTW9kZWxEZXBsb3ltZW50TGlmZWN5Y2xlVHJhbnNpdGlvbhI1CjFNT0RFTF9ERVBMT1lNRU5UX0xJRkVDWUNMRV9UUkFOU0lUSU9OX1VOU1BFQ0lGSUVEEAASMAosTU9ERUxfREVQTE9ZTUVOVF9MSUZFQ1lDTEVfVFJBTlNJVElPTl9DUkVBVEUQARIvCitNT0RFTF9ERVBMT1lNRU5UX0xJRkVDWUNMRV9UUkFOU0lUSU9OX1BBVVNFEAISMAosTU9ERUxfREVQTE9ZTUVOVF9MSUZFQ1lDTEVfVFJBTlNJVElPTl9SRVNVTUUQAxIyCi5NT0RFTF9ERVBMT1lNRU5UX0xJRkVDWUNMRV9UUkFOU0lUSU9OX1RFQVJET1dOEAQq0QEKG01vZGVsRGVwbG95bWVudERlc2lyZWRTdGF0ZRIuCipNT0RFTF9ERVBMT1lNRU5UX0RFU0lSRURfU1RBVEVfVU5TUEVDSUZJRUQQABIqCiZNT0RFTF9ERVBMT1lNRU5UX0RFU0lSRURfU1RBVEVfUFJFU0VOVBABEisKJ01PREVMX0RFUExPWU1FTlRfREVTSVJFRF9TVEFURV9URUFSRE9XThACEikKJU1PREVMX0RFUExPWU1FTlRfREVTSVJFRF9TVEFURV9QQVVTRUQQA2IGcHJvdG8z", [file_dotcore_catalog_v1_engine_release, file_dotcore_catalog_v1_execution, file_dotcore_catalog_v1_hardware_class, file_dotcore_catalog_v1_model, file_dotcore_catalog_v1_model_capabilities, file_dotcore_catalog_v1_oci, file_dotcore_catalog_v1_parameter, file_dotcore_catalog_v1_selector, file_dotcore_catalog_v1_storage, file_dotcore_catalog_v1_upstream_model_template, file_dotcore_deployment_v1_accelerator_memory, file_dotcore_deployment_v1_artifact, file_dotcore_deployment_v1_placement, file_dotcore_engine_v1_engine, file_dotcore_model_v1_state, file_dotcore_observation_v1_observation, file_google_protobuf_empty]);
+ fileDesc("CiFkb3Rjb3JlL21vZGVsL3YxL2RlcGxveW1lbnQucHJvdG8SEGRvdGNvcmUubW9kZWwudjEirwUKGk1vZGVsRGVwbG95bWVudERlY2xhcmF0aW9uEhsKE21vZGVsX2RlcGxveW1lbnRfaWQYASABKAkSOAoJcGxhY2VtZW50GAIgASgLMiUuZG90Y29yZS5kZXBsb3ltZW50LnYxLkV4YWN0UGxhY2VtZW50EkgKGHJlcXVlc3RlZF9wYXJhbGxlbF9zaGFwZRgDIAEoDjImLmRvdGNvcmUuY2F0YWxvZy52MS5Nb2RlbFBhcmFsbGVsU2hhcGUSOwoIc25hcHNob3QYBCABKAsyKS5kb3Rjb3JlLm1vZGVsLnYxLk1vZGVsRGVwbG95bWVudFNuYXBzaG90EkQKDWRlc2lyZWRfc3RhdGUYBSABKA4yLS5kb3Rjb3JlLm1vZGVsLnYxLk1vZGVsRGVwbG95bWVudERlc2lyZWRTdGF0ZRJbCh51cHN0cmVhbV9kZXNpcmVkX2NvbmZpZ3VyYXRpb24YBiABKAsyMy5kb3Rjb3JlLm1vZGVsLnYxLlVwc3RyZWFtTW9kZWxEZXNpcmVkQ29uZmlndXJhdGlvbhIdChVtb2RlbF9kZXBsb3ltZW50X25hbWUYByABKAkSHAoUbGlmZWN5Y2xlX2dlbmVyYXRpb24YCCABKAQSUgoUbGlmZWN5Y2xlX3RyYW5zaXRpb24YCSABKA4yNC5kb3Rjb3JlLm1vZGVsLnYxLk1vZGVsRGVwbG95bWVudExpZmVjeWNsZVRyYW5zaXRpb24SIwobbGlmZWN5Y2xlX3JlY292ZXJ5X3JlcXVpcmVkGAogASgIEloKH25vZGVfcmVzb2x2ZWRfbGF1bmNoX2lkZW50aXRpZXMYCyADKAsyMS5kb3Rjb3JlLmRlcGxveW1lbnQudjEuTm9kZVJlc29sdmVkTGF1bmNoSWRlbnRpdHkiygQKD01vZGVsRGVwbG95bWVudBIbChNtb2RlbF9kZXBsb3ltZW50X2lkGAEgASgJEjcKBmludGVudBgCIAEoCzInLmRvdGNvcmUubW9kZWwudjEuTW9kZWxEZXBsb3ltZW50SW50ZW50EjsKCHNuYXBzaG90GAMgASgLMikuZG90Y29yZS5tb2RlbC52MS5Nb2RlbERlcGxveW1lbnRTbmFwc2hvdBI1CgVzdGF0ZRgEIAEoCzImLmRvdGNvcmUubW9kZWwudjEuTW9kZWxEZXBsb3ltZW50U3RhdGUSRAoNZGVzaXJlZF9zdGF0ZRgFIAEoDjItLmRvdGNvcmUubW9kZWwudjEuTW9kZWxEZXBsb3ltZW50RGVzaXJlZFN0YXRlElsKHnVwc3RyZWFtX2Rlc2lyZWRfY29uZmlndXJhdGlvbhgGIAEoCzIzLmRvdGNvcmUubW9kZWwudjEuVXBzdHJlYW1Nb2RlbERlc2lyZWRDb25maWd1cmF0aW9uEh0KFW1vZGVsX2RlcGxveW1lbnRfbmFtZRgHIAEoCRJPChJhY2NlbGVyYXRvcl9tZW1vcnkYCCABKAsyMy5kb3Rjb3JlLmRlcGxveW1lbnQudjEuQWNjZWxlcmF0b3JNZW1vcnlPYnNlcnZhdGlvbhJaCh9ub2RlX3Jlc29sdmVkX2xhdW5jaF9pZGVudGl0aWVzGAkgAygLMjEuZG90Y29yZS5kZXBsb3ltZW50LnYxLk5vZGVSZXNvbHZlZExhdW5jaElkZW50aXR5IpsBChVNb2RlbERlcGxveW1lbnRJbnRlbnQSOAoJcGxhY2VtZW50GAEgASgLMiUuZG90Y29yZS5kZXBsb3ltZW50LnYxLkV4YWN0UGxhY2VtZW50EkgKGHJlcXVlc3RlZF9wYXJhbGxlbF9zaGFwZRgCIAEoDjImLmRvdGNvcmUuY2F0YWxvZy52MS5Nb2RlbFBhcmFsbGVsU2hhcGUiswEKF01vZGVsRGVwbG95bWVudFNuYXBzaG90EkcKBWxvY2FsGAEgASgLMjYuZG90Y29yZS5tb2RlbC52MS5SZXNvbHZlZExvY2FsTW9kZWxEZXBsb3ltZW50U25hcHNob3RIABJFCgh1cHN0cmVhbRgEIAEoCzIxLmRvdGNvcmUubW9kZWwudjEuVXBzdHJlYW1Nb2RlbERlcGxveW1lbnRTbmFwc2hvdEgAQggKBnNvdXJjZSL+CgokUmVzb2x2ZWRMb2NhbE1vZGVsRGVwbG95bWVudFNuYXBzaG90EhAKCG1vZGVsX2lkGAEgASgJEhoKEm1vZGVsX2Rpc3BsYXlfbmFtZRgCIAEoCRIVCg1tb2RlbF92ZXJzaW9uGAMgASgJEhgKEGNvbmZpZ3VyYXRpb25faWQYBCABKAkSEgoKcHJvZmlsZV9pZBgFIAEoCRJBCg9lbmdpbmVfYXJ0aWZhY3QYBiABKAsyKC5kb3Rjb3JlLm1vZGVsLnYxLlJlc29sdmVkRW5naW5lQXJ0aWZhY3QSOQoObW9kZWxfYXJ0aWZhY3QYByABKAsyIS5kb3Rjb3JlLmNhdGFsb2cudjEuTW9kZWxBcnRpZmFjdBJHChNhdXhpbGlhcnlfYXJ0aWZhY3RzGAggAygLMiouZG90Y29yZS5jYXRhbG9nLnYxLk1vZGVsQXV4aWxpYXJ5QXJ0aWZhY3QSQwoQaGFyZHdhcmVfc3VwcG9ydBgVIAEoCzIpLmRvdGNvcmUubW9kZWwudjEuUmVzb2x2ZWRIYXJkd2FyZVN1cHBvcnQSSgoVcmVzb2x2ZWRfZW5naW5lX3N0YXRlGAogASgLMisuZG90Y29yZS5lbmdpbmUudjEuUmVzb2x2ZWRFbmdpbmVNb2RlbFN0YXRlEk4KFmV4ZWN1dGlvbl9jYXBhYmlsaXRpZXMYCyABKAsyLi5kb3Rjb3JlLmNhdGFsb2cudjEuTW9kZWxFeGVjdXRpb25DYXBhYmlsaXRpZXMSRgoSaW5wdXRfY2FwYWJpbGl0aWVzGAwgASgLMiouZG90Y29yZS5jYXRhbG9nLnYxLk1vZGVsSW5wdXRDYXBhYmlsaXRpZXMSPwoNcmVxdWVzdF9raW5kcxgWIAMoDjIoLmRvdGNvcmUuY2F0YWxvZy52MS5JbmZlcmVuY2VSZXF1ZXN0S2luZBJEChdlbmdpbmVfcGFyYW1ldGVyX3ZhbHVlcxgNIAEoCzIjLmRvdGNvcmUuY2F0YWxvZy52MS5QYXJhbWV0ZXJWYWx1ZXMSIgoaZW5naW5lX3BhcmFtZXRlcl9zY2hlbWFfaWQYDiABKAkSSgoXZW5naW5lX3BhcmFtZXRlcl9zY2hlbWEYDyABKAsyKS5kb3Rjb3JlLmNhdGFsb2cudjEuRW5naW5lUGFyYW1ldGVyU2NoZW1hEk4KIXByb2ZpbGVfZW5naW5lX3BhcmFtZXRlcl9kZWZhdWx0cxgXIAEoCzIjLmRvdGNvcmUuY2F0YWxvZy52MS5QYXJhbWV0ZXJWYWx1ZXMSZgotcHJvZmlsZV9lbmdpbmVfcGFyYW1ldGVyX3Zpc2liaWxpdHlfb3ZlcnJpZGVzGBggAygLMi8uZG90Y29yZS5jYXRhbG9nLnYxLlBhcmFtZXRlclZpc2liaWxpdHlPdmVycmlkZRImCh5nZW5lcmF0ZWRfZW5naW5lX3BhcmFtZXRlcl9pZHMYGSADKAkSRgoTZGVzaXJlZF9sYXVuY2hfcGxhbhgQIAEoCzIpLmRvdGNvcmUubW9kZWwudjEuUG9ydGFibGVNb2RlbExhdW5jaFBsYW4SHgoWY3VzdG9tX2FyZ3VtZW50X3Rva2VucxgRIAMoCRI/ChByZXNvbHZlZF9vcHRpb25zGBIgAygLMiUuZG90Y29yZS5tb2RlbC52MS5SZXNvbHZlZE1vZGVsT3B0aW9uEkEKD3NlbGVjdGVkX3RhcmdldBgTIAEoCzIoLmRvdGNvcmUubW9kZWwudjEuUmVzb2x2ZWRTZWxlY3RlZFRhcmdldBIwCgZzaXppbmcYFCABKAsyIC5kb3Rjb3JlLm1vZGVsLnYxLlJlc29sdmVkU2l6aW5nIkwKE1Jlc29sdmVkTW9kZWxPcHRpb24SNQoGdmFsdWVzGAEgASgLMiUuZG90Y29yZS5jYXRhbG9nLnYxLk1vZGVsT3B0aW9uVmFsdWVzIq0BChdSZXNvbHZlZEhhcmR3YXJlU3VwcG9ydBIZChFoYXJkd2FyZV9jbGFzc19pZBgBIAEoCRIuCghzZWxlY3RvchgCIAEoCzIcLmRvdGNvcmUuY2F0YWxvZy52MS5TZWxlY3RvchJHChVoYXJkd2FyZV9yZXF1aXJlbWVudHMYAyABKAsyKC5kb3Rjb3JlLmNhdGFsb2cudjEuSGFyZHdhcmVSZXF1aXJlbWVudHMi0gEKFlJlc29sdmVkU2VsZWN0ZWRUYXJnZXQSXgofYWNjZWxlcmF0b3JfbWVtb3J5X2FyY2hpdGVjdHVyZRgBIAEoDjI1LmRvdGNvcmUub2JzZXJ2YXRpb24udjEuQWNjZWxlcmF0b3JNZW1vcnlBcmNoaXRlY3R1cmUSMAojYWNjZWxlcmF0b3JfbWVtb3J5X3Bvb2xfdG90YWxfYnl0ZXMYAiABKARIAIgBAUImCiRfYWNjZWxlcmF0b3JfbWVtb3J5X3Bvb2xfdG90YWxfYnl0ZXMisAEKDlJlc29sdmVkU2l6aW5nEiUKGGFjY2VsZXJhdG9yX21lbW9yeV9ieXRlcxgBIAEoBEgAiAEBEiQKHGRlcml2ZWRfZW5naW5lX3BhcmFtZXRlcl9pZHMYAiADKAkSHgoRaG9zdF9tZW1vcnlfYnl0ZXMYAyABKARIAYgBAUIbChlfYWNjZWxlcmF0b3JfbWVtb3J5X2J5dGVzQhQKEl9ob3N0X21lbW9yeV9ieXRlcyLPAwoWUmVzb2x2ZWRFbmdpbmVBcnRpZmFjdBIZChFlbmdpbmVfcmVsZWFzZV9pZBgBIAEoCRIyCgtlbmdpbmVfa2luZBgCIAEoDjIdLmRvdGNvcmUuZW5naW5lLnYxLkVuZ2luZUtpbmQSFgoOZW5naW5lX3ZlcnNpb24YAyABKAkSNAoIYXJ0aWZhY3QYBCABKAsyIi5kb3Rjb3JlLmNhdGFsb2cudjEuRW5naW5lQXJ0aWZhY3QSMQoIcGxhdGZvcm0YByABKAsyHy5kb3Rjb3JlLmNhdGFsb2cudjEuT2NpUGxhdGZvcm0SEwoLYWNjZWxlcmF0b3IYCCABKAkSKwojbWluaW11bV9udmlkaWFfZHJpdmVyX21ham9yX3ZlcnNpb24YCSABKA0STAoScHJlcGFyYXRpb25fcG9saWN5GAUgASgOMjAuZG90Y29yZS5kZXBsb3ltZW50LnYxLkFydGlmYWN0UHJlcGFyYXRpb25Qb2xpY3kSFwoPbGF1bmNoX2lkZW50aXR5GAYgASgJEjwKEHN0b3JhZ2VfYmluZGluZ3MYCiADKAsyIi5kb3Rjb3JlLmNhdGFsb2cudjEuU3RvcmFnZUJpbmRpbmciwwEKF1BvcnRhYmxlTW9kZWxMYXVuY2hQbGFuEkUKCWFyZ3VtZW50cxgBIAMoCzIyLmRvdGNvcmUubW9kZWwudjEuUG9ydGFibGVNb2RlbExhdW5jaEFyZ3VtZW50VG9rZW4SFgoOZm9ybWF0X3ZlcnNpb24YAiABKA0SSQoRYXJ0aWZhY3RfYmluZGluZ3MYAyADKAsyLi5kb3Rjb3JlLm1vZGVsLnYxLlBvcnRhYmxlTW9kZWxBcnRpZmFjdEJpbmRpbmciwgEKIFBvcnRhYmxlTW9kZWxMYXVuY2hBcmd1bWVudFRva2VuEhEKB2xpdGVyYWwYASABKAlIABIdChNhcnRpZmFjdF9iaW5kaW5nX2lkGAIgASgJSAASMwoRZW5naW5lX2V4ZWN1dGFibGUYAyABKAsyFi5nb29nbGUucHJvdG9idWYuRW1wdHlIABIWCgxzZXJ2aWNlX2hvc3QYBCABKAlIABIWCgxzZXJ2aWNlX3BvcnQYBSABKAlIAEIHCgV2YWx1ZSKPAQocUG9ydGFibGVNb2RlbEFydGlmYWN0QmluZGluZxISCgpiaW5kaW5nX2lkGAEgASgJEhsKEW1vZGVsX2FydGlmYWN0X2lkGAIgASgJSAASHwoVYXV4aWxpYXJ5X2FydGlmYWN0X2lkGAMgASgJSAASEQoJcmVhZF9vbmx5GAUgASgIQgoKCGFydGlmYWN0Im0KH1Vwc3RyZWFtTW9kZWxEZXBsb3ltZW50U25hcHNob3QSSgoXdXBzdHJlYW1fbW9kZWxfdGVtcGxhdGUYASABKAsyKS5kb3Rjb3JlLmNhdGFsb2cudjEuVXBzdHJlYW1Nb2RlbFRlbXBsYXRlIqUCCiFVcHN0cmVhbU1vZGVsRGVzaXJlZENvbmZpZ3VyYXRpb24SGQoRdXBzdHJlYW1fbW9kZWxfaWQYASABKAkSDwoHYXBpX2tleRgCIAEoCRJMChJpbnB1dF9jYXBhYmlsaXRpZXMYBCABKAsyMC5kb3Rjb3JlLm1vZGVsLnYxLlVwc3RyZWFtTW9kZWxJbnB1dENhcGFiaWxpdGllcxJJCghlbmRwb2ludBgFIAEoCzI3LmRvdGNvcmUubW9kZWwudjEuT3BlbkFpQ29tcGF0aWJsZUVuZHBvaW50Q29uZmlndXJhdGlvbhI7Cghib3VuZGFyeRgGIAEoCzIpLmRvdGNvcmUuY2F0YWxvZy52MS5VcHN0cmVhbU1vZGVsQm91bmRhcnkiTwoeVXBzdHJlYW1Nb2RlbElucHV0Q2FwYWJpbGl0aWVzEhUKDXN1cHBvcnRzX3RleHQYASABKAgSFgoOc3VwcG9ydHNfaW1hZ2UYAiABKAgiVgolT3BlbkFpQ29tcGF0aWJsZUVuZHBvaW50Q29uZmlndXJhdGlvbhIQCghiYXNlX3VybBgBIAEoCRIbChNhbGxvd19pbnNlY3VyZV9odHRwGAIgASgIKqQCCiJNb2RlbERlcGxveW1lbnRMaWZlY3ljbGVUcmFuc2l0aW9uEjUKMU1PREVMX0RFUExPWU1FTlRfTElGRUNZQ0xFX1RSQU5TSVRJT05fVU5TUEVDSUZJRUQQABIwCixNT0RFTF9ERVBMT1lNRU5UX0xJRkVDWUNMRV9UUkFOU0lUSU9OX0NSRUFURRABEi8KK01PREVMX0RFUExPWU1FTlRfTElGRUNZQ0xFX1RSQU5TSVRJT05fUEFVU0UQAhIwCixNT0RFTF9ERVBMT1lNRU5UX0xJRkVDWUNMRV9UUkFOU0lUSU9OX1JFU1VNRRADEjIKLk1PREVMX0RFUExPWU1FTlRfTElGRUNZQ0xFX1RSQU5TSVRJT05fVEVBUkRPV04QBCrRAQobTW9kZWxEZXBsb3ltZW50RGVzaXJlZFN0YXRlEi4KKk1PREVMX0RFUExPWU1FTlRfREVTSVJFRF9TVEFURV9VTlNQRUNJRklFRBAAEioKJk1PREVMX0RFUExPWU1FTlRfREVTSVJFRF9TVEFURV9QUkVTRU5UEAESKwonTU9ERUxfREVQTE9ZTUVOVF9ERVNJUkVEX1NUQVRFX1RFQVJET1dOEAISKQolTU9ERUxfREVQTE9ZTUVOVF9ERVNJUkVEX1NUQVRFX1BBVVNFRBADYgZwcm90bzM", [file_dotcore_catalog_v1_engine_release, file_dotcore_catalog_v1_execution, file_dotcore_catalog_v1_hardware_class, file_dotcore_catalog_v1_model, file_dotcore_catalog_v1_model_capabilities, file_dotcore_catalog_v1_oci, file_dotcore_catalog_v1_parameter, file_dotcore_catalog_v1_selector, file_dotcore_catalog_v1_storage, file_dotcore_catalog_v1_upstream_model_template, file_dotcore_deployment_v1_accelerator_memory, file_dotcore_deployment_v1_artifact, file_dotcore_deployment_v1_placement, file_dotcore_engine_v1_engine, file_dotcore_model_v1_state, file_dotcore_observation_v1_observation, file_google_protobuf_empty]);
/**
* Declarative active model deployment in a desired site. It is written only by
@@ -401,6 +401,27 @@ export type ResolvedLocalModelDeploymentSnapshot = Message<"dotcore.model.v1.Res
*/
engineParameterSchema?: EngineParameterSchema;
+ /**
+ * Engine parameter defaults of the selected profile at creation, each parameter it leaves out taking the engine's.
+ *
+ * @generated from field: dotcore.catalog.v1.ParameterValues profile_engine_parameter_defaults = 23;
+ */
+ profileEngineParameterDefaults?: ParameterValues;
+
+ /**
+ * Engine parameter visibility overrides of the selected profile at creation.
+ *
+ * @generated from field: repeated dotcore.catalog.v1.ParameterVisibilityOverride profile_engine_parameter_visibility_overrides = 24;
+ */
+ profileEngineParameterVisibilityOverrides: ParameterVisibilityOverride[];
+
+ /**
+ * Secret engine parameter ids whose values were generated at creation, in schema order.
+ *
+ * @generated from field: repeated string generated_engine_parameter_ids = 25;
+ */
+ generatedEngineParameterIds: string[];
+
/**
* Exact portable desired launch plan used for reconciliation.
*
diff --git a/proto/dotcore/model/v1/deployment.proto b/proto/dotcore/model/v1/deployment.proto
index c209c97f..b2be1ea9 100644
--- a/proto/dotcore/model/v1/deployment.proto
+++ b/proto/dotcore/model/v1/deployment.proto
@@ -162,6 +162,15 @@ message ResolvedLocalModelDeploymentSnapshot {
// Exact accepted engine parameter schema retained for offline interpretation.
dotcore.catalog.v1.EngineParameterSchema engine_parameter_schema = 15;
+ // Engine parameter defaults of the selected profile at creation, each parameter it leaves out taking the engine's.
+ dotcore.catalog.v1.ParameterValues profile_engine_parameter_defaults = 23;
+
+ // Engine parameter visibility overrides of the selected profile at creation.
+ repeated dotcore.catalog.v1.ParameterVisibilityOverride profile_engine_parameter_visibility_overrides = 24;
+
+ // Secret engine parameter ids whose values were generated at creation, in schema order.
+ repeated string generated_engine_parameter_ids = 25;
+
// Exact portable desired launch plan used for reconciliation.
PortableModelLaunchPlan desired_launch_plan = 16;
From c8ee20a4793727ec17ea75a6079b6f9a47043232 Mon Sep 17 00:00:00 2001
From: Andre-Philippe Paquet
Date: Thu, 12 Nov 2026 19:11:22 -0500
Subject: [PATCH 28/40] unreviewed: feat(backend): accept secret engine
parameters
---
backend/catalog/src/lib.rs | 91 ++++-
.../catalog/src/validation/engine_release.rs | 29 +-
backend/catalog/src/validation/mod.rs | 11 +-
backend/catalog/src/validation/model.rs | 93 ++++-
.../core/src/application/controller/mod.rs | 3 +-
.../core/src/application/controller/owner.rs | 2 +-
.../src/application/controller/parameters.rs | 67 +---
.../application/controller/test_support.rs | 27 +-
.../core/src/application/controller/tests.rs | 4 +-
backend/core/src/application/mod.rs | 3 +-
backend/core/src/composition/mod.rs | 4 +-
backend/core/src/deployment_logs/mod.rs | 5 -
backend/core/src/development/mod.rs | 5 +-
backend/core/src/development/tests.rs | 3 +
backend/core/src/dispatch/model.rs | 44 ++-
backend/core/src/lib.rs | 3 +
backend/core/src/model/compatibility.rs | 3 +-
backend/core/src/model/controller/logs.rs | 114 +++++-
.../model/controller/reconciliation/mod.rs | 30 +-
.../controller/reconciliation/tests/mod.rs | 332 +++++++++++++++++-
.../core/src/model/controller/test_support.rs | 14 +
.../src/model/engine/ninfer/declaration.rs | 1 +
.../src/model/engine/strata/declaration.rs | 202 ++++-------
.../core/src/model/engine/vllm/declaration.rs | 2 +
backend/core/src/model/repository.rs | 324 ++++++++++++++++-
backend/core/src/model/resolution.rs | 136 ++++++-
backend/core/src/model/resolved.rs | 127 ++++++-
backend/core/src/secret_generator.rs | 71 ++++
backend/core/src/test_support/mod.rs | 26 ++
backend/core/src/test_support/strata.rs | 173 ++++++++-
.../core/tests/backend/catalog_contracts.rs | 15 +-
.../tests/gated/nvidia_docker_integration.rs | 1 +
.../gated/vllm_nvidia_docker_integration.rs | 1 +
backend/domain/src/test_support/model.rs | 3 +
docs/specs/models.md | 11 +-
35 files changed, 1660 insertions(+), 320 deletions(-)
create mode 100644 backend/core/src/secret_generator.rs
diff --git a/backend/catalog/src/lib.rs b/backend/catalog/src/lib.rs
index 7e518c85..623ffbf5 100644
--- a/backend/catalog/src/lib.rs
+++ b/backend/catalog/src/lib.rs
@@ -46,9 +46,10 @@ use source::{CatalogSourceRecords, CatalogSourceRole, load_catalog_source};
pub use validation::CatalogDiagnostic;
pub use validation::EngineDeclarationValidator;
pub use validation::{
- overlay_parameter_defaults, valid_configuration_value, valid_environment_variable,
- valid_pinned_hugging_face_artifact, validate_application_with_policy,
- validate_parameter_values, validate_supplied_parameter_values,
+ is_secret_parameter, overlay_parameter_defaults, valid_configuration_value,
+ valid_environment_variable, valid_pinned_hugging_face_artifact,
+ validate_application_with_policy, validate_parameter_values,
+ validate_parameter_values_before_secrets, validate_supplied_parameter_values,
validate_upstream_model_template,
};
@@ -1936,6 +1937,90 @@ artifacts:
);
}
+ #[test]
+ fn a_secret_engine_parameter_needs_no_default_and_takes_no_profile_default_or_sizing() {
+ let load = |sizing: &str, profile_defaults: &str| {
+ let root = temporary_catalog_root();
+ create_empty_catalog_source(&root);
+ write_sizing_engine(&root);
+ let engine_path = root.join("engines/fixture.yaml");
+ let engine = std::fs::read_to_string(&engine_path).expect("sizing engine");
+ std::fs::write(
+ &engine_path,
+ engine.replace(
+ "artifacts:\n",
+ " - parameterId: \"api-key\"\n displayName: \"API key\"\n visibility: \"PARAMETER_VISIBILITY_ADVANCED\"\n secret: { generatedLength: 32, minimumLength: 16 }\nartifacts:\n",
+ ),
+ )
+ .expect("sizing engine with a secret");
+ write_sizing_model_with(
+ &root,
+ sizing,
+ UNPARAMETERIZED_BINDING_SIZING,
+ profile_defaults,
+ );
+ Catalog::load_with_policy(
+ &root,
+ OciArtifactPolicy::Development,
+ &ACCEPT_ENGINE_DECLARATIONS,
+ )
+ };
+ let sizing = |estimate: &str, derived: &str| {
+ format!(
+ " sizing:\n{derived} estimates:\n acceleratorMemory:\n unit: bytes\n equation: \"{estimate}\"\n"
+ )
+ };
+ let defaults = format!(" values:\n{SIZING_ENGINE_CACHE_DEFAULT}");
+
+ let catalog = load(&sizing("${total-context}", ""), &defaults).expect("secret engine");
+ assert_eq!(
+ catalog
+ .local_defaults_for(&CatalogModelId("fixture-model".to_owned()), "balanced")
+ .expect("profile defaults")
+ .1,
+ unsigned_values(&[
+ ("prompt-cache-memory-mib", 0),
+ ("total-context", 32_768),
+ ("parallel-requests", 4),
+ ])
+ );
+
+ let error = load(
+ &sizing("${total-context}", ""),
+ &format!(
+ "{defaults} - parameterId: \"api-key\"\n string: \"catalog-literal-key\"\n"
+ ),
+ )
+ .expect_err("a profile default on a secret")
+ .to_string();
+ assert!(
+ error.contains(
+ "profile \"balanced\" sets secret engine parameter \"api-key\", which each deployment generates or receives"
+ ),
+ "{error}"
+ );
+ assert!(!error.contains("catalog-literal-key"), "{error}");
+
+ let error = load(&sizing("${total-context} + ${api-key}", ""), &defaults)
+ .expect_err("an estimate reading a secret")
+ .to_string();
+ assert!(error.contains("api-key"), "{error}");
+
+ let error = load(
+ &sizing(
+ "${total-context}",
+ " derivedParameters:\n api-key: \"1\"\n",
+ ),
+ &defaults,
+ )
+ .expect_err("a derivation of a secret")
+ .to_string();
+ assert!(
+ error.contains("derived parameter \"api-key\" is not an unsigned engine parameter"),
+ "{error}"
+ );
+ }
+
#[test]
fn the_linearity_guard_reports_sizing_that_bends_with_the_total_context() {
let guard = |case: &str, equation: &str, allowance: &str| {
diff --git a/backend/catalog/src/validation/engine_release.rs b/backend/catalog/src/validation/engine_release.rs
index bd9399fd..0ba86ac1 100644
--- a/backend/catalog/src/validation/engine_release.rs
+++ b/backend/catalog/src/validation/engine_release.rs
@@ -233,7 +233,7 @@ fn validate_parameter_schema(
}
/// Validates one engine parameter definition: the shared definition rules, a unique id, and the
-/// engine owner rule that parameters are booleans or unsigned integers with a unit.
+/// engine owner rule that parameters are booleans, unsigned integers with a unit, or secrets.
fn validate_parameter_definition(
definition: &ParameterDefinition,
ids: &mut BTreeSet,
@@ -248,12 +248,17 @@ fn validate_parameter_definition(
Some(ParameterType::UnsignedInteger(schema)) if schema.unit.trim().is_empty() => {
errors.push(anyhow::anyhow!("engine parameter {id:?} requires a unit"));
}
- Some(ParameterType::StringEnum(_) | ParameterType::Text(_) | ParameterType::Secret(_)) => {
+ Some(ParameterType::StringEnum(_) | ParameterType::Text(_)) => {
errors.push(anyhow::anyhow!(
- "engine parameter {id:?} must be a boolean or an unsigned integer"
+ "engine parameter {id:?} must be a boolean, an unsigned integer or a secret"
))
}
- Some(ParameterType::Boolean(_) | ParameterType::UnsignedInteger(_)) | None => {}
+ Some(
+ ParameterType::Boolean(_)
+ | ParameterType::UnsignedInteger(_)
+ | ParameterType::Secret(_),
+ )
+ | None => {}
}
}
@@ -433,7 +438,7 @@ mod tests {
}
#[test]
- fn engine_parameters_are_booleans_or_unsigned_integers_with_a_unit() {
+ fn engine_parameters_are_booleans_unsigned_integers_with_a_unit_or_secrets() {
let definition = |parameter_type| ParameterDefinition {
parameter_id: "feature".to_owned(),
display_name: "Feature".to_owned(),
@@ -475,7 +480,17 @@ mod tests {
default_value: Some("on".to_owned()),
},
))),
- ["engine parameter \"feature\" must be a boolean or an unsigned integer"]
+ ["engine parameter \"feature\" must be a boolean, an unsigned integer or a secret"]
+ );
+ assert_eq!(
+ errors(&definition(ParameterType::Text(
+ dotcore_proto::dotcore::catalog::v1::TextParameterSchema {
+ minimum_length: 0,
+ maximum_length: 8,
+ default_value: Some("on".to_owned()),
+ },
+ ))),
+ ["engine parameter \"feature\" must be a boolean, an unsigned integer or a secret"]
);
assert_eq!(
errors(&definition(ParameterType::Secret(
@@ -484,7 +499,7 @@ mod tests {
minimum_length: 12,
},
))),
- ["engine parameter \"feature\" must be a boolean or an unsigned integer"]
+ Vec::::new()
);
let mut basic = definition(boolean.clone());
basic.visibility = ParameterVisibility::Basic as i32;
diff --git a/backend/catalog/src/validation/mod.rs b/backend/catalog/src/validation/mod.rs
index 6979fa79..78945519 100644
--- a/backend/catalog/src/validation/mod.rs
+++ b/backend/catalog/src/validation/mod.rs
@@ -24,7 +24,8 @@ pub use application::{
valid_configuration_value, valid_environment_variable, validate_application_with_policy,
};
pub use model::{
- overlay_parameter_defaults, valid_pinned_hugging_face_artifact, validate_parameter_values,
+ is_secret_parameter, overlay_parameter_defaults, valid_pinned_hugging_face_artifact,
+ validate_parameter_values, validate_parameter_values_before_secrets,
validate_supplied_parameter_values,
};
pub(super) use sensitive_literal::validate_sensitive_literals;
@@ -456,10 +457,14 @@ fn validate_sizing_coverage(
continue;
};
let defaulted = model::overlay_parameter_defaults(values, &engine_defaults);
- // Resolution fills the context per request, so no sizing needs to derive it.
+ // Resolution fills the context per request and deployment creation generates
+ // secrets, so no sizing needs to derive either.
for parameter_id in model::missing_parameter_ids(&defaulted, engine)
.into_iter()
- .filter(|parameter_id| parameter_id != CONTEXT_PER_REQUEST_PARAMETER_ID)
+ .filter(|parameter_id| {
+ parameter_id != CONTEXT_PER_REQUEST_PARAMETER_ID
+ && !model::is_secret_parameter(engine, parameter_id)
+ })
{
let hidden = profile.engine_parameter_visibility_overrides.iter().any(
|visibility_override| {
diff --git a/backend/catalog/src/validation/model.rs b/backend/catalog/src/validation/model.rs
index 3e46398b..be4ad525 100644
--- a/backend/catalog/src/validation/model.rs
+++ b/backend/catalog/src/validation/model.rs
@@ -31,7 +31,7 @@ use crate::{
},
selector::{SelectorRelation, selector_relation},
shared_parameters::{profile_may_default, profile_visibility_problem},
- typed_parameters::default_value,
+ typed_parameters::{check_value, default_value, is_secret},
validation::{EngineDeclarationValidator, configuration_engine_release},
};
@@ -834,6 +834,18 @@ fn validate_profiles(
value.parameter_id
));
}
+ // A secret default would be a literal every deployment shares.
+ for value in values
+ .values
+ .iter()
+ .filter(|value| is_secret_parameter(engine, &value.parameter_id))
+ {
+ errors.push(anyhow::anyhow!(
+ "profile {:?} sets secret engine parameter {:?}, which each deployment generates or receives",
+ profile.profile_id,
+ value.parameter_id
+ ));
+ }
}
None => errors.push(anyhow::anyhow!(
"profile {:?} requires engine parameter defaults",
@@ -918,15 +930,39 @@ pub fn validate_parameter_values(
profile_id: &str,
errors: &mut Vec,
) {
- let missing = missing_parameter_ids(values, engine);
- if !missing.is_empty() {
- errors.push(anyhow::anyhow!(
- "profile {profile_id:?} engine parameter values are incomplete; missing {}",
- missing.join(", ")
- ));
- return;
- }
- validate_supplied_parameter_values(values, engine, profile_id, errors);
+ validate_values(
+ values,
+ engine,
+ profile_id,
+ SecretCompleteness::Required,
+ errors,
+ );
+}
+
+/// Validates engine parameter values that are complete except, possibly, for secrets, whose
+/// values exist only once a deployment is created.
+pub fn validate_parameter_values_before_secrets(
+ values: &ParameterValues,
+ engine: &EngineRelease,
+ profile_id: &str,
+ errors: &mut Vec,
+) {
+ validate_values(
+ values,
+ engine,
+ profile_id,
+ SecretCompleteness::Optional,
+ errors,
+ );
+}
+
+/// Whether the engine release declares `parameter_id` as a secret.
+pub fn is_secret_parameter(engine: &EngineRelease, parameter_id: &str) -> bool {
+ engine
+ .parameter_schema
+ .iter()
+ .flat_map(|schema| &schema.parameters)
+ .any(|definition| definition.parameter_id == parameter_id && is_secret(definition))
}
/// Validates supplied engine parameter values, which may omit parameters that sizing derives.
@@ -974,6 +1010,13 @@ pub fn validate_supplied_parameter_values(
}
(Some(EngineParameterType::Boolean(_)), Some(EngineParameterValueKind::Boolean(_))) => {
}
+ (Some(EngineParameterType::Secret(_)), Some(value @ EngineParameterValueKind::String(_))) => {
+ if let Err(error) = check_value(definition, value) {
+ errors.push(anyhow::anyhow!(
+ "profile {profile_id:?} engine parameter {parameter_id:?} value is invalid: {error}"
+ ));
+ }
+ }
_ => errors.push(anyhow::anyhow!(
"profile {profile_id:?} engine parameter {parameter_id:?} has a value of the wrong type"
)),
@@ -1041,6 +1084,36 @@ pub fn missing_parameter_ids(values: &ParameterValues, engine: &EngineRelease) -
.unwrap_or_default()
}
+/// Whether value completeness covers secret parameters.
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum SecretCompleteness {
+ Required,
+ Optional,
+}
+
+fn validate_values(
+ values: &ParameterValues,
+ engine: &EngineRelease,
+ profile_id: &str,
+ secrets: SecretCompleteness,
+ errors: &mut Vec,
+) {
+ let missing = missing_parameter_ids(values, engine)
+ .into_iter()
+ .filter(|parameter_id| {
+ secrets == SecretCompleteness::Required || !is_secret_parameter(engine, parameter_id)
+ })
+ .collect::>();
+ if !missing.is_empty() {
+ errors.push(anyhow::anyhow!(
+ "profile {profile_id:?} engine parameter values are incomplete; missing {}",
+ missing.join(", ")
+ ));
+ return;
+ }
+ validate_supplied_parameter_values(values, engine, profile_id, errors);
+}
+
fn valid_weight_quantization(value: &str) -> bool {
value.is_empty()
|| (value.len() <= MAX_WEIGHT_QUANTIZATION_BYTES
diff --git a/backend/core/src/application/controller/mod.rs b/backend/core/src/application/controller/mod.rs
index 7b4d803b..0bbcf2e8 100644
--- a/backend/core/src/application/controller/mod.rs
+++ b/backend/core/src/application/controller/mod.rs
@@ -83,7 +83,6 @@ use operations::{
create_deployment, pause_deployment, remove_deployment, resume_deployment, update_metadata,
};
use owner::ApplicationOwner;
-pub use parameters::{RandomSecretGenerator, SecretGenerator};
use projection::project;
use realization::{
ApplicationRealizationInput, ApplicationRealizationResults, ApplicationRealizationSnapshot,
@@ -209,7 +208,7 @@ pub struct ApplicationControllerConfiguration {
pub configuration_sources: ApplicationConfigurationSources,
pub inference_route_resolver: Arc,
pub target_capabilities: Arc,
- pub secret_generator: Arc,
+ pub secret_generator: Arc,
}
/// Apps-controller lifecycle authority on the site manager: the owner of application deployment
diff --git a/backend/core/src/application/controller/owner.rs b/backend/core/src/application/controller/owner.rs
index 31f77630..076894b8 100644
--- a/backend/core/src/application/controller/owner.rs
+++ b/backend/core/src/application/controller/owner.rs
@@ -48,7 +48,7 @@ pub(super) struct ApplicationOwner {
pub(super) configuration_sources: ApplicationConfigurationSources,
pub(super) inference_route_resolver: Arc,
pub(super) target_capabilities: Arc,
- pub(super) secret_generator: Arc,
+ pub(super) secret_generator: Arc,
pub(super) desired: Arc,
pub(super) current: Arc,
diff --git a/backend/core/src/application/controller/parameters.rs b/backend/core/src/application/controller/parameters.rs
index 6c8f9374..e850e88b 100644
--- a/backend/core/src/application/controller/parameters.rs
+++ b/backend/core/src/application/controller/parameters.rs
@@ -13,42 +13,7 @@ use dotcore_proto::dotcore::catalog::v1::{
};
use super::errors::{ApplicationLifecycleError, ParameterRejection, configuration};
-
-/// Source of the random values generated for secret parameters an operator leaves out.
-pub trait SecretGenerator: Send + Sync {
- /// Returns `length` characters drawn uniformly from ASCII letters and digits.
- fn generate(&self, length: usize) -> anyhow::Result;
-}
-
-/// Generates secret values from the operating system's cryptographic random source.
-#[derive(Clone, Copy, Debug, Default)]
-pub struct RandomSecretGenerator;
-
-impl SecretGenerator for RandomSecretGenerator {
- fn generate(&self, length: usize) -> anyhow::Result {
- // 248 is the largest multiple of the alphabet size within a byte; rejecting bytes above
- // it keeps every character equally likely.
- const ACCEPTED_BYTES: u8 =
- (u8::MAX / SECRET_ALPHABET.len() as u8) * SECRET_ALPHABET.len() as u8;
- let mut value = String::with_capacity(length);
- let mut bytes = [0_u8; 64];
- while value.len() < length {
- getrandom::fill(&mut bytes).map_err(|source| {
- anyhow::Error::new(EntropyError(source)).context("Secret entropy failed")
- })?;
- value.extend(
- bytes
- .iter()
- .filter(|byte| **byte < ACCEPTED_BYTES)
- .map(|byte| {
- char::from(SECRET_ALPHABET[usize::from(*byte) % SECRET_ALPHABET.len()])
- })
- .take(length - value.len()),
- );
- }
- Ok(value)
- }
-}
+use crate::SecretGenerator;
/// The parameter values a new deployment retains and the secrets among them that were generated.
#[derive(Debug, PartialEq, Eq)]
@@ -121,33 +86,3 @@ pub(super) fn resolve_parameter_values(
}
Ok(resolved)
}
-
-/// Characters a generated secret is drawn from: safe in environment values, URLs and shells.
-const SECRET_ALPHABET: &[u8; 62] =
- b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
-
-#[derive(Debug, thiserror::Error)]
-#[error("{0}")]
-struct EntropyError(getrandom::Error);
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn random_secrets_have_the_requested_length_from_the_alphanumeric_alphabet() {
- let generator = RandomSecretGenerator;
- for length in [1, 32, 200] {
- let value = generator.generate(length).expect("generated secret");
- assert_eq!(value.len(), length);
- assert!(
- value.bytes().all(|byte| SECRET_ALPHABET.contains(&byte)),
- "{value}"
- );
- }
- assert_ne!(
- generator.generate(32).expect("first secret"),
- generator.generate(32).expect("second secret")
- );
- }
-}
diff --git a/backend/core/src/application/controller/test_support.rs b/backend/core/src/application/controller/test_support.rs
index 6e923161..f9e4945f 100644
--- a/backend/core/src/application/controller/test_support.rs
+++ b/backend/core/src/application/controller/test_support.rs
@@ -69,6 +69,7 @@ use dotcore_site::{
use tokio::sync::Notify;
use super::*;
+pub(super) use crate::test_support::{sequential_secret, test_secret_generator};
#[derive(Default)]
pub(super) struct FailingApplicationPreparer {
@@ -2772,32 +2773,6 @@ pub(super) fn test_inference_route_resolver_with(
Arc::new(TestInferenceRouteResolver { result })
}
-/// A secret generator whose values tests can predict: the `n`th call returns `secret` padded
-/// with `x` to the requested length.
-pub(super) fn test_secret_generator() -> Arc {
- Arc::new(SequentialSecretGenerator::default())
-}
-
-/// The `n`th value [`test_secret_generator`] returns at `length`, counting from 1.
-pub(super) fn sequential_secret(n: usize, length: usize) -> String {
- let mut value = format!("secret{n}");
- value.extend(std::iter::repeat_n('x', length.saturating_sub(value.len())));
- value.truncate(length);
- value
-}
-
-#[derive(Default)]
-struct SequentialSecretGenerator {
- calls: AtomicUsize,
-}
-
-impl super::SecretGenerator for SequentialSecretGenerator {
- fn generate(&self, length: usize) -> anyhow::Result {
- let n = self.calls.fetch_add(1, Ordering::SeqCst) + 1;
- Ok(sequential_secret(n, length))
- }
-}
-
pub(super) fn resolved_route(route_name: &str) -> ResolvedInferenceRoute {
ResolvedInferenceRoute {
inference_route_id: "route-id".to_owned(),
diff --git a/backend/core/src/application/controller/tests.rs b/backend/core/src/application/controller/tests.rs
index 409d7817..333f1a3f 100644
--- a/backend/core/src/application/controller/tests.rs
+++ b/backend/core/src/application/controller/tests.rs
@@ -8194,7 +8194,7 @@ async fn invalid_secret_and_text_values_reject_creation_without_quoting_them() {
fn secret_parameter_test_controller(
directory: &Path,
- secret_generator: Arc,
+ secret_generator: Arc,
) -> (
TestController,
TestApplicationDeclarationRepository,
@@ -8222,7 +8222,7 @@ fn parameter_test_controller(
fn test_controller_for(
directory: &Path,
application: dotcore_proto::dotcore::catalog::v1::Application,
- secret_generator: Arc,
+ secret_generator: Arc,
) -> (
TestController,
TestApplicationDeclarationRepository,
diff --git a/backend/core/src/application/mod.rs b/backend/core/src/application/mod.rs
index cf53ec62..7080a8a7 100644
--- a/backend/core/src/application/mod.rs
+++ b/backend/core/src/application/mod.rs
@@ -16,6 +16,5 @@ pub use controller::{
ApplicationConfigurationSources, ApplicationController, ApplicationControllerConfiguration,
ApplicationDeploymentLogController, ApplicationLifecycleController, ApplicationLifecycleError,
InferenceRouteResolutionError, InferenceRouteResolver, ParameterRejection,
- RandomSecretGenerator, ResolvedInferenceRoute, RetainedTargetMismatch, SecretGenerator,
- TargetIneligibility,
+ ResolvedInferenceRoute, RetainedTargetMismatch, TargetIneligibility,
};
diff --git a/backend/core/src/composition/mod.rs b/backend/core/src/composition/mod.rs
index 609c4a3f..d3513d0a 100644
--- a/backend/core/src/composition/mod.rs
+++ b/backend/core/src/composition/mod.rs
@@ -59,7 +59,7 @@ use tracing::{Instrument, Span, debug, error, field, info, info_span, warn};
use crate::{
application::{
ApplicationConfigurationSources, ApplicationController, ApplicationControllerConfiguration,
- RandomSecretGenerator, service::ApplicationService,
+ service::ApplicationService,
},
model::{controller::InferenceController, service::ModelDeploymentService},
route_manager::{
@@ -370,7 +370,7 @@ where
),
inference_route_resolver: inference_route_resolver(routing.route_manager.clone()),
target_capabilities,
- secret_generator: Arc::new(RandomSecretGenerator),
+ secret_generator: Arc::new(crate::RandomSecretGenerator),
},
log_admission,
)?;
diff --git a/backend/core/src/deployment_logs/mod.rs b/backend/core/src/deployment_logs/mod.rs
index 60cf7cec..53eb24c6 100644
--- a/backend/core/src/deployment_logs/mod.rs
+++ b/backend/core/src/deployment_logs/mod.rs
@@ -102,11 +102,6 @@ pub(crate) struct OwnerLogSources {
}
impl OwnerLogSources {
- /// Results of a deployment whose launch holds no protected values.
- pub(crate) fn unprotected(sources: Vec) -> Self {
- Self::protected(sources, ProtectedLogValues::default())
- }
-
/// Results whose entries and diagnostics the node redacts `protected` from.
pub(crate) fn protected(sources: Vec, protected: ProtectedLogValues) -> Self {
Self {
diff --git a/backend/core/src/development/mod.rs b/backend/core/src/development/mod.rs
index 0d8c1659..28c254c7 100644
--- a/backend/core/src/development/mod.rs
+++ b/backend/core/src/development/mod.rs
@@ -66,8 +66,8 @@ use tokio_util::sync::CancellationToken;
use crate::model::{
engine::{self, EngineIntegrationRegistry},
resolution::{
- apply_profile_defaults, resolve_local_launch, resolve_local_values_on_target,
- select_engine_artifact,
+ EngineSecrets, apply_profile_defaults, resolve_local_launch,
+ resolve_local_values_on_target, select_engine_artifact,
},
resolved::ResolvedModelLaunch,
};
@@ -373,6 +373,7 @@ impl DirectWorkloadFacade {
&configured_target,
&target_inventory,
engine_identity.as_ref(),
+ EngineSecrets::Generate(&crate::RandomSecretGenerator),
)
.map_err(DirectWorkloadLaunchError::resolution)?;
self.pin_nix_engine(&mut snapshot, &cancellation).await?;
diff --git a/backend/core/src/development/tests.rs b/backend/core/src/development/tests.rs
index c2f63c5d..404640b7 100644
--- a/backend/core/src/development/tests.rs
+++ b/backend/core/src/development/tests.rs
@@ -1508,6 +1508,7 @@ async fn facade_pins_a_tag_only_engine_image_and_prepares_and_launches_by_its_im
reference: reference.to_owned(),
image_id: IMAGE_ID.to_owned(),
}),
+ crate::model::resolution::EngineSecrets::Generate(&crate::RandomSecretGenerator),
)
.expect("pinned launch");
let resolved = ResolvedModelLaunch::from_local_snapshot(&pinned).expect("resolved model");
@@ -1613,6 +1614,7 @@ async fn facade_pins_prepares_and_launches_a_docker_delivered_engine_of_its_own_
&target,
&inventory,
None,
+ crate::model::resolution::EngineSecrets::Generate(&crate::RandomSecretGenerator),
)
.expect("resolved launch");
pinned
@@ -1721,6 +1723,7 @@ fn seed_selected_artifacts(
&target,
&inventory,
None,
+ crate::model::resolution::EngineSecrets::Generate(&crate::RandomSecretGenerator),
)
.expect("resolved launch");
let resolved = ResolvedModelLaunch::from_snapshot(&ModelDeploymentSnapshot {
diff --git a/backend/core/src/dispatch/model.rs b/backend/core/src/dispatch/model.rs
index 2d880ab7..98c831da 100644
--- a/backend/core/src/dispatch/model.rs
+++ b/backend/core/src/dispatch/model.rs
@@ -21,6 +21,7 @@ use super::{
Admission, dispatch_admitted,
registry::{KeyedRegistry, KeyedRegistryError},
};
+use crate::management_logs::ProtectedLogValues;
/// One observation plus the execution target whose adapter returned it.
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -205,7 +206,12 @@ impl RuntimeTargetMap {
});
if let Err(error) = capability {
let result = Err(error);
- record_runtime_result(&span, &result, "create_workload");
+ record_runtime_result(
+ &span,
+ &result,
+ "create_workload",
+ &ProtectedLogValues::default(),
+ );
return result;
}
let _permit = self
@@ -221,11 +227,14 @@ impl RuntimeTargetMap {
.await
}
+ /// Creates a workload under an admitted lifecycle operation; a failure's log event never
+ /// shows the `protected` values its runtime error may quote.
pub(crate) async fn create_workload_admitted(
&self,
token: &LifecycleOperationToken,
execution_target_id: &str,
workload: &WorkloadLaunch,
+ protected: &ProtectedLogValues,
) -> Result {
let adapter = self.adapter(execution_target_id)?;
workload
@@ -239,7 +248,7 @@ impl RuntimeTargetMap {
Admission::Operation(token),
commit_fencing_error,
|| {
- dispatch_adapter_action(
+ dispatch_protected_action(
execution_target_id,
info_span!(
"runtime_create_workload",
@@ -251,17 +260,21 @@ impl RuntimeTargetMap {
),
"create_workload",
adapter.create_workload(workload),
+ protected,
)
},
)
.await
}
+ /// Starts a workload under an admitted lifecycle operation; a failure's log event never
+ /// shows the `protected` values its runtime error may quote.
pub(crate) async fn start_workload_admitted(
&self,
token: &LifecycleOperationToken,
execution_target_id: &str,
workload_id: &WorkloadId,
+ protected: &ProtectedLogValues,
) -> Result {
let adapter = self.adapter(execution_target_id)?;
dispatch_admitted(
@@ -269,7 +282,7 @@ impl RuntimeTargetMap {
Admission::Operation(token),
commit_fencing_error,
|| {
- dispatch_adapter_action(
+ dispatch_protected_action(
execution_target_id,
info_span!(
"runtime_start_workload",
@@ -280,6 +293,7 @@ impl RuntimeTargetMap {
),
"start_workload",
adapter.start_workload(workload_id),
+ protected,
)
},
)
@@ -721,6 +735,25 @@ async fn dispatch_adapter_action(
span: Span,
operation: &'static str,
action: RuntimeFuture<'_, T>,
+) -> Result {
+ dispatch_protected_action(
+ execution_target_id,
+ span,
+ operation,
+ action,
+ &ProtectedLogValues::default(),
+ )
+ .await
+}
+
+/// Runs an adapter action like [`dispatch_adapter_action`], redacting `protected` from the
+/// logged failure.
+async fn dispatch_protected_action(
+ execution_target_id: &str,
+ span: Span,
+ operation: &'static str,
+ action: RuntimeFuture<'_, T>,
+ protected: &ProtectedLogValues,
) -> Result {
let result =
action
@@ -730,7 +763,7 @@ async fn dispatch_adapter_action(
execution_target_id: execution_target_id.into(),
source,
});
- record_runtime_result(&span, &result, operation);
+ record_runtime_result(&span, &result, operation, protected);
result
}
@@ -752,6 +785,7 @@ fn record_runtime_result(
span: &Span,
result: &Result,
operation: &'static str,
+ protected: &ProtectedLogValues,
) {
match result {
Ok(_) => {
@@ -768,7 +802,7 @@ fn record_runtime_result(
Err(error) => {
span.record("outcome", "failed");
span.record("error_kind", "adapter");
- error!(parent: span, event_kind = "model_runtime_failure", operation, error_kind = "adapter", error = ?error, "Runtime operation failed");
+ error!(parent: span, event_kind = "model_runtime_failure", operation, error_kind = "adapter", error = %protected.redact(&format!("{error:?}")), "Runtime operation failed");
}
}
}
diff --git a/backend/core/src/lib.rs b/backend/core/src/lib.rs
index afe67141..1476c5a3 100644
--- a/backend/core/src/lib.rs
+++ b/backend/core/src/lib.rs
@@ -36,8 +36,11 @@ pub(crate) mod node_memory;
pub(crate) mod resource_watch;
pub(crate) mod route_manager;
pub(crate) mod router;
+mod secret_generator;
pub(crate) mod storage;
+pub use secret_generator::{RandomSecretGenerator, SecretGenerator};
+
pub(crate) mod inference;
#[cfg(test)]
diff --git a/backend/core/src/model/compatibility.rs b/backend/core/src/model/compatibility.rs
index 8dd0122b..94463710 100644
--- a/backend/core/src/model/compatibility.rs
+++ b/backend/core/src/model/compatibility.rs
@@ -44,7 +44,7 @@ use crate::{
advisory::local_deployment_advisories,
engine::{EngineIntegrationRegistry, engine_artifact_kinds, unsigned_parameter_value},
resolution::{
- ModelLaunchResolutionError, apply_profile_defaults,
+ EngineSecrets, ModelLaunchResolutionError, apply_profile_defaults,
resolve_local_launch_on_selected_target,
},
selection::{EngineArtifactSelectionError, engine_artifact_kind},
@@ -261,6 +261,7 @@ pub(crate) fn evaluate_profile(
target,
selected,
None,
+ EngineSecrets::Withhold,
)
});
let snapshot = match resolved {
diff --git a/backend/core/src/model/controller/logs.rs b/backend/core/src/model/controller/logs.rs
index fa56937c..cfe8e4fb 100644
--- a/backend/core/src/model/controller/logs.rs
+++ b/backend/core/src/model/controller/logs.rs
@@ -6,8 +6,9 @@
//! serves the output of each preparation step it ran for the deployment, then
//! reads or tails the runtime logs of the workload launch rebuilt from its
//! installed view and prepared artifacts, addressed by the launch's container
-//! correlation. The configured upstream API keys and base URLs are also
-//! returned so the caller can redact them from returned log lines.
+//! correlation. The configured upstream API keys and base URLs, and the
+//! secret engine parameter values of local deployments, are also returned so
+//! the caller can redact them from returned log lines.
use std::{
future::Future,
@@ -129,7 +130,10 @@ where
.as_ref()
.map(|endpoint| endpoint.base_url.clone()),
)
- });
+ })
+ .chain(crate::model::resolved::secret_engine_parameter_values(
+ deployment,
+ ));
Ok(ProtectedLogValues::new(values))
}
}
@@ -215,6 +219,28 @@ impl NodeModelLogs {
}
}
+ /// The secret engine parameter values of `deployment_id` in the installed view, which its
+ /// log lines never show. None without a view, since nothing was realized yet, or when the
+ /// view does not assign it; any other failure to read the view fails the read.
+ fn protected_values(
+ &self,
+ deployment_id: &str,
+ ) -> Result {
+ let assigned = match self.input.model_deployments() {
+ Ok(assigned) => assigned,
+ // Without an installed view this incarnation has realized nothing, so no line yet
+ // carries a value.
+ Err(SiteRepositoryError::NoAcceptedView) => Vec::new(),
+ Err(error) => return Err(DeploymentLogError::configuration(error)),
+ };
+ Ok(ProtectedLogValues::new(
+ assigned
+ .iter()
+ .filter(|deployment| deployment.model_deployment_id == deployment_id)
+ .flat_map(crate::model::resolved::secret_engine_parameter_values),
+ ))
+ }
+
/// Whether `deployment_id` is assigned but withheld from the realizer: its engine is not
/// pinned on this node yet, or its launch does not resolve.
fn is_withheld(&self, deployment_id: &str) -> Result {
@@ -244,8 +270,9 @@ impl DeploymentLogOwner for NodeModelLogs {
deployment_id: &'a str,
query: DeploymentLogReadQuery,
) -> DeploymentLogOwnerFuture<'a, OwnerLogSources> {
- // Node model deployments are never upstream, so no launch value is protected, in step
- // lines either.
+ // Node model deployments are never upstream, so the protected values are their secret
+ // engine parameter values, in step lines too. A read that can't learn them fails rather
+ // than serve lines unprotected.
Box::pin(async move {
let selection = self.select(deployment_id, query.source_names());
// Only a selected workload source is resolved, so steps stay readable whatever its
@@ -255,6 +282,7 @@ impl DeploymentLogOwner for NodeModelLogs {
.as_deref()
.map(|names| self.resolve(deployment_id, names))
.transpose()?;
+ let protected = self.protected_values(deployment_id)?;
let mut histories = self
.preparation
.read(deployment_id, &selection.steps, query.history())
@@ -270,7 +298,7 @@ impl DeploymentLogOwner for NodeModelLogs {
}],
});
}
- Ok(OwnerLogSources::unprotected(histories))
+ Ok(OwnerLogSources::protected(histories, protected))
})
}
@@ -291,6 +319,7 @@ impl DeploymentLogOwner for NodeModelLogs {
.as_deref()
.map(|names| self.resolve(deployment_id, names))
.transpose()?;
+ let protected = self.protected_values(deployment_id)?;
let mut streams = self.preparation.follow(
deployment_id,
&selection.steps,
@@ -319,7 +348,7 @@ impl DeploymentLogOwner for NodeModelLogs {
}],
});
}
- Ok(OwnerLogSources::unprotected(streams)
+ Ok(OwnerLogSources::protected(streams, protected)
.with_late(late)
.with_changes(changes))
})
@@ -584,7 +613,8 @@ mod tests {
dispatch::model::RuntimeTargetMapBuilder,
model::controller::test_support::{
TestCurrent, TestDesired, ready_oci_preparer, test_desired_deployment,
- test_simulated_runtime_target, test_site, valid_upstream_desired_deployment,
+ test_simulated_runtime_target, test_site, test_strata_desired_deployment_with_api_key,
+ valid_upstream_desired_deployment,
},
};
@@ -642,6 +672,64 @@ mod tests {
assert!(!source_history.candidate_budget_truncated);
}
+ #[tokio::test]
+ async fn node_model_logs_protect_the_secret_engine_values_of_the_deployment() {
+ let directory = tempfile::tempdir().expect("preparation log directory");
+ let calls = Arc::new(Mutex::new(Vec::new()));
+ let desired = TestDesired::new(calls.clone());
+ desired
+ .state
+ .lock()
+ .expect("desired")
+ .model_deployments
+ .push(test_strata_desired_deployment_with_api_key(
+ "fixture-model",
+ "node-engine-secret-key",
+ ));
+ let mut runtimes = RuntimeTargetMapBuilder::new();
+ runtimes
+ .insert(
+ "target-one",
+ LogRuntime {
+ calls: Arc::new(Mutex::new(Vec::new())),
+ },
+ )
+ .expect("log runtime");
+ let logs = NodeModelLogs::new(
+ desired,
+ runtimes.build().expect("Runtime map"),
+ ArtifactManager::new("/test-cache".into(), Some(ready_oci_preparer()))
+ .expect("artifact paths"),
+ Arc::new(AtomicBool::new(true)),
+ PreparationLogs::on_system_clock(directory.path()),
+ crate::deployment_logs::WorkloadLifecycle::new(),
+ );
+ let history = DeploymentLogReadQuery::new(
+ Vec::new(),
+ RuntimeLogQuery::new(NonZeroU16::new(2).expect("limit"), None).expect("query"),
+ );
+
+ let read = logs
+ .read_logs("deployment", history)
+ .await
+ .expect("history");
+ let other = logs
+ .read_logs(
+ "other-deployment",
+ DeploymentLogReadQuery::new(
+ Vec::new(),
+ RuntimeLogQuery::new(NonZeroU16::new(2).expect("limit"), None).expect("query"),
+ ),
+ )
+ .await;
+
+ assert_eq!(
+ read.protected.values(),
+ [b"node-engine-secret-key".to_vec()]
+ );
+ assert!(matches!(other, Err(DeploymentLogError::NotFound)));
+ }
+
#[tokio::test]
async fn node_model_logs_serve_installed_assignments_once_the_realizer_started() {
let directory = tempfile::tempdir().expect("preparation log directory");
@@ -1104,7 +1192,10 @@ mod tests {
.lock()
.expect("desired")
.model_deployments
- .push(upstream);
+ .extend([
+ upstream,
+ test_strata_desired_deployment_with_api_key("fixture-model", "local-engine-key"),
+ ]);
let controller = InferenceController::new(
desired.clone(),
TestCurrent::new(calls),
@@ -1128,6 +1219,10 @@ mod tests {
.protected_log_values(id)
.await
.expect("fresh protected values");
+ let local = controller
+ .protected_log_values(ModelDeploymentId("deployment".to_owned()))
+ .await
+ .expect("local protected values");
// Protected values are kept longest first.
assert_eq!(
@@ -1150,6 +1245,7 @@ mod tests {
.iter()
.all(|value| value != b"private-upstream")
);
+ assert_eq!(local.values(), [b"local-engine-key".to_vec()]);
}
fn entry_texts(entries: &[RuntimeLogEntry]) -> Vec {
diff --git a/backend/core/src/model/controller/reconciliation/mod.rs b/backend/core/src/model/controller/reconciliation/mod.rs
index e287d18b..686a17d4 100644
--- a/backend/core/src/model/controller/reconciliation/mod.rs
+++ b/backend/core/src/model/controller/reconciliation/mod.rs
@@ -1490,6 +1490,9 @@ where
artifacts_replaced,
"model reconciliation selected runtime workload action",
);
+ // Runtime errors may quote the launch, so neither failure text nor logs show them
+ // unredacted.
+ let protected = protected_startup_values(desired, &launch.spec.environment);
match self
.transition_workload(
desired,
@@ -1517,12 +1520,8 @@ where
}
Ok(None) => {}
Err(error) if is_definitive_realization_failure(&error) => {
- self.record_realization_failure(
- desired,
- &error,
- &protected_startup_values(&launch.spec.environment),
- )
- .await?;
+ self.record_realization_failure(desired, &error, &protected)
+ .await?;
self.serving_targets_publisher
.withdraw_deployment(&ModelDeploymentId(
desired.model_deployment_id.clone(),
@@ -1531,7 +1530,7 @@ where
event_kind = "model_realization_failed",
deployment_id = %desired.model_deployment_id,
error_kind = "definitive",
- reason = %diagnostic_chain(&error),
+ reason = %protected.redact(&diagnostic_chain(&error)),
"model workload realization failed definitively; recorded Failed and stopped retrying",
);
continue;
@@ -1541,7 +1540,7 @@ where
event_kind = "model_realization_retry",
deployment_id = %desired.model_deployment_id,
error_kind = "transient",
- reason = %diagnostic_chain(&error),
+ reason = %protected.redact(&diagnostic_chain(&error)),
"model workload realization failed transiently; keeping in-progress and retrying",
);
pending = true;
@@ -2203,6 +2202,7 @@ where
activation_state: Option<&dotcore_proto::dotcore::model::v1::ModelDeploymentState>,
) -> Result, ModelLifecycleError> {
let generation = launch.correlation.reconcile_generation;
+ let protected = protected_startup_values(desired, &launch.spec.environment);
let operation = self.capture_operation(
desired,
LifecycleOperationAttempt::Runtime {
@@ -2258,6 +2258,7 @@ where
})?
.execution_target_id,
launch,
+ &protected,
)
.await?;
self.announce_started_workload(desired);
@@ -2303,6 +2304,7 @@ where
})?
.execution_target_id,
launch,
+ &protected,
)
.await?;
self.announce_started_workload(desired);
@@ -2353,6 +2355,7 @@ where
&operation,
&execution_target_id,
&workload.workload_id,
+ &protected,
)
.await?;
self.announce_started_workload(desired);
@@ -2388,6 +2391,7 @@ where
&operation,
&execution_target_id,
&workload.workload_id,
+ &protected,
)
.await?;
self.announce_started_workload(desired);
@@ -2764,7 +2768,7 @@ where
)
.expect("startup log limit is within runtime bounds");
let query = dotcore_runtime::RuntimeWorkloadLogQuery::new(source, history);
- let protected = protected_startup_values(&launch.spec.environment);
+ let protected = protected_startup_values(desired, &launch.spec.environment);
match real_time::bound(
Duration::from_secs(2),
self.runtimes.read_workload_logs(
@@ -3409,7 +3413,10 @@ fn startup_output(
}
}
+/// Values startup output and failure text never show: the deployment's secret engine parameter
+/// values and the literal values of sensitive environment variables.
fn protected_startup_values(
+ desired: &dotcore_proto::dotcore::model::v1::ModelDeploymentDeclaration,
environment: &BTreeMap,
) -> crate::management_logs::ProtectedLogValues {
// Token values are runtime-resolved paths, hosts, and ports, never owner secrets, so each
@@ -3423,7 +3430,10 @@ fn protected_startup_values(
Some(literal.clone())
}
_ => None,
- });
+ })
+ .chain(crate::model::resolved::secret_engine_parameter_values(
+ desired,
+ ));
crate::management_logs::ProtectedLogValues::new(values)
}
diff --git a/backend/core/src/model/controller/reconciliation/tests/mod.rs b/backend/core/src/model/controller/reconciliation/tests/mod.rs
index ba682c0a..62d1ebf1 100644
--- a/backend/core/src/model/controller/reconciliation/tests/mod.rs
+++ b/backend/core/src/model/controller/reconciliation/tests/mod.rs
@@ -32,8 +32,8 @@ use dotcore_proto::dotcore::{
site::v1::{ModelRuntimeBinding, execution_target, model_runtime_binding},
};
use dotcore_runtime::{
- LoopbackEndpoint, ObservedWorkload, RuntimeBackend, RuntimeBackendId, RuntimeFuture,
- WorkloadCorrelation, WorkloadId, WorkloadLaunch, WorkloadObservation,
+ LoopbackEndpoint, ObservedWorkload, RuntimeBackend, RuntimeBackendError, RuntimeBackendId,
+ RuntimeFuture, WorkloadCorrelation, WorkloadId, WorkloadLaunch, WorkloadObservation,
};
use dotcore_runtime_backends::simulated::{
InMemoryRuntimeBackend, SimulatedRuntimeConfig, SimulatedRuntimeTestSupport,
@@ -103,7 +103,11 @@ fn startup_log_protection_selects_sensitive_environment_values() {
),
]);
assert_eq!(
- super::protected_startup_values(&environment).values(),
+ super::protected_startup_values(
+ &test_desired_deployment("fixture-model", ModelDeploymentDesiredState::Present),
+ &environment
+ )
+ .values(),
&[b"sk-test-private".to_vec()]
);
}
@@ -122,7 +126,11 @@ fn startup_log_protection_selects_literal_parts_of_symbolic_sensitive_values() {
),
]);
assert_eq!(
- super::protected_startup_values(&environment).values(),
+ super::protected_startup_values(
+ &test_desired_deployment("fixture-model", ModelDeploymentDesiredState::Present),
+ &environment
+ )
+ .values(),
&[b"sk-secret@".to_vec()]
);
}
@@ -559,7 +567,7 @@ async fn protected_launch_values_never_leave_the_node_in_a_realization_failure()
WorkloadValue::literal("fixture-model".to_owned()),
),
]);
- let protected: ProtectedLogValues = super::protected_startup_values(&environment);
+ let protected: ProtectedLogValues = super::protected_startup_values(&deployment, &environment);
let error = ModelLifecycleError::configuration(format!(
"engine rejected HF_TOKEN={SECRET} for fixture-model"
));
@@ -2976,6 +2984,320 @@ async fn loading_stopped_workload_records_startup_failure_without_restart() {
assert!(resumed_state.failure.is_none());
}
+#[tokio::test]
+async fn a_secret_engine_value_reaches_no_log_failure_text_telemetry_or_command() {
+ use prost::Message as _;
+
+ const SENTINEL: &str = "sentinel-api-key-6f1c2b9d";
+ let contains_sentinel = |bytes: &[u8]| {
+ bytes
+ .windows(SENTINEL.len())
+ .any(|window| window == SENTINEL.as_bytes())
+ };
+ let calls = Arc::new(Mutex::new(Vec::new()));
+ let desired = TestDesired::new(calls.clone());
+ let deployment =
+ crate::model::controller::test_support::test_strata_desired_deployment_with_api_key(
+ "model", SENTINEL,
+ );
+ let identity = crate::model::controller::test_support::expected_identity(
+ &test_site(),
+ &deployment,
+ dotcore_runtime_backends::simulated::BACKEND_ID,
+ );
+ desired
+ .state
+ .lock()
+ .expect("desired state")
+ .model_deployments
+ .push(deployment.clone());
+ let current = TestCurrent::new(calls.clone());
+ let mut starting = current_deployment("deployment");
+ starting.state = Some(crate::model::controller::state::realization_prepared_state(
+ None,
+ ));
+ current.upsert(starting, None).expect("current state");
+ // The engine echoes its key on its way out, as a misbehaving engine could.
+ let startup_logs = Arc::new(Mutex::new(StartupLogBehavior::History {
+ expected_workload_id: identity.workload_id.clone(),
+ history: dotcore_runtime::RuntimeLogHistory {
+ entries: vec![dotcore_runtime::RuntimeLogEntry {
+ source: dotcore_runtime::RuntimeLogSource {
+ name: "model".to_owned(),
+ label: "Model".to_owned(),
+ },
+ timestamp: std::time::SystemTime::UNIX_EPOCH,
+ stream: dotcore_runtime::RuntimeLogStreamKind::Stderr,
+ content: format!("refusing api key {SENTINEL}").into_bytes(),
+ truncated: false,
+ }],
+ has_older_output: false,
+ candidate_budget_truncated: false,
+ },
+ }));
+ let mut builder = RuntimeTargetMapBuilder::new();
+ builder
+ .insert(
+ "target-one",
+ PausedRuntime {
+ calls: calls.clone(),
+ observations: Arc::new(Mutex::new(vec![vec![WorkloadObservation::Stopped {
+ workload: identity.clone(),
+ exit_status: Some(17),
+ }]])),
+ start_observation: Some(WorkloadObservation::Running(identity.clone())),
+ startup_logs,
+ backend_id: RuntimeBackendId(
+ dotcore_runtime_backends::simulated::BACKEND_ID.to_owned(),
+ ),
+ },
+ )
+ .expect("Runtime adapter");
+ let artifacts = ArtifactManager::from_parts(
+ dotcore_artifact::ModelArtifactCache::new("/test-cache".into()).expect("test cache"),
+ Some(crate::model::controller::test_support::ready_oci_preparer()),
+ );
+ let controller = sweep_controller(
+ &desired,
+ ¤t,
+ builder.build().expect("Runtime map"),
+ artifacts.clone(),
+ );
+
+ let (reconciliation, records, output) =
+ capture_records_async(|| controller.inner.reconcile()).await;
+ reconciliation.expect("startup reconciliation");
+
+ // Failure text: the recorded failure shows the output with the key redacted.
+ let state = current.snapshot().expect("current state").model_deployments[0]
+ .state
+ .clone()
+ .expect("deployment state");
+ assert_eq!(state.status, ModelDeploymentStatus::Failed as i32);
+ assert_eq!(
+ state.failure.as_ref().expect("startup failure").diagnostics,
+ [
+ "Stage: engine startup",
+ "Exit status: 17",
+ "Output tail: refusing api key [REDACTED]",
+ ]
+ );
+ assert!(
+ !format!("{:?}", current.snapshot().expect("current state")).contains(SENTINEL),
+ "current state"
+ );
+ // Log lines: every event and span of the reconciliation, at every level.
+ assert!(!records.is_empty());
+ assert!(!output.contains(SENTINEL), "{output}");
+ assert!(!format!("{records:?}").contains(SENTINEL), "{records:#?}");
+ // Telemetry record inputs and the launch command, plan and materialized workload alike.
+ let resolved = resolved_launch(&deployment).expect("resolved launch");
+ assert!(!contains_sentinel(
+ &resolved.telemetry_inputs().encode_to_vec()
+ ));
+ assert!(!contains_sentinel(&resolved.plan.encode_to_vec()));
+ let launch = workload_launch(
+ &deployment,
+ selected_target(&test_site(), &deployment).expect("execution target"),
+ &artifacts
+ .expected_model_paths(&resolved.artifacts)
+ .expect("artifact paths"),
+ )
+ .expect("workload launch");
+ for value in launch
+ .spec
+ .arguments
+ .iter()
+ .chain(launch.spec.environment.values())
+ {
+ assert!(!format!("{value:?}").contains(SENTINEL));
+ }
+
+ // Runtime errors quoting the key, transient then definitive, are logged and recorded
+ // redacted.
+ let quoting_errors = Arc::new(Mutex::new(vec![
+ RuntimeBackendError::ImagePlatformMismatch {
+ image: format!("strata --api-key {SENTINEL}"),
+ required: dotcore_domain::ExecutionPlatform::linux_amd64(),
+ observed: dotcore_domain::ExecutionPlatform::linux_arm64(),
+ },
+ RuntimeBackendError::InvalidLaunch(anyhow!("engine refused api key {SENTINEL}")),
+ ]));
+ let current = TestCurrent::new(calls.clone());
+ let mut prepared = current_deployment("deployment");
+ prepared.state = Some(crate::model::controller::state::realization_prepared_state(
+ None,
+ ));
+ current.upsert(prepared, None).expect("current state");
+ let mut builder = RuntimeTargetMapBuilder::new();
+ builder
+ .insert(
+ "target-one",
+ QuotingFailureRuntime {
+ errors: quoting_errors.clone(),
+ },
+ )
+ .expect("Runtime adapter");
+ let controller = sweep_controller(
+ &desired,
+ ¤t,
+ builder.build().expect("Runtime map"),
+ artifacts,
+ );
+
+ let (transient, transient_records, transient_output) =
+ capture_records_async(|| controller.inner.reconcile()).await;
+ transient.expect("transient failure reconciliation");
+ let (definitive, definitive_records, definitive_output) =
+ capture_records_async(|| controller.inner.reconcile()).await;
+ definitive.expect("definitive failure reconciliation");
+
+ assert!(quoting_errors.lock().expect("errors").is_empty());
+ for (event_kind, records, output) in [
+ (
+ "\"model_realization_retry\"",
+ &transient_records,
+ &transient_output,
+ ),
+ (
+ "\"model_realization_failed\"",
+ &definitive_records,
+ &definitive_output,
+ ),
+ ] {
+ let reason = records
+ .iter()
+ .find_map(|record| match record {
+ Record::Event { fields, .. }
+ if fields.get("event_kind").map(String::as_str) == Some(event_kind) =>
+ {
+ fields.get("reason").cloned()
+ }
+ _ => None,
+ })
+ .unwrap_or_else(|| panic!("{event_kind} event: {records:#?}"));
+ assert!(reason.contains("[REDACTED]"), "{reason}");
+ assert!(!output.contains(SENTINEL), "{output}");
+ assert!(!format!("{records:?}").contains(SENTINEL), "{records:#?}");
+ }
+ let failure = current.snapshot().expect("current state").model_deployments[0]
+ .state
+ .clone()
+ .expect("deployment state")
+ .failure
+ .expect("definitive failure");
+ assert!(
+ failure.message.contains("[REDACTED]"),
+ "{}",
+ failure.message
+ );
+ assert!(!format!("{failure:?}").contains(SENTINEL), "{failure:?}");
+}
+
+/// The node realizer of the sentinel sweep over `desired` and `current`, on `runtimes`.
+fn sweep_controller(
+ desired: &TestDesired,
+ current: &TestCurrent,
+ runtimes: crate::dispatch::model::RuntimeTargetMap,
+ artifacts: ArtifactManager,
+) -> super::super::test_support::TestRealizer {
+ let (serving_targets_publisher, serving_targets_source) =
+ crate::model::controller::serving_targets::ServingTargetsPublisher::new();
+ let inner = InferenceControllerInner {
+ workload_lifecycle: crate::deployment_logs::WorkloadLifecycle::new(),
+ preparation_logs: crate::deployment_logs::PreparationLogs::on_system_clock(
+ std::path::Path::new("/test-preparation-logs"),
+ ),
+ results: desired.clone(),
+ target_capabilities: crate::model::controller::test_support::test_target_capability_source(
+ ),
+ desired: desired.clone(),
+ state: current.clone(),
+ runtimes,
+ engine_integrations: crate::model::controller::test_support::test_engine_integrations(),
+ operation: Arc::new(AsyncMutex::new(())),
+ reconciliation: AsyncMutex::new(()),
+ preparations: PreparationCoordinator::from_deployments(&[])
+ .expect("preparation coordinator"),
+ observer_started: Mutex::new(false),
+ wakeup: Arc::new(Notify::new()),
+ artifacts,
+ serving_targets_publisher,
+ serving_targets_source,
+ accelerator_attribution: None,
+ shutdown: tokio_util::sync::CancellationToken::new(),
+ timing: Timing::test().model,
+ clock: Arc::new(SystemClock),
+ };
+ manager_test_controller(
+ desired.clone(),
+ super::super::ModelRealizer {
+ inner: Arc::new(inner),
+ },
+ )
+}
+
+/// A runtime with no workloads whose creations fail with the queued errors, last first.
+struct QuotingFailureRuntime {
+ errors: Arc>>,
+}
+
+impl RuntimeBackend for QuotingFailureRuntime {
+ fn backend_id(&self) -> RuntimeBackendId {
+ RuntimeBackendId(dotcore_runtime_backends::simulated::BACKEND_ID.to_owned())
+ }
+
+ fn materialization_requirements(&self) -> dotcore_runtime::WorkloadMaterializationRequirements {
+ dotcore_runtime::WorkloadMaterializationRequirements::virtualized()
+ }
+
+ fn discover_workloads<'a>(&'a self) -> RuntimeFuture<'a, Vec> {
+ Box::pin(ready(Ok(Vec::new())))
+ }
+
+ fn create_workload<'a>(
+ &'a self,
+ _workload: &'a WorkloadLaunch,
+ ) -> RuntimeFuture<'a, WorkloadObservation> {
+ let error = self
+ .errors
+ .lock()
+ .expect("errors")
+ .pop()
+ .unwrap_or_else(test_runtime_error);
+ Box::pin(ready(Err(error)))
+ }
+
+ fn start_workload<'a>(
+ &'a self,
+ _workload_id: &'a WorkloadId,
+ ) -> RuntimeFuture<'a, WorkloadObservation> {
+ Box::pin(ready(Err(test_runtime_error())))
+ }
+
+ fn inspect_workload<'a>(
+ &'a self,
+ _workload_id: &'a WorkloadId,
+ ) -> RuntimeFuture<'a, WorkloadObservation> {
+ Box::pin(ready(Err(test_runtime_error())))
+ }
+
+ fn stop_workload<'a>(&'a self, _workload_id: &'a WorkloadId) -> RuntimeFuture<'a, ()> {
+ Box::pin(ready(Ok(())))
+ }
+
+ fn remove_workload<'a>(&'a self, _workload_id: &'a WorkloadId) -> RuntimeFuture<'a, ()> {
+ Box::pin(ready(Ok(())))
+ }
+
+ fn remove_storage<'a>(
+ &'a self,
+ _: &'a dotcore_runtime::WorkloadStorageKey,
+ ) -> RuntimeFuture<'a, ()> {
+ Box::pin(ready(Ok(())))
+ }
+}
+
#[tokio::test(start_paused = true)]
async fn simulated_download_precedes_runtime_creation_clears_progress_and_skips_oci() {
let calls = Arc::new(Mutex::new(Vec::new()));
diff --git a/backend/core/src/model/controller/test_support.rs b/backend/core/src/model/controller/test_support.rs
index c3a8dab5..12280cb9 100644
--- a/backend/core/src/model/controller/test_support.rs
+++ b/backend/core/src/model/controller/test_support.rs
@@ -1061,6 +1061,20 @@ pub(super) fn test_strata_desired_deployment(model_id: &str) -> ModelDeploymentD
}
}
+/// Returns a present deployment of the in-code Strata fixture whose engine declares a secret API
+/// key, retained as `api_key`.
+pub(super) fn test_strata_desired_deployment_with_api_key(
+ model_id: &str,
+ api_key: &str,
+) -> ModelDeploymentDeclaration {
+ ModelDeploymentDeclaration {
+ snapshot: Some(
+ crate::test_support::strata::local_model_snapshot_with_api_key(model_id, api_key),
+ ),
+ ..test_desired_deployment(model_id, ModelDeploymentDesiredState::Present)
+ }
+}
+
pub(super) fn valid_upstream_desired_deployment() -> ModelDeploymentDeclaration {
ModelDeploymentDeclaration {
model_deployment_id: "upstream-deployment".to_owned(),
diff --git a/backend/core/src/model/engine/ninfer/declaration.rs b/backend/core/src/model/engine/ninfer/declaration.rs
index bb6e3f63..c2030baa 100644
--- a/backend/core/src/model/engine/ninfer/declaration.rs
+++ b/backend/core/src/model/engine/ninfer/declaration.rs
@@ -1153,6 +1153,7 @@ profiles:
&target,
&inventory,
None,
+ crate::model::resolution::EngineSecrets::Generate(&crate::RandomSecretGenerator),
)
}
diff --git a/backend/core/src/model/engine/strata/declaration.rs b/backend/core/src/model/engine/strata/declaration.rs
index 497de4c1..4d50c177 100644
--- a/backend/core/src/model/engine/strata/declaration.rs
+++ b/backend/core/src/model/engine/strata/declaration.rs
@@ -1,25 +1,27 @@
//! Strata-specific validation of catalog engine releases and model engine configurations.
//!
-//! A release must declare well-formed unsigned-integer parameters, at most the parallel requests
-//! Strata's start wrapper accepts, Linux artifacts only, and the deployment's data folder the
-//! wrapper writes to. A configuration must bind model artifacts shaped as its fixed settings say,
-//! with the draft layer they name, run text-only on exactly one NVIDIA accelerator, declare no
-//! options, and reference every other release parameter. Launch values must give Strata a total
-//! context that is exactly what its parallel requests hold.
+//! A release must declare well-formed unsigned-integer or secret parameters, at most the parallel
+//! requests Strata's start wrapper accepts, Linux artifacts only, and the deployment's data folder
+//! the wrapper writes to. A configuration must bind model artifacts shaped as its fixed settings
+//! say, with the draft layer they name, run text-only on exactly one NVIDIA accelerator, declare no
+//! options, and reference every other unsigned-integer release parameter. Launch values must give
+//! Strata a total context that is exactly what its parallel requests hold.
use std::collections::BTreeSet;
-use dotcore_catalog::shared_parameters::{
- self, CONTEXT_PER_REQUEST_PARAMETER_ID, PARALLEL_REQUESTS_PARAMETER_ID,
- TOTAL_CONTEXT_PARAMETER_ID, is_shared_parameter,
+use dotcore_catalog::{
+ shared_parameters::{
+ self, CONTEXT_PER_REQUEST_PARAMETER_ID, PARALLEL_REQUESTS_PARAMETER_ID,
+ TOTAL_CONTEXT_PARAMETER_ID, is_shared_parameter,
+ },
+ typed_parameters::is_secret,
};
use dotcore_domain::Accelerator;
use dotcore_proto::dotcore::{
catalog::v1::{
EngineParameterSchema, EngineRelease, HardwareClass, HuggingFaceArtifactSource, Model,
ModelEngineConfiguration, ModelInputModality, ModelParallelShape, ParameterValue,
- ParameterValues, StorageScope,
- hugging_face_artifact_source::Content as HuggingFaceContent,
+ ParameterValues, StorageScope, hugging_face_artifact_source::Content as HuggingFaceContent,
parameter_definition::ParameterType, parameter_value::Value as EngineParameterValueKind,
},
engine::{
@@ -107,8 +109,8 @@ pub(crate) fn validate_parameter_values(
Ok(())
}
-/// Accepts a release of non-empty unsigned-integer parameters that declares at most the parallel
-/// requests the start wrapper accepts, ships only Linux artifacts, and declares a
+/// Accepts a release of non-empty unsigned-integer or secret parameters that declares at most the
+/// parallel requests the start wrapper accepts, ships only Linux artifacts, and declares a
/// deployment-scoped storage at the wrapper's data folder. The catalog checks the shared
/// parameters' fixed shape.
pub(crate) fn validate_engine_release(engine: &EngineRelease) -> Vec {
@@ -122,16 +124,18 @@ pub(crate) fn validate_engine_release(engine: &EngineRelease) -> Vec= range.minimum && !range.unit.is_empty()
- )
+ && match definition.parameter_type.as_ref() {
+ Some(ParameterType::UnsignedInteger(range)) => {
+ range.maximum >= range.minimum && !range.unit.is_empty()
+ }
+ Some(ParameterType::Secret(_)) => true,
+ _ => false,
+ }
})
});
if !valid {
errors.push(anyhow::anyhow!(
- "Strata engine release requires non-empty unsigned-integer parameter definitions"
+ "Strata engine release requires non-empty unsigned-integer or secret parameter definitions"
));
}
if unsigned_parameter_maximum(engine, PARALLEL_REQUESTS_PARAMETER_ID)
@@ -252,6 +256,8 @@ fn validate_parameters(
fixed: &StrataFixedModelSettings,
errors: &mut Vec,
) {
+ // Secret parameters never enter the launch plan, so the reference rule below leaves them
+ // out.
let declared = engine
.parameter_schema
.as_ref()
@@ -259,6 +265,7 @@ fn validate_parameters(
schema
.parameters
.iter()
+ .filter(|definition| !is_secret(definition))
.map(|definition| definition.parameter_id.as_str())
.collect::>()
})
@@ -271,8 +278,9 @@ fn validate_parameters(
));
}
- // Every release parameter must reach the launch plan, so none can be set without effect. The
- // total context reaches it through the parallel requests and the context per request.
+ // Every other release parameter must reach the launch plan, so none can be set without
+ // effect. The total context reaches it through the parallel requests and the context per
+ // request.
let launched = BTreeSet::from([
expert_vram,
TOTAL_CONTEXT_PARAMETER_ID,
@@ -470,8 +478,10 @@ mod tests {
},
test_support::{
strata::{
- DRAFT_LAYER_ARTIFACT_ID, EXPERT_VRAM_MIB_PARAMETER_ID, configuration, model,
- ready_pack_configuration, release, split_gguf_configuration,
+ API_KEY_PARAMETER_ID, CATALOG_ENGINE, CATALOG_MODEL, DRAFT_LAYER_ARTIFACT_ID,
+ EXPERT_VRAM_MIB_PARAMETER_ID, catalog_engine_with_api_key, configuration,
+ load_catalog, model, ready_pack_configuration, release, release_with_api_key,
+ split_gguf_configuration,
},
unsigned_parameter_values,
},
@@ -516,7 +526,9 @@ mod tests {
Some(ParameterType::Boolean(BooleanParameterSchema::default()));
assert_eq!(
messages(validate_engine_release(&boolean)),
- ["Strata engine release requires non-empty unsigned-integer parameter definitions"]
+ [
+ "Strata engine release requires non-empty unsigned-integer or secret parameter definitions"
+ ]
);
}
@@ -600,6 +612,41 @@ mod tests {
}
}
+ #[test]
+ fn accepts_a_secret_parameter_beyond_the_launched_ones() {
+ let release = release_with_api_key();
+
+ assert_eq!(
+ messages(validate_engine_release(&release)),
+ Vec::::new()
+ );
+ assert_eq!(
+ messages(validate_model_configuration(
+ &model(),
+ &configuration(),
+ &release
+ )),
+ Vec::::new()
+ );
+ load_catalog(&catalog_engine_with_api_key(), CATALOG_MODEL)
+ .expect("a profile leaving the secret to creation");
+
+ let mut configuration = configuration();
+ fixed_settings(&mut configuration).expert_vram_mib_parameter_id =
+ API_KEY_PARAMETER_ID.to_owned();
+ assert_eq!(
+ messages(validate_model_configuration(
+ &model(),
+ &configuration,
+ &release
+ )),
+ [
+ "configuration \"strata-nvidia\" has an invalid Strata engine parameter reference",
+ "configuration \"strata-nvidia\" must reference every Strata engine release parameter",
+ ]
+ );
+ }
+
#[test]
fn rejects_release_parameters_the_configuration_does_not_reference() {
let mut release = release();
@@ -837,7 +884,7 @@ mod tests {
);
assert_ne!(model, CATALOG_MODEL);
- let error = load_catalog(&model).expect_err("no expert cap");
+ let error = load_catalog(CATALOG_ENGINE, &model).expect_err("no expert cap");
assert!(
format!("{error:#}").contains(
@@ -1007,111 +1054,9 @@ mod tests {
);
}
- const CATALOG_ENGINE: &str = r#"
-engineReleaseId: "fixture-strata"
-engineKind: "ENGINE_KIND_STRATA"
-version: "1"
-parameterSchema:
- schemaId: "fixture-strata-v1"
- parameters:
- - { parameterId: "total-context", displayName: "Total context", visibility: "PARAMETER_VISIBILITY_BASIC", description: "total-context", unsignedInteger: { minimum: "1024", maximum: "2097152", unit: "tokens", defaultValue: "32768" } }
- - { parameterId: "context-per-request", displayName: "Context per request", visibility: "PARAMETER_VISIBILITY_BASIC", description: "context-per-request", unsignedInteger: { minimum: "1024", maximum: "262144", unit: "tokens" } }
- - { parameterId: "parallel-requests", displayName: "Parallel requests", visibility: "PARAMETER_VISIBILITY_ADVANCED", description: "parallel-requests", unsignedInteger: { minimum: "1", maximum: "8", unit: "requests", defaultValue: "1" } }
- - { parameterId: "expert-vram-mib", displayName: "Expert accelerator memory", visibility: "PARAMETER_VISIBILITY_ADVANCED", description: "expert-vram-mib", unsignedInteger: { minimum: "1", maximum: "1048576", unit: "MiB" } }
-storageBindings:
- - { bindingName: "data", mountPath: "/data", kind: "STORAGE_KIND_CACHE", scope: "STORAGE_SCOPE_DEPLOYMENT" }
-artifacts:
- - artifactId: "linux-amd64-cuda"
- oci:
- repository: { registry: { host: "registry.dotcore.test" }, name: "strata" }
- digest: { algorithm: "sha256", encoded: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" }
- selectors:
- - platform: { operatingSystem: "linux", architecture: "amd64" }
- accelerators: ["nvidia"]
- minimumNvidiaDriverMajorVersion: 580
-"#;
-
- const CATALOG_MODEL: &str = r#"
-modelId: "fixture-strata-model"
-displayName: "Fixture Strata model"
-version: "1"
-maximumContextTokens: 262144
-artifacts:
- - artifactId: "fixture-strata-artifact"
- weightQuantization: "Q4_K_XL"
- source:
- repository: "dotcore/fixture-strata"
- revision: "0123456789abcdef0123456789abcdef01234567"
- file:
- path: "fixture-Q4_K_XL.gguf"
- provenanceDigest: "74d2c57145e6ff11d1d2faa79594477f9bc903a611af1fb20218189fbbb77d82"
-configurations:
- - configurationId: "strata-nvidia"
- engineArtifacts: [{ engineReleaseId: "fixture-strata", engineArtifactId: "linux-amd64-cuda" }]
- artifactBindings: [{ bindingId: "fixture-strata-artifact", modelArtifactId: "fixture-strata-artifact" }]
- sizing:
- parameters: { baseBytes: 1000, bytesPerKvToken: 10, expertMib: 20000, expertVramMib: 8192 }
- derivedParameters:
- expert-vram-mib: "${expertVramMib}"
- estimates:
- acceleratorMemory:
- unit: bytes
- equation: "${baseBytes} + ${bytesPerKvToken} * ${total-context} + ${expert-vram-mib} * MiB"
- hostMemory:
- unit: bytes
- equation: "${expertMib} * MiB"
- hardwareSupport:
- - hardwareClassId: "nvidia"
- hardwareRequirements:
- minimumMemoryBytesPerDevice: "32000000000"
- minimumSystemMemoryBytes: "64000000000"
- bindingSizing:
- - bindingId: "fixture-strata-artifact"
- engineConfiguration:
- engineKind: "ENGINE_KIND_STRATA"
- strata:
- fixedSettings:
- expertVramMibParameterId: "expert-vram-mib"
- architecture: "qwen35moe"
- mtpDraftTokens: 2
- singleGguf: {}
- executionCapabilities: { supportedParallelShapes: ["MODEL_PARALLEL_SHAPE_ONE"] }
- requestKinds: ['INFERENCE_REQUEST_KIND_CHAT']
- inputCapabilities:
- supportedModalities: ["MODEL_INPUT_MODALITY_TEXT"]
-profiles:
- - profileId: "strata-q4"
- displayName: "Q4 Strata"
- description: "Runs the fixture through Strata."
- configurationId: "strata-nvidia"
- defaultModelArtifactBindingId: "fixture-strata-artifact"
- engineParameterDefaults: {}
- "#;
-
/// Loads the profile-shaped engine and model with the shared hardware classes.
fn profile_shaped_catalog() -> dotcore_catalog::Catalog {
- load_catalog(CATALOG_MODEL).expect("Strata catalog")
- }
-
- /// Loads the engine and the given model with the shared hardware classes.
- fn load_catalog(model: &str) -> anyhow::Result {
- let root = tempfile::tempdir().expect("Catalog root");
- for (path, content) in [
- ("engines/strata.yaml", CATALOG_ENGINE),
- ("models/strata-model.yaml", model),
- ] {
- let path = root.path().join(path);
- std::fs::create_dir_all(path.parent().expect("directory")).expect("directory");
- std::fs::write(path, content).expect("declaration");
- }
- dotcore_catalog::test_support::write_hardware_classes(root.path());
-
- dotcore_catalog::Catalog::load_with_policy(
- root.path(),
- dotcore_catalog::OciArtifactPolicy::Development,
- crate::model::engine::declaration_validator(),
- )
- .map_err(anyhow::Error::new)
+ load_catalog(CATALOG_ENGINE, CATALOG_MODEL).expect("Strata catalog")
}
/// Resolves the profile on an NVIDIA target with the supplied engine parameter values after
@@ -1142,6 +1087,7 @@ profiles:
&target,
&inventory,
None,
+ crate::model::resolution::EngineSecrets::Generate(&crate::RandomSecretGenerator),
)
}
diff --git a/backend/core/src/model/engine/vllm/declaration.rs b/backend/core/src/model/engine/vllm/declaration.rs
index 1a7739dc..fc6838c8 100644
--- a/backend/core/src/model/engine/vllm/declaration.rs
+++ b/backend/core/src/model/engine/vllm/declaration.rs
@@ -875,6 +875,7 @@ mod tests {
&target,
&inventory,
None,
+ crate::model::resolution::EngineSecrets::Generate(&crate::RandomSecretGenerator),
)
.expect("vLLM launch on a GPU of a mixed machine");
let total = launch
@@ -1211,6 +1212,7 @@ profiles:
&target,
&inventory,
None,
+ crate::model::resolution::EngineSecrets::Generate(&crate::RandomSecretGenerator),
)
}
diff --git a/backend/core/src/model/repository.rs b/backend/core/src/model/repository.rs
index ff8ce628..19395eba 100644
--- a/backend/core/src/model/repository.rs
+++ b/backend/core/src/model/repository.rs
@@ -41,7 +41,11 @@ use super::{
recommendation::{RecommendationFit, UseCaseRecommendations, recommend_models},
sizing_scenario::SizingScenario,
};
-use crate::route_manager::{RouteDeclarationRepository, RouteRepositoryError};
+use crate::{
+ RandomSecretGenerator, SecretGenerator,
+ model::resolution::EngineSecrets,
+ route_manager::{RouteDeclarationRepository, RouteRepositoryError},
+};
#[derive(Clone)]
pub struct ModelDeclarationRepository {
@@ -50,6 +54,7 @@ pub struct ModelDeclarationRepository {
routes: RouteDeclarationRepository,
target_capabilities: Arc>,
lifecycle: Option,
+ secret_generator: Arc,
}
impl ModelDeclarationRepository {
@@ -64,6 +69,7 @@ impl ModelDeclarationRepository {
catalog,
target_capabilities: Arc::new(RwLock::new(target_capabilities)),
lifecycle: None,
+ secret_generator: Arc::new(RandomSecretGenerator),
}
}
@@ -192,6 +198,7 @@ impl ModelDeclarationRepository {
profile_id,
placement,
configuration,
+ EngineSecrets::Generate(self.secret_generator.as_ref()),
)?;
deployment.model_deployment_id = deployment_id;
deployment.lifecycle_generation =
@@ -223,8 +230,14 @@ impl ModelDeclarationRepository {
ModelDeclarationRepositoryError,
> {
let site = self.site.model_snapshot()?;
- let deployment =
- self.resolve_model_deployment(&site, model_id, profile_id, placement, configuration)?;
+ let deployment = self.resolve_model_deployment(
+ &site,
+ model_id,
+ profile_id,
+ placement,
+ configuration,
+ EngineSecrets::Withhold,
+ )?;
match deployment.snapshot.and_then(|snapshot| snapshot.source) {
Some(ModelDeploymentSnapshotSource::Local(local)) => Ok(local),
Some(ModelDeploymentSnapshotSource::Upstream(_)) | None => {
@@ -602,6 +615,7 @@ impl ModelDeclarationRepository {
profile_id: &str,
placement: &ExactPlacement,
configuration: &LocalModelDeploymentConfiguration,
+ secrets: EngineSecrets<'_>,
) -> Result {
let catalog_model_id = CatalogModelId(model_id.to_owned());
validate_model_profile(
@@ -629,6 +643,7 @@ impl ModelDeclarationRepository {
.unwrap_or_else(|value| value.into_inner()),
// The SiteNode named by the exact placement pins a local-development engine itself.
None,
+ secrets,
)
.map_err(launch_resolution_error)?;
if local.model_id != model_id || local.profile_id != profile_id {
@@ -653,6 +668,13 @@ impl ModelDeclarationRepository {
node_resolved_launch_identities: Vec::new(),
})
}
+
+ /// Replaces the generator of the secret values new deployments retain.
+ #[cfg(test)]
+ pub(crate) fn with_secret_generator(mut self, generator: Arc) -> Self {
+ self.secret_generator = generator;
+ self
+ }
}
/// The target a resource estimate is evaluated on: its placement, the target observations that
@@ -813,6 +835,9 @@ pub(crate) fn launch_resolution_error(
error @ crate::model::resolution::ModelLaunchResolutionError::SizingUnavailable {
..
} => ModelDeclarationRepositoryError::Sizing(Box::new(error)),
+ error @ crate::model::resolution::ModelLaunchResolutionError::SecretGeneration {
+ ..
+ } => ModelDeclarationRepositoryError::SecretGeneration(Box::new(error)),
}
}
@@ -930,6 +955,9 @@ pub enum ModelDeclarationRepositoryError {
#[error("could not pin the engine launch identity: {0}")]
EngineLaunchIdentity(#[source] Box),
+ #[error(transparent)]
+ SecretGeneration(Box),
+
#[error("Catalog model state is invalid: {0}")]
InvalidState(&'static str),
@@ -1231,6 +1259,217 @@ mod tests {
assert!(!site.is_dirty());
}
+ #[test]
+ fn creation_generates_a_missing_engine_secret_once_after_sizing_and_keeps_a_typed_one() {
+ use crate::test_support::strata::{
+ API_KEY_GENERATED_LENGTH, API_KEY_PARAMETER_ID, EXPERT_VRAM_MIB_PARAMETER_ID,
+ };
+
+ let directory = test_directory();
+ let site_path = strata_site(directory.path());
+ let repository = strata_repository(&site_path, crate::test_support::strata::CATALOG_MODEL);
+ let placement = fixture_placement();
+ let configuration = LocalModelDeploymentConfiguration::default();
+ let typed_key = "typed-api-key-0123456789";
+ let typed = LocalModelDeploymentConfiguration {
+ engine_parameter_values: Some(ParameterValues {
+ values: vec![secret_value(API_KEY_PARAMETER_ID, typed_key)],
+ }),
+ ..Default::default()
+ };
+
+ // Advisory reads size and preview the launch without any secret value.
+ let estimate = repository
+ .estimate_local_model_resources("fixture-strata-model", "strata-q4", &typed, None)
+ .expect("estimate without a generated secret");
+ assert_eq!(estimate.hardware_class_estimates.len(), 1);
+ let preview = repository
+ .preview_local_model("fixture-strata-model", "strata-q4", &placement, &typed)
+ .expect("preview without a secret value");
+ assert_eq!(
+ secret_of(&preview, API_KEY_PARAMETER_ID),
+ None,
+ "a preview withholds even a typed secret"
+ );
+ assert!(preview.generated_engine_parameter_ids.is_empty());
+
+ let ((generated, kept), records, output) =
+ dotcore_domain::test_support::tracing::capture_records(|| {
+ let load = |configuration| {
+ repository.load_model(
+ "fixture-strata-model",
+ "strata-q4",
+ &placement,
+ configuration,
+ &ModelDeploymentExposureIntent::Hidden,
+ None,
+ )
+ };
+ (
+ load(&configuration).expect("deployment with a generated secret"),
+ load(&typed).expect("deployment with a typed secret"),
+ )
+ });
+
+ // The previews and estimate drew nothing: the first generated value is the first call.
+ let generated_local = local_snapshot(&generated);
+ assert_eq!(
+ secret_of(&generated_local, API_KEY_PARAMETER_ID),
+ Some(crate::test_support::sequential_secret(
+ 1,
+ API_KEY_GENERATED_LENGTH as usize
+ ))
+ );
+ assert_eq!(
+ generated_local.generated_engine_parameter_ids,
+ [API_KEY_PARAMETER_ID]
+ );
+ assert_eq!(
+ generated_local
+ .sizing
+ .as_ref()
+ .map(|sizing| sizing.derived_engine_parameter_ids.clone()),
+ Some(vec![
+ "context-per-request".to_owned(),
+ EXPERT_VRAM_MIB_PARAMETER_ID.to_owned(),
+ ])
+ );
+ let kept_local = local_snapshot(&kept);
+ assert_eq!(
+ secret_of(&kept_local, API_KEY_PARAMETER_ID),
+ Some(typed_key.to_owned())
+ );
+ assert!(kept_local.generated_engine_parameter_ids.is_empty());
+ assert_eq!(
+ generated_local.desired_launch_plan, kept_local.desired_launch_plan,
+ "the secret never reaches the launch plan"
+ );
+ assert_eq!(
+ generated_local.desired_launch_plan,
+ preview.desired_launch_plan
+ );
+ for secret in [typed_key, &crate::test_support::sequential_secret(1, 32)] {
+ assert!(!output.contains(secret), "{output}");
+ assert!(!format!("{records:?}").contains(secret), "{records:#?}");
+ }
+
+ // A relaunch reads the retained snapshot, which a reloaded site keeps as created.
+ let reloaded =
+ SiteRepository::load(&site_path, crate::test_support::site_declaration_rules())
+ .expect("reloaded site");
+ let retained = reloaded
+ .model_snapshot()
+ .expect("reloaded deployments")
+ .deployments;
+ for created in [&generated, &kept] {
+ let retained = retained
+ .iter()
+ .find(|deployment| deployment.model_deployment_id == created.model_deployment_id)
+ .expect("retained deployment");
+ assert_eq!(retained.snapshot, created.snapshot);
+ crate::model::resolved::ResolvedModelLaunch::from_declaration(retained)
+ .expect("relaunch from the retained snapshot");
+ }
+ }
+
+ #[test]
+ fn a_typed_engine_secret_is_checked_without_being_quoted() {
+ use crate::test_support::strata::API_KEY_PARAMETER_ID;
+
+ let directory = test_directory();
+ let site_path = strata_site(directory.path());
+ let repository = strata_repository(&site_path, crate::test_support::strata::CATALOG_MODEL);
+
+ for (value, reason) in [
+ ("short-key", "it is shorter than the minimum length of 16"),
+ (
+ "unsafe=api-key-0123456789",
+ "it contains `=` or a control character",
+ ),
+ ] {
+ let error = repository
+ .load_model(
+ "fixture-strata-model",
+ "strata-q4",
+ &fixture_placement(),
+ &LocalModelDeploymentConfiguration {
+ engine_parameter_values: Some(ParameterValues {
+ values: vec![secret_value(API_KEY_PARAMETER_ID, value)],
+ }),
+ ..Default::default()
+ },
+ &ModelDeploymentExposureIntent::Hidden,
+ None,
+ )
+ .expect_err("invalid typed secret")
+ .to_string();
+
+ assert_eq!(
+ error,
+ format!(
+ "invalid engine parameter values: profile \"strata-q4\" engine parameter \"api-key\" value is invalid: {reason}"
+ )
+ );
+ }
+ }
+
+ #[test]
+ fn the_snapshot_retains_the_profile_engine_parameter_defaults_and_visibility_overrides() {
+ use dotcore_proto::dotcore::catalog::v1::{
+ ParameterVisibility, ParameterVisibilityOverride,
+ };
+
+ let directory = test_directory();
+ let site_path = strata_site(directory.path());
+ let model = crate::test_support::strata::CATALOG_MODEL.replace(
+ " engineParameterDefaults: {}\n",
+ r#" engineParameterDefaults:
+ values:
+ - { parameterId: "parallel-requests", unsignedInteger: "2" }
+ engineParameterVisibilityOverrides:
+ - { parameterId: "expert-vram-mib", visibility: "PARAMETER_VISIBILITY_HIDDEN" }
+ - { parameterId: "api-key", visibility: "PARAMETER_VISIBILITY_BASIC" }
+"#,
+ );
+ assert_ne!(model, crate::test_support::strata::CATALOG_MODEL);
+ let repository = strata_repository(&site_path, &model);
+
+ let created = repository
+ .load_model(
+ "fixture-strata-model",
+ "strata-q4",
+ &fixture_placement(),
+ &LocalModelDeploymentConfiguration::default(),
+ &ModelDeploymentExposureIntent::Hidden,
+ None,
+ )
+ .expect("deployment");
+
+ let local = local_snapshot(&created);
+ assert_eq!(
+ local.profile_engine_parameter_defaults,
+ Some(ParameterValues {
+ values: vec![
+ unsigned_value("parallel-requests", 2),
+ unsigned_value("total-context", 32_768),
+ ],
+ })
+ );
+ assert_eq!(
+ local.profile_engine_parameter_visibility_overrides,
+ [
+ ParameterVisibilityOverride {
+ parameter_id: "expert-vram-mib".to_owned(),
+ visibility: ParameterVisibility::Hidden as i32,
+ },
+ ParameterVisibilityOverride {
+ parameter_id: "api-key".to_owned(),
+ visibility: ParameterVisibility::Basic as i32,
+ },
+ ]
+ );
+ }
+
#[test]
fn tag_only_engine_is_created_unpinned_for_its_hosting_node_to_pin() {
let directory = test_directory();
@@ -1422,6 +1661,7 @@ mod tests {
&target,
&inventory,
None,
+ crate::model::resolution::EngineSecrets::Generate(&crate::RandomSecretGenerator),
)
.expect("direct launch resolution");
@@ -3212,6 +3452,84 @@ mod tests {
catalog_root
}
+ /// Copies the simulated-site fixture, whose NVIDIA target runs Strata, into `directory`.
+ fn strata_site(directory: &Path) -> std::path::PathBuf {
+ let site_path = directory.join("site.yaml");
+ fs::copy(
+ Path::new(env!("CARGO_MANIFEST_DIR")).join("../fixtures/simulated-site/site.yaml"),
+ &site_path,
+ )
+ .expect("simulated site fixture");
+ site_path
+ }
+
+ /// A repository over the site at `site_path` and the Strata engine with an API key secret
+ /// beside `model`, generating predictable secrets.
+ fn strata_repository(site_path: &Path, model: &str) -> ModelDeclarationRepository {
+ let catalog = crate::test_support::strata::load_catalog(
+ &crate::test_support::strata::catalog_engine_with_api_key(),
+ model,
+ )
+ .expect("Strata catalog with an API key");
+ ModelDeclarationRepository::new(
+ SiteRepository::load(site_path, crate::test_support::site_declaration_rules())
+ .expect("validated simulated site"),
+ Arc::new(catalog),
+ fixture_target_capabilities(),
+ )
+ .with_secret_generator(crate::test_support::test_secret_generator())
+ }
+
+ fn fixture_placement() -> ExactPlacement {
+ ExactPlacement {
+ site_node_id: "fixture-node".to_owned(),
+ execution_target_id: "fixture-target".to_owned(),
+ }
+ }
+
+ fn local_snapshot(
+ deployment: &ModelDeploymentDeclaration,
+ ) -> dotcore_proto::dotcore::model::v1::ResolvedLocalModelDeploymentSnapshot {
+ match deployment
+ .snapshot
+ .as_ref()
+ .and_then(|snapshot| snapshot.source.as_ref())
+ {
+ Some(ModelDeploymentSnapshotSource::Local(local)) => local.clone(),
+ _ => panic!("local snapshot"),
+ }
+ }
+
+ /// The retained string value of `parameter_id`, if any.
+ fn secret_of(
+ local: &dotcore_proto::dotcore::model::v1::ResolvedLocalModelDeploymentSnapshot,
+ parameter_id: &str,
+ ) -> Option {
+ local
+ .engine_parameter_values
+ .iter()
+ .flat_map(|values| &values.values)
+ .find(|value| value.parameter_id == parameter_id)
+ .and_then(|value| match value.value.as_ref() {
+ Some(EngineParameterValueKind::String(secret)) => Some(secret.clone()),
+ _ => None,
+ })
+ }
+
+ fn secret_value(parameter_id: &str, value: &str) -> ParameterValue {
+ ParameterValue {
+ parameter_id: parameter_id.to_owned(),
+ value: Some(EngineParameterValueKind::String(value.to_owned())),
+ }
+ }
+
+ fn unsigned_value(parameter_id: &str, value: u64) -> ParameterValue {
+ ParameterValue {
+ parameter_id: parameter_id.to_owned(),
+ value: Some(EngineParameterValueKind::UnsignedInteger(value)),
+ }
+ }
+
fn fixture_target_capabilities() -> TargetCapabilityInventory {
crate::test_support::execution_target_capabilities(
dotcore_domain::ExecutionPlatform::linux_amd64(),
diff --git a/backend/core/src/model/resolution.rs b/backend/core/src/model/resolution.rs
index 144db35e..4ca3e1d8 100644
--- a/backend/core/src/model/resolution.rs
+++ b/backend/core/src/model/resolution.rs
@@ -7,7 +7,7 @@ use dotcore_artifact::{ArtifactManager, LocalImageIdentityResolutionError};
use dotcore_catalog::{
ACCELERATOR_MEMORY_RESOURCE, Catalog, CatalogError, CatalogModelId, HOST_MEMORY_RESOURCE,
ScalarResourceEstimate, SupportedHardwareClass, options::resolve_model_option_values,
- overlay_parameter_defaults, shared_parameters, validate_parameter_values,
+ overlay_parameter_defaults, shared_parameters, validate_parameter_values_before_secrets,
validate_supplied_parameter_values,
};
use dotcore_domain::deployment::artifact::immutable_remote_launch_identity;
@@ -32,6 +32,7 @@ use dotcore_proto::dotcore::{
use dotcore_site::node::target_inventory::TargetCapabilityInventory;
use crate::{
+ SecretGenerator,
hardware::selection::{
HardwareClassSelectionError, SelectedTargetCapabilities, TargetCapabilitySelectionError,
select_hardware_class, select_target_capabilities,
@@ -153,7 +154,12 @@ pub(crate) struct LocalModelSelection {
///
/// `engine_identity` is the local image ID a caller resolved for a selected tag-only image, which
/// may instead be left to the hosting SiteNode. It must name the selected image and is rejected
-/// for a digest-pinned image or a Nix artifact.
+/// for a digest-pinned image or a Nix artifact. `secrets` says whether the snapshot completes its
+/// secret engine parameter values to launch, or withholds them as an advisory snapshot.
+#[expect(
+ clippy::too_many_arguments,
+ reason = "each input names one independent part of the selection"
+)]
pub fn resolve_local_launch(
catalog: &Catalog,
model_id: &CatalogModelId,
@@ -162,6 +168,7 @@ pub fn resolve_local_launch(
target: &dotcore_proto::dotcore::site::v1::ExecutionTarget,
target_inventory: &TargetCapabilityInventory,
engine_identity: Option<&EngineLaunchIdentity>,
+ secrets: EngineSecrets<'_>,
) -> Result {
let selected_target = select_target_capabilities(target, target_inventory)
.map_err(ModelLaunchResolutionError::TargetCapability)?;
@@ -173,11 +180,80 @@ pub fn resolve_local_launch(
target,
&selected_target,
engine_identity,
+ secrets,
)
}
+/// What launch resolution does with secret engine parameter values, which sizing never reads.
+#[derive(Clone, Copy)]
+pub enum EngineSecrets<'a> {
+ /// Keeps each supplied secret value and generates each missing one, for a snapshot that
+ /// launches.
+ Generate(&'a dyn SecretGenerator),
+
+ /// Leaves every secret value out, for an advisory snapshot that never launches.
+ Withhold,
+}
+
+impl EngineSecrets<'_> {
+ /// Completes or strips the secret values among resolved `values` and returns the ids of the
+ /// generated ones, in schema order.
+ fn apply(
+ self,
+ values: &mut ParameterValues,
+ schema: &EngineParameterSchema,
+ ) -> Result, ModelLaunchResolutionError> {
+ let secrets = schema.parameters.iter().filter_map(|definition| {
+ match definition.parameter_type.as_ref() {
+ Some(EngineParameterType::Secret(secret)) => Some((definition, secret)),
+ _ => None,
+ }
+ });
+ let generator = match self {
+ Self::Generate(generator) => generator,
+ Self::Withhold => {
+ let secret_ids = secrets
+ .map(|(definition, _)| definition.parameter_id.as_str())
+ .collect::>();
+ values
+ .values
+ .retain(|value| !secret_ids.contains(&value.parameter_id.as_str()));
+ return Ok(Vec::new());
+ }
+ };
+ let mut generated = Vec::new();
+ for (definition, secret) in secrets {
+ let parameter_id = &definition.parameter_id;
+ if values
+ .values
+ .iter()
+ .any(|value| &value.parameter_id == parameter_id)
+ {
+ continue;
+ }
+ let secret_value = generator
+ .generate(secret.generated_length as usize)
+ .map_err(|source| ModelLaunchResolutionError::SecretGeneration {
+ parameter_id: parameter_id.clone(),
+ source,
+ })?;
+ values.values.push(ParameterValue {
+ parameter_id: parameter_id.clone(),
+ value: Some(EngineParameterValueKind::String(secret_value)),
+ });
+ generated.push(parameter_id.clone());
+ }
+ Ok(generated)
+ }
+}
+
/// Resolves one local catalog selection into its launch snapshot like [`resolve_local_launch`],
-/// on target capabilities the caller already selected.
+/// on target capabilities the caller already selected. Secret values are completed after sizing,
+/// which never reads them.
+#[expect(
+ clippy::too_many_arguments,
+ reason = "each input names one independent part of the selection"
+)]
pub(crate) fn resolve_local_launch_on_selected_target(
catalog: &Catalog,
model_id: &CatalogModelId,
@@ -186,6 +262,7 @@ pub(crate) fn resolve_local_launch_on_selected_target(
target: &dotcore_proto::dotcore::site::v1::ExecutionTarget,
selected_target: &SelectedTargetCapabilities,
engine_identity: Option<&EngineLaunchIdentity>,
+ secrets: EngineSecrets<'_>,
) -> Result {
let resolved = resolve_local_values_for_target(
catalog,
@@ -196,10 +273,19 @@ pub(crate) fn resolve_local_launch_on_selected_target(
)?;
let sizing = resolved.resolved_sizing();
let selection = resolved.selection;
- let parameter_values = resolved.engine_parameter_values;
+ let mut parameter_values = resolved.engine_parameter_values;
let engine_parameter_schema_id = resolved.engine_parameter_schema_id;
let engine_parameter_schema = resolved.engine_parameter_schema;
let option_values = resolved.option_values;
+ let generated_engine_parameter_ids =
+ secrets.apply(&mut parameter_values, &engine_parameter_schema)?;
+ let (_, profile_engine_parameter_defaults) =
+ catalog
+ .local_defaults_for(model_id, profile_id)
+ .ok_or_else(|| ModelLaunchResolutionError::ProfileDefaultsUnavailable {
+ model_id: model_id.0.clone(),
+ profile_id: profile_id.to_owned(),
+ })?;
let engine_target =
selection
.engine_target
@@ -269,6 +355,11 @@ pub(crate) fn resolve_local_launch_on_selected_target(
engine_parameter_values: Some(parameter_values),
engine_parameter_schema_id,
engine_parameter_schema: Some(engine_parameter_schema),
+ profile_engine_parameter_defaults: Some(profile_engine_parameter_defaults),
+ profile_engine_parameter_visibility_overrides: selection
+ .profile
+ .engine_parameter_visibility_overrides,
+ generated_engine_parameter_ids,
desired_launch_plan: None,
custom_argument_tokens: configuration.custom_argument_tokens.clone(),
resolved_options: option_values
@@ -289,10 +380,15 @@ pub(crate) fn resolve_local_launch_on_selected_target(
.translate(&local)
.map_err(|error| ModelLaunchResolutionError::LaunchPlan(anyhow::Error::new(error)))?,
);
- crate::model::resolved::validate_local_snapshot_with(
- &local,
- dotcore_domain::deployment::artifact::LaunchIdentityResolution::NodeResolved,
- )
+ let resolution = dotcore_domain::deployment::artifact::LaunchIdentityResolution::NodeResolved;
+ match secrets {
+ EngineSecrets::Generate(_) => {
+ crate::model::resolved::validate_local_snapshot_with(&local, resolution)
+ }
+ EngineSecrets::Withhold => {
+ crate::model::resolved::validate_advisory_local_snapshot(&local, resolution)
+ }
+ }
.map_err(ModelLaunchResolutionError::CatalogResolution)?;
Ok(local)
}
@@ -769,7 +865,8 @@ fn evaluate_selected_values(
.values
.extend(derived_engine_parameter_values.values.iter().cloned());
let mut parameter_errors = Vec::new();
- validate_parameter_values(
+ // Only creation completes secrets, after sizing, so previews and placement never need one.
+ validate_parameter_values_before_secrets(
&complete_values,
&selection.engine_release,
profile_id,
@@ -991,6 +1088,13 @@ pub enum ModelLaunchResolutionError {
#[error("could not translate local model launch plan: {0}")]
LaunchPlan(#[source] anyhow::Error),
+ #[error("could not generate secret engine parameter {parameter_id:?}: {source}")]
+ SecretGeneration {
+ parameter_id: String,
+ #[source]
+ source: anyhow::Error,
+ },
+
#[error(
"Engine artifact {reference:?} is digest-pinned or a Nix artifact and takes no resolved launch identity"
)]
@@ -1210,6 +1314,7 @@ mod tests {
&target,
&inventory,
None,
+ crate::model::resolution::EngineSecrets::Generate(&crate::RandomSecretGenerator),
)
.expect("unified snapshot");
@@ -1272,6 +1377,9 @@ mod tests {
&target,
&inventory,
None,
+ crate::model::resolution::EngineSecrets::Generate(
+ &crate::RandomSecretGenerator,
+ ),
)
.expect("snapshot")
};
@@ -1309,6 +1417,7 @@ mod tests {
&target,
&inventory,
None,
+ crate::model::resolution::EngineSecrets::Generate(&crate::RandomSecretGenerator),
)
.expect("manager admission");
@@ -1375,6 +1484,7 @@ mod tests {
&target,
&inventory,
None,
+ crate::model::resolution::EngineSecrets::Generate(&crate::RandomSecretGenerator),
)
.expect("snapshot artifact admission");
@@ -1436,6 +1546,7 @@ mod tests {
&target,
&inventory,
None,
+ crate::model::resolution::EngineSecrets::Generate(&crate::RandomSecretGenerator),
)
.expect("derived snapshot");
let values =
@@ -1500,6 +1611,7 @@ mod tests {
&target,
&inventory,
None,
+ crate::model::resolution::EngineSecrets::Generate(&crate::RandomSecretGenerator),
)
.expect("snapshot on the nvidia class");
let launch = ResolvedHardwareRequirementsValue::single_device(
@@ -1572,6 +1684,7 @@ mod tests {
&target,
&inventory,
None,
+ crate::model::resolution::EngineSecrets::Generate(&crate::RandomSecretGenerator),
)
};
@@ -1730,6 +1843,7 @@ mod tests {
&target,
&inventory,
None,
+ crate::model::resolution::EngineSecrets::Generate(&crate::RandomSecretGenerator),
)
.expect("supplied snapshot");
@@ -2153,6 +2267,7 @@ mod tests {
&target,
&inventory,
None,
+ crate::model::resolution::EngineSecrets::Generate(&crate::RandomSecretGenerator),
)
.expect("disabled vision snapshot");
@@ -2225,6 +2340,7 @@ mod tests {
&target,
&inventory,
None,
+ crate::model::resolution::EngineSecrets::Generate(&crate::RandomSecretGenerator),
)
.expect("digest-pinned snapshot");
@@ -2563,6 +2679,7 @@ mod tests {
target,
&inventory,
None,
+ crate::model::resolution::EngineSecrets::Generate(&crate::RandomSecretGenerator),
)
};
@@ -2663,6 +2780,7 @@ mod tests {
&target,
&inventory,
engine_identity,
+ crate::model::resolution::EngineSecrets::Generate(&crate::RandomSecretGenerator),
)
}
diff --git a/backend/core/src/model/resolved.rs b/backend/core/src/model/resolved.rs
index a4578415..6e104731 100644
--- a/backend/core/src/model/resolved.rs
+++ b/backend/core/src/model/resolved.rs
@@ -5,7 +5,10 @@ use std::collections::{BTreeMap, BTreeSet};
use dotcore_artifact::{
EngineArtifactPreparation, EngineArtifactSource, ModelArtifactSource, OciPlatform,
};
-use dotcore_catalog::{CatalogError, CatalogModelId, validate_parameter_values};
+use dotcore_catalog::{
+ CatalogError, CatalogModelId, typed_parameters::is_secret, validate_parameter_values,
+ validate_parameter_values_before_secrets,
+};
use dotcore_domain::{
MemoryArchitecture,
deployment::artifact::{
@@ -20,6 +23,7 @@ use dotcore_proto::dotcore::{
ModelInputCapabilities, ParameterValues, StorageBinding,
engine_artifact::Kind as EngineArtifactKind,
hugging_face_artifact_source::Content as HuggingFaceContent, nix_artifact::Provides,
+ parameter_value::Value as EngineParameterValueKind,
},
deployment::v1::ArtifactPreparationPolicy,
model::v1::{
@@ -197,7 +201,17 @@ impl ResolvedModelLaunch {
self.engine.launch_identity.clone()
}
},
- engine_parameter_values: snapshot.engine_parameter_values.clone(),
+ // Sizing reads integers only, so records leave secret values out.
+ engine_parameter_values: snapshot.engine_parameter_values.as_ref().map(|values| {
+ ParameterValues {
+ values: values
+ .values
+ .iter()
+ .filter(|value| !is_secret_value(snapshot, &value.parameter_id))
+ .cloned()
+ .collect(),
+ }
+ }),
option_values: snapshot
.resolved_options
.iter()
@@ -458,6 +472,54 @@ pub(crate) fn validate_local_snapshot(
pub(crate) fn validate_local_snapshot_with(
local: &ResolvedLocalModelDeploymentSnapshot,
resolution: LaunchIdentityResolution,
+) -> Result<(), CatalogError> {
+ validate_local_snapshot_values(local, resolution, SecretValues::Retained)
+}
+
+/// Validates an advisory local snapshot like [`validate_local_snapshot_with`], but one that
+/// withholds every secret engine parameter value, since it never launches.
+pub(crate) fn validate_advisory_local_snapshot(
+ local: &ResolvedLocalModelDeploymentSnapshot,
+ resolution: LaunchIdentityResolution,
+) -> Result<(), CatalogError> {
+ validate_local_snapshot_values(local, resolution, SecretValues::Withheld)
+}
+
+/// The secret engine parameter values a local deployment retains, which logs, failure text and
+/// telemetry never show; none for an upstream deployment.
+pub(crate) fn secret_engine_parameter_values(
+ declaration: &ModelDeploymentDeclaration,
+) -> Vec {
+ let Some(Source::Local(local)) = declaration
+ .snapshot
+ .as_ref()
+ .and_then(|snapshot| snapshot.source.as_ref())
+ else {
+ return Vec::new();
+ };
+ local
+ .engine_parameter_values
+ .iter()
+ .flat_map(|values| &values.values)
+ .filter(|value| is_secret_value(local, &value.parameter_id))
+ .filter_map(|value| match value.value.as_ref() {
+ Some(EngineParameterValueKind::String(secret)) => Some(secret.clone()),
+ _ => None,
+ })
+ .collect()
+}
+
+/// Whether a snapshot's secret engine parameter values are retained or withheld.
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum SecretValues {
+ Retained,
+ Withheld,
+}
+
+fn validate_local_snapshot_values(
+ local: &ResolvedLocalModelDeploymentSnapshot,
+ resolution: LaunchIdentityResolution,
+ secrets: SecretValues,
) -> Result<(), CatalogError> {
if local.model_id.is_empty()
|| local.configuration_id.is_empty()
@@ -489,20 +551,51 @@ pub(crate) fn validate_local_snapshot_with(
.as_ref()
.ok_or_else(|| invalid("local deployment snapshot has no engine parameter values"))?;
let mut parameter_errors = Vec::new();
- validate_parameter_values(
- values,
- &EngineRelease {
- parameter_schema: Some(schema.clone()),
- ..Default::default()
- },
- &local.profile_id,
- &mut parameter_errors,
- );
- if parameter_errors.into_iter().next().is_some() {
+ let engine_release = EngineRelease {
+ parameter_schema: Some(schema.clone()),
+ ..Default::default()
+ };
+ match secrets {
+ SecretValues::Retained => validate_parameter_values(
+ values,
+ &engine_release,
+ &local.profile_id,
+ &mut parameter_errors,
+ ),
+ SecretValues::Withheld => validate_parameter_values_before_secrets(
+ values,
+ &engine_release,
+ &local.profile_id,
+ &mut parameter_errors,
+ ),
+ }
+ let withheld_secret = secrets == SecretValues::Withheld
+ && values
+ .values
+ .iter()
+ .any(|value| is_secret_value(local, &value.parameter_id));
+ if parameter_errors.into_iter().next().is_some() || withheld_secret {
return Err(invalid(
"local deployment snapshot has invalid engine parameter values",
));
}
+ let generated_ids = local
+ .generated_engine_parameter_ids
+ .iter()
+ .collect::>();
+ if generated_ids.len() != local.generated_engine_parameter_ids.len()
+ || generated_ids.iter().any(|parameter_id| {
+ !is_secret_value(local, parameter_id)
+ || !values
+ .values
+ .iter()
+ .any(|value| &value.parameter_id == *parameter_id)
+ })
+ {
+ return Err(invalid(
+ "local deployment snapshot names a generated engine parameter that is not a retained secret",
+ ));
+ }
let engine = local
.engine_artifact
.as_ref()
@@ -621,6 +714,16 @@ pub(crate) fn selected_target_memory_architecture(
)
}
+/// Whether the snapshot's retained engine parameter schema declares `parameter_id` as a secret
+/// parameter, whose value logs, failure text and telemetry never show.
+fn is_secret_value(local: &ResolvedLocalModelDeploymentSnapshot, parameter_id: &str) -> bool {
+ local
+ .engine_parameter_schema
+ .iter()
+ .flat_map(|schema| &schema.parameters)
+ .any(|definition| definition.parameter_id == parameter_id && is_secret(definition))
+}
+
/// Requires every derived parameter id to name one retained engine parameter value.
fn validate_sizing(
sizing: Option<&ResolvedSizing>,
diff --git a/backend/core/src/secret_generator.rs b/backend/core/src/secret_generator.rs
new file mode 100644
index 00000000..3195c0fc
--- /dev/null
+++ b/backend/core/src/secret_generator.rs
@@ -0,0 +1,71 @@
+//! Random values for deployment secret parameters.
+//!
+//! Application and model deployments generate a value for each secret parameter an operator
+//! leaves out when the deployment is created. Generators are injected, so tests can predict the
+//! values, while production draws them from the operating system's cryptographic random source.
+
+/// Source of the random values generated for secret parameters an operator leaves out.
+pub trait SecretGenerator: Send + Sync {
+ /// Returns `length` characters drawn uniformly from ASCII letters and digits.
+ fn generate(&self, length: usize) -> anyhow::Result;
+}
+
+/// Generates secret values from the operating system's cryptographic random source.
+#[derive(Clone, Copy, Debug, Default)]
+pub struct RandomSecretGenerator;
+
+impl SecretGenerator for RandomSecretGenerator {
+ fn generate(&self, length: usize) -> anyhow::Result {
+ // 248 is the largest multiple of the alphabet size within a byte; rejecting bytes above
+ // it keeps every character equally likely.
+ const ACCEPTED_BYTES: u8 =
+ (u8::MAX / SECRET_ALPHABET.len() as u8) * SECRET_ALPHABET.len() as u8;
+ let mut value = String::with_capacity(length);
+ let mut bytes = [0_u8; 64];
+ while value.len() < length {
+ getrandom::fill(&mut bytes).map_err(|source| {
+ anyhow::Error::new(EntropyError(source)).context("Secret entropy failed")
+ })?;
+ value.extend(
+ bytes
+ .iter()
+ .filter(|byte| **byte < ACCEPTED_BYTES)
+ .map(|byte| {
+ char::from(SECRET_ALPHABET[usize::from(*byte) % SECRET_ALPHABET.len()])
+ })
+ .take(length - value.len()),
+ );
+ }
+ Ok(value)
+ }
+}
+
+/// Characters a generated secret is drawn from: safe in environment values, URLs and shells.
+const SECRET_ALPHABET: &[u8; 62] =
+ b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
+
+#[derive(Debug, thiserror::Error)]
+#[error("{0}")]
+struct EntropyError(getrandom::Error);
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn random_secrets_have_the_requested_length_from_the_alphanumeric_alphabet() {
+ let generator = RandomSecretGenerator;
+ for length in [1, 32, 200] {
+ let value = generator.generate(length).expect("generated secret");
+ assert_eq!(value.len(), length);
+ assert!(
+ value.bytes().all(|byte| SECRET_ALPHABET.contains(&byte)),
+ "{value}"
+ );
+ }
+ assert_ne!(
+ generator.generate(32).expect("first secret"),
+ generator.generate(32).expect("second secret")
+ );
+ }
+}
diff --git a/backend/core/src/test_support/mod.rs b/backend/core/src/test_support/mod.rs
index 0232e50e..54f016c4 100644
--- a/backend/core/src/test_support/mod.rs
+++ b/backend/core/src/test_support/mod.rs
@@ -276,3 +276,29 @@ pub(crate) fn routed_site(deployment_ids: &[&str]) -> dotcore_proto::dotcore::si
pub(crate) fn site_declaration_rules() -> SiteDeclarationRules {
crate::model::site_declaration_rules()
}
+
+/// A secret generator whose values tests can predict: the `n`th call returns `secret` padded
+/// with `x` to the requested length.
+pub(crate) fn test_secret_generator() -> std::sync::Arc {
+ std::sync::Arc::new(SequentialSecretGenerator::default())
+}
+
+/// The `n`th value [`test_secret_generator`] returns at `length`, counting from 1.
+pub(crate) fn sequential_secret(n: usize, length: usize) -> String {
+ let mut value = format!("secret{n}");
+ value.extend(std::iter::repeat_n('x', length.saturating_sub(value.len())));
+ value.truncate(length);
+ value
+}
+
+#[derive(Default)]
+struct SequentialSecretGenerator {
+ calls: std::sync::atomic::AtomicUsize,
+}
+
+impl crate::SecretGenerator for SequentialSecretGenerator {
+ fn generate(&self, length: usize) -> anyhow::Result {
+ let n = self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1;
+ Ok(sequential_secret(n, length))
+ }
+}
diff --git a/backend/core/src/test_support/strata.rs b/backend/core/src/test_support/strata.rs
index c3be6bc9..3397737a 100644
--- a/backend/core/src/test_support/strata.rs
+++ b/backend/core/src/test_support/strata.rs
@@ -16,9 +16,9 @@ use dotcore_proto::dotcore::{
HuggingFaceSnapshotFile, InferenceRequestKind, Model, ModelArtifact, ModelArtifactBinding,
ModelAuxiliaryArtifact, ModelEngineConfiguration, ModelExecutionCapabilities,
ModelInputCapabilities, ModelInputModality, ModelParallelShape, ParameterDefinition,
- ParameterValue, ParameterValues, ParameterVisibility, StorageBinding, StorageKind,
- StorageScope, UnsignedIntegerParameterSchema, hugging_face_artifact_source,
- parameter_definition, parameter_value,
+ ParameterValue, ParameterValues, ParameterVisibility, SecretParameterSchema,
+ StorageBinding, StorageKind, StorageScope, UnsignedIntegerParameterSchema,
+ hugging_face_artifact_source, parameter_definition, parameter_value,
},
engine::{
strata::v1::{
@@ -38,6 +38,12 @@ pub(crate) const EXPERT_VRAM_MIB_PARAMETER_ID: &str = "expert-vram-mib";
/// Auxiliary artifact holding the fixture model's separate draft layer.
pub(crate) const DRAFT_LAYER_ARTIFACT_ID: &str = "fixture-draft-layer";
+/// Secret engine parameter some fixtures add: the deployment's API key.
+pub(crate) const API_KEY_PARAMETER_ID: &str = "api-key";
+
+/// Length of a generated [`API_KEY_PARAMETER_ID`] value.
+pub(crate) const API_KEY_GENERATED_LENGTH: u32 = 32;
+
/// Resolved engine parameter values of the profile: the engine's default total context, the
/// context per request filled from it, one parallel request and the expert cap from sizing.
const PROFILE_PARAMETERS: [(&str, u64); 4] = [
@@ -102,6 +108,30 @@ pub(crate) fn local_model_snapshot_with(
snapshot
}
+/// Returns [`local_model_snapshot`] from [`release_with_api_key`], with `api_key` as the value of
+/// its secret.
+pub(crate) fn local_model_snapshot_with_api_key(
+ model_id: &str,
+ api_key: &str,
+) -> ModelDeploymentSnapshot {
+ let mut snapshot = local_model_snapshot(model_id);
+ let Some(model_deployment_snapshot::Source::Local(local)) = snapshot.source.as_mut() else {
+ panic!("local fixture snapshot");
+ };
+ local.engine_parameter_schema = release_with_api_key().parameter_schema;
+ local
+ .engine_parameter_values
+ .as_mut()
+ .expect("engine parameter values")
+ .values
+ .push(ParameterValue {
+ parameter_id: API_KEY_PARAMETER_ID.to_owned(),
+ value: Some(parameter_value::Value::String(api_key.to_owned())),
+ });
+ refresh_launch_plan(local);
+ snapshot
+}
+
/// Recomputes the canonical launch plan after a test edits a Strata snapshot.
pub(crate) fn refresh_launch_plan(local: &mut ResolvedLocalModelDeploymentSnapshot) {
local.desired_launch_plan =
@@ -207,6 +237,59 @@ pub(crate) fn release() -> EngineRelease {
}
}
+/// Returns [`release`] with the secret [`API_KEY_PARAMETER_ID`] added after its other parameters.
+pub(crate) fn release_with_api_key() -> EngineRelease {
+ let mut release = release();
+ release
+ .parameter_schema
+ .as_mut()
+ .expect("schema")
+ .parameters
+ .push(ParameterDefinition {
+ parameter_id: API_KEY_PARAMETER_ID.to_owned(),
+ display_name: "API key".to_owned(),
+ parameter_type: Some(parameter_definition::ParameterType::Secret(
+ SecretParameterSchema {
+ generated_length: API_KEY_GENERATED_LENGTH,
+ minimum_length: 16,
+ },
+ )),
+ visibility: ParameterVisibility::Advanced as i32,
+ description: API_KEY_PARAMETER_ID.to_owned(),
+ });
+ release
+}
+
+/// Returns [`CATALOG_ENGINE`] with the secret [`API_KEY_PARAMETER_ID`] of
+/// [`release_with_api_key`].
+pub(crate) fn catalog_engine_with_api_key() -> String {
+ CATALOG_ENGINE.replace(
+ "storageBindings:\n",
+ " - { parameterId: \"api-key\", displayName: \"API key\", visibility: \"PARAMETER_VISIBILITY_ADVANCED\", description: \"api-key\", secret: { generatedLength: 32, minimumLength: 16 } }\nstorageBindings:\n",
+ )
+}
+
+/// Loads the given engine and model declarations with the shared hardware classes.
+pub(crate) fn load_catalog(engine: &str, model: &str) -> anyhow::Result {
+ let root = tempfile::tempdir().expect("Catalog root");
+ for (path, content) in [
+ ("engines/strata.yaml", engine),
+ ("models/strata-model.yaml", model),
+ ] {
+ let path = root.path().join(path);
+ std::fs::create_dir_all(path.parent().expect("directory")).expect("directory");
+ std::fs::write(path, content).expect("declaration");
+ }
+ dotcore_catalog::test_support::write_hardware_classes(root.path());
+
+ dotcore_catalog::Catalog::load_with_policy(
+ root.path(),
+ dotcore_catalog::OciArtifactPolicy::Development,
+ crate::model::engine::declaration_validator(),
+ )
+ .map_err(anyhow::Error::new)
+}
+
/// Returns a model with a single GGUF, a split GGUF set, a ready-made pack and a draft layer, and
/// no options.
pub(crate) fn model() -> Model {
@@ -372,3 +455,87 @@ fn snapshot_source