From e28bf0dd64a8d43ab3143b76f07397419dc7cd9e Mon Sep 17 00:00:00 2001 From: Andre-Philippe Paquet Date: Wed, 11 Nov 2026 13:25:20 -0500 Subject: [PATCH 01/25] feat(proto): add get started recommendation contract --- .../gen/dotcore/application/v1/service_pb.ts | 134 ++++++++++++++---- .../gen/dotcore/catalog/v1/application_pb.ts | 18 ++- .../dotcore/catalog/v1/recommendation_pb.ts | 25 ++-- .../src/gen/dotcore/model/v1/service_pb.ts | 22 ++- .../test/catalog-model-site-contract.test.ts | 1 + proto/dotcore/application/v1/service.proto | 26 ++++ proto/dotcore/catalog/v1/application.proto | 7 + proto/dotcore/catalog/v1/recommendation.proto | 16 +-- proto/dotcore/model/v1/service.proto | 10 +- 9 files changed, 204 insertions(+), 55 deletions(-) diff --git a/frontend/src/gen/dotcore/application/v1/service_pb.ts b/frontend/src/gen/dotcore/application/v1/service_pb.ts index 42859e1f..a9b6f84a 100644 --- a/frontend/src/gen/dotcore/application/v1/service_pb.ts +++ b/frontend/src/gen/dotcore/application/v1/service_pb.ts @@ -18,7 +18,85 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file dotcore/application/v1/service.proto. */ export const file_dotcore_application_v1_service: GenFile = /*@__PURE__*/ - fileDesc("CiRkb3Rjb3JlL2FwcGxpY2F0aW9uL3YxL3NlcnZpY2UucHJvdG8SFmRvdGNvcmUuYXBwbGljYXRpb24udjEiIwohTGlzdEFwcGxpY2F0aW9uRGVwbG95bWVudHNSZXF1ZXN0ImgKIkxpc3RBcHBsaWNhdGlvbkRlcGxveW1lbnRzUmVzcG9uc2USQgoLZGVwbG95bWVudHMYASADKAsyLS5kb3Rjb3JlLmFwcGxpY2F0aW9uLnYxLkFwcGxpY2F0aW9uRGVwbG95bWVudCJECh9HZXRBcHBsaWNhdGlvbkRlcGxveW1lbnRSZXF1ZXN0EiEKGWFwcGxpY2F0aW9uX2RlcGxveW1lbnRfaWQYASABKAkiZQogR2V0QXBwbGljYXRpb25EZXBsb3ltZW50UmVzcG9uc2USQQoKZGVwbG95bWVudBgBIAEoCzItLmRvdGNvcmUuYXBwbGljYXRpb24udjEuQXBwbGljYXRpb25EZXBsb3ltZW50IuMCCiJDcmVhdGVBcHBsaWNhdGlvbkRlcGxveW1lbnRSZXF1ZXN0EhYKDmFwcGxpY2F0aW9uX2lkGAEgASgJEjgKCXBsYWNlbWVudBgCIAEoCzIlLmRvdGNvcmUuZGVwbG95bWVudC52MS5FeGFjdFBsYWNlbWVudBIfChJpbmZlcmVuY2Vfcm91dGVfaWQYAyABKAlIAIgBARIrCiNleHBlY3RlZF9pbmZlcmVuY2Vfcm91dGVfZ2VuZXJhdGlvbhgEIAEoBBIoChthcHBsaWNhdGlvbl9kZXBsb3ltZW50X25hbWUYBiABKAlIAYgBARI8ChBwYXJhbWV0ZXJfdmFsdWVzGAUgAygLMiIuZG90Y29yZS5jYXRhbG9nLnYxLlBhcmFtZXRlclZhbHVlQhUKE19pbmZlcmVuY2Vfcm91dGVfaWRCHgocX2FwcGxpY2F0aW9uX2RlcGxveW1lbnRfbmFtZSJoCiNDcmVhdGVBcHBsaWNhdGlvbkRlcGxveW1lbnRSZXNwb25zZRJBCgpkZXBsb3ltZW50GAEgASgLMi0uZG90Y29yZS5hcHBsaWNhdGlvbi52MS5BcHBsaWNhdGlvbkRlcGxveW1lbnQiRwoiUmVtb3ZlQXBwbGljYXRpb25EZXBsb3ltZW50UmVxdWVzdBIhChlhcHBsaWNhdGlvbl9kZXBsb3ltZW50X2lkGAEgASgJImgKI1JlbW92ZUFwcGxpY2F0aW9uRGVwbG95bWVudFJlc3BvbnNlEkEKCmRlcGxveW1lbnQYASABKAsyLS5kb3Rjb3JlLmFwcGxpY2F0aW9uLnYxLkFwcGxpY2F0aW9uRGVwbG95bWVudCI7ChZQYXVzZURlcGxveW1lbnRSZXF1ZXN0EiEKGWFwcGxpY2F0aW9uX2RlcGxveW1lbnRfaWQYASABKAkiXAoXUGF1c2VEZXBsb3ltZW50UmVzcG9uc2USQQoKZGVwbG95bWVudBgBIAEoCzItLmRvdGNvcmUuYXBwbGljYXRpb24udjEuQXBwbGljYXRpb25EZXBsb3ltZW50IjwKF1Jlc3VtZURlcGxveW1lbnRSZXF1ZXN0EiEKGWFwcGxpY2F0aW9uX2RlcGxveW1lbnRfaWQYASABKAkiXQoYUmVzdW1lRGVwbG95bWVudFJlc3BvbnNlEkEKCmRlcGxveW1lbnQYASABKAsyLS5kb3Rjb3JlLmFwcGxpY2F0aW9uLnYxLkFwcGxpY2F0aW9uRGVwbG95bWVudCK3AQoZUmVhZERlcGxveW1lbnRMb2dzUmVxdWVzdBIhChlhcHBsaWNhdGlvbl9kZXBsb3ltZW50X2lkGAEgASgJEhQKDHNvdXJjZV9uYW1lcxgCIAMoCRIRCglwYWdlX3NpemUYAyABKA0SOQoQYmVmb3JlX3RpbWVzdGFtcBgEIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBIAIgBAUITChFfYmVmb3JlX3RpbWVzdGFtcCKmAwoaUmVhZERlcGxveW1lbnRMb2dzUmVzcG9uc2USPAoHc291cmNlcxgBIAMoCzIrLmRvdGNvcmUuYXBwbGljYXRpb24udjEuRGVwbG95bWVudExvZ1NvdXJjZRI7CgdlbnRyaWVzGAIgAygLMiouZG90Y29yZS5hcHBsaWNhdGlvbi52MS5EZXBsb3ltZW50TG9nRW50cnkSPgoVbmV4dF9iZWZvcmVfdGltZXN0YW1wGAMgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcEgAiAEBEkgKDWhpc3Rvcnlfc3RhdGUYBCABKA4yMS5kb3Rjb3JlLmFwcGxpY2F0aW9uLnYxLkRlcGxveW1lbnRMb2dIaXN0b3J5U3RhdGUSGgoScmVzcG9uc2VfdHJ1bmNhdGVkGAUgASgIEk0KEGxhdGVzdF9wb3NpdGlvbnMYBiADKAsyMy5kb3Rjb3JlLmFwcGxpY2F0aW9uLnYxLkRlcGxveW1lbnRMb2dSZXN1bWVQb3NpdGlvbkIYChZfbmV4dF9iZWZvcmVfdGltZXN0YW1wIqUBChtGb2xsb3dEZXBsb3ltZW50TG9nc1JlcXVlc3QSIQoZYXBwbGljYXRpb25fZGVwbG95bWVudF9pZBgBIAEoCRIUCgxzb3VyY2VfbmFtZXMYAiADKAkSTQoQcmVzdW1lX3Bvc2l0aW9ucxgDIAMoCzIzLmRvdGNvcmUuYXBwbGljYXRpb24udjEuRGVwbG95bWVudExvZ1Jlc3VtZVBvc2l0aW9uIq4BChxGb2xsb3dEZXBsb3ltZW50TG9nc1Jlc3BvbnNlEkgKDHNvdXJjZV9ldmVudBgBIAEoCzIwLmRvdGNvcmUuYXBwbGljYXRpb24udjEuRGVwbG95bWVudExvZ1NvdXJjZUV2ZW50SAASOwoFZW50cnkYAiABKAsyKi5kb3Rjb3JlLmFwcGxpY2F0aW9uLnYxLkRlcGxveW1lbnRMb2dFbnRyeUgAQgcKBWV2ZW50IoEBChNEZXBsb3ltZW50TG9nU291cmNlEhMKC3NvdXJjZV9uYW1lGAEgASgJEhQKDHNvdXJjZV9sYWJlbBgCIAEoCRI/CgVzdGF0ZRgDIAEoDjIwLmRvdGNvcmUuYXBwbGljYXRpb24udjEuRGVwbG95bWVudExvZ1NvdXJjZVN0YXRlIs8BChJEZXBsb3ltZW50TG9nRW50cnkSEwoLc291cmNlX25hbWUYASABKAkSFAoMc291cmNlX2xhYmVsGAIgASgJEjsKBnN0cmVhbRgDIAEoDjIrLmRvdGNvcmUuYXBwbGljYXRpb24udjEuRGVwbG95bWVudExvZ1N0cmVhbRItCgl0aW1lc3RhbXAYBCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEg8KB2NvbnRlbnQYBSABKAwSEQoJdHJ1bmNhdGVkGAYgASgIImgKG0RlcGxveW1lbnRMb2dSZXN1bWVQb3NpdGlvbhITCgtzb3VyY2VfbmFtZRgBIAEoCRI0ChBsYXRlc3RfdGltZXN0YW1wGAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCKGAQoYRGVwbG95bWVudExvZ1NvdXJjZUV2ZW50EhMKC3NvdXJjZV9uYW1lGAEgASgJEhQKDHNvdXJjZV9sYWJlbBgCIAEoCRI/CgVzdGF0ZRgDIAEoDjIwLmRvdGNvcmUuYXBwbGljYXRpb24udjEuRGVwbG95bWVudExvZ1NvdXJjZVN0YXRlIskBCipVcGRhdGVBcHBsaWNhdGlvbkRlcGxveW1lbnRNZXRhZGF0YVJlcXVlc3QSIQoZYXBwbGljYXRpb25fZGVwbG95bWVudF9pZBgBIAEoCRJHCghtZXRhZGF0YRgCIAEoCzI1LmRvdGNvcmUuYXBwbGljYXRpb24udjEuQXBwbGljYXRpb25EZXBsb3ltZW50TWV0YWRhdGESLwoLdXBkYXRlX21hc2sYAyABKAsyGi5nb29nbGUucHJvdG9idWYuRmllbGRNYXNrIksKHUFwcGxpY2F0aW9uRGVwbG95bWVudE1ldGFkYXRhEhkKDGRpc3BsYXlfbmFtZRgBIAEoCUgAiAEBQg8KDV9kaXNwbGF5X25hbWUicAorVXBkYXRlQXBwbGljYXRpb25EZXBsb3ltZW50TWV0YWRhdGFSZXNwb25zZRJBCgpkZXBsb3ltZW50GAEgASgLMi0uZG90Y29yZS5hcHBsaWNhdGlvbi52MS5BcHBsaWNhdGlvbkRlcGxveW1lbnQqvgEKGURlcGxveW1lbnRMb2dIaXN0b3J5U3RhdGUSLAooREVQTE9ZTUVOVF9MT0dfSElTVE9SWV9TVEFURV9VTlNQRUNJRklFRBAAEjgKNERFUExPWU1FTlRfTE9HX0hJU1RPUllfU1RBVEVfT0xERVJfT1VUUFVUX01BWV9SRU1BSU4QARI5CjVERVBMT1lNRU5UX0xPR19ISVNUT1JZX1NUQVRFX05PX09MREVSX09VVFBVVF9PQlNFUlZFRBACKsYBChhEZXBsb3ltZW50TG9nU291cmNlU3RhdGUSKwonREVQTE9ZTUVOVF9MT0dfU09VUkNFX1NUQVRFX1VOU1BFQ0lGSUVEEAASKQolREVQTE9ZTUVOVF9MT0dfU09VUkNFX1NUQVRFX0FWQUlMQUJMRRABEisKJ0RFUExPWU1FTlRfTE9HX1NPVVJDRV9TVEFURV9VTkFWQUlMQUJMRRACEiUKIURFUExPWU1FTlRfTE9HX1NPVVJDRV9TVEFURV9FTkRFRBADKoABChNEZXBsb3ltZW50TG9nU3RyZWFtEiUKIURFUExPWU1FTlRfTE9HX1NUUkVBTV9VTlNQRUNJRklFRBAAEiAKHERFUExPWU1FTlRfTE9HX1NUUkVBTV9TVERPVVQQARIgChxERVBMT1lNRU5UX0xPR19TVFJFQU1fU1RERVJSEAIylQoKHEFwcGxpY2F0aW9uRGVwbG95bWVudFNlcnZpY2USkwEKGkxpc3RBcHBsaWNhdGlvbkRlcGxveW1lbnRzEjkuZG90Y29yZS5hcHBsaWNhdGlvbi52MS5MaXN0QXBwbGljYXRpb25EZXBsb3ltZW50c1JlcXVlc3QaOi5kb3Rjb3JlLmFwcGxpY2F0aW9uLnYxLkxpc3RBcHBsaWNhdGlvbkRlcGxveW1lbnRzUmVzcG9uc2USjQEKGEdldEFwcGxpY2F0aW9uRGVwbG95bWVudBI3LmRvdGNvcmUuYXBwbGljYXRpb24udjEuR2V0QXBwbGljYXRpb25EZXBsb3ltZW50UmVxdWVzdBo4LmRvdGNvcmUuYXBwbGljYXRpb24udjEuR2V0QXBwbGljYXRpb25EZXBsb3ltZW50UmVzcG9uc2USlgEKG0NyZWF0ZUFwcGxpY2F0aW9uRGVwbG95bWVudBI6LmRvdGNvcmUuYXBwbGljYXRpb24udjEuQ3JlYXRlQXBwbGljYXRpb25EZXBsb3ltZW50UmVxdWVzdBo7LmRvdGNvcmUuYXBwbGljYXRpb24udjEuQ3JlYXRlQXBwbGljYXRpb25EZXBsb3ltZW50UmVzcG9uc2USlgEKG1JlbW92ZUFwcGxpY2F0aW9uRGVwbG95bWVudBI6LmRvdGNvcmUuYXBwbGljYXRpb24udjEuUmVtb3ZlQXBwbGljYXRpb25EZXBsb3ltZW50UmVxdWVzdBo7LmRvdGNvcmUuYXBwbGljYXRpb24udjEuUmVtb3ZlQXBwbGljYXRpb25EZXBsb3ltZW50UmVzcG9uc2UScgoPUGF1c2VEZXBsb3ltZW50Ei4uZG90Y29yZS5hcHBsaWNhdGlvbi52MS5QYXVzZURlcGxveW1lbnRSZXF1ZXN0Gi8uZG90Y29yZS5hcHBsaWNhdGlvbi52MS5QYXVzZURlcGxveW1lbnRSZXNwb25zZRJ1ChBSZXN1bWVEZXBsb3ltZW50Ei8uZG90Y29yZS5hcHBsaWNhdGlvbi52MS5SZXN1bWVEZXBsb3ltZW50UmVxdWVzdBowLmRvdGNvcmUuYXBwbGljYXRpb24udjEuUmVzdW1lRGVwbG95bWVudFJlc3BvbnNlEnsKElJlYWREZXBsb3ltZW50TG9ncxIxLmRvdGNvcmUuYXBwbGljYXRpb24udjEuUmVhZERlcGxveW1lbnRMb2dzUmVxdWVzdBoyLmRvdGNvcmUuYXBwbGljYXRpb24udjEuUmVhZERlcGxveW1lbnRMb2dzUmVzcG9uc2USgwEKFEZvbGxvd0RlcGxveW1lbnRMb2dzEjMuZG90Y29yZS5hcHBsaWNhdGlvbi52MS5Gb2xsb3dEZXBsb3ltZW50TG9nc1JlcXVlc3QaNC5kb3Rjb3JlLmFwcGxpY2F0aW9uLnYxLkZvbGxvd0RlcGxveW1lbnRMb2dzUmVzcG9uc2UwARKuAQojVXBkYXRlQXBwbGljYXRpb25EZXBsb3ltZW50TWV0YWRhdGESQi5kb3Rjb3JlLmFwcGxpY2F0aW9uLnYxLlVwZGF0ZUFwcGxpY2F0aW9uRGVwbG95bWVudE1ldGFkYXRhUmVxdWVzdBpDLmRvdGNvcmUuYXBwbGljYXRpb24udjEuVXBkYXRlQXBwbGljYXRpb25EZXBsb3ltZW50TWV0YWRhdGFSZXNwb25zZWIGcHJvdG8z", [file_dotcore_application_v1_deployment, file_dotcore_catalog_v1_parameter, file_dotcore_deployment_v1_placement, file_google_protobuf_field_mask, file_google_protobuf_timestamp]); + fileDesc("CiRkb3Rjb3JlL2FwcGxpY2F0aW9uL3YxL3NlcnZpY2UucHJvdG8SFmRvdGNvcmUuYXBwbGljYXRpb24udjEiNwodTGlzdEFwcGxpY2F0aW9uVGFyZ2V0c1JlcXVlc3QSFgoOYXBwbGljYXRpb25faWQYASABKAkiYgoeTGlzdEFwcGxpY2F0aW9uVGFyZ2V0c1Jlc3BvbnNlEkAKB3RhcmdldHMYASADKAsyLy5kb3Rjb3JlLmFwcGxpY2F0aW9uLnYxLkFwcGxpY2F0aW9uVGFyZ2V0T3B0aW9uIrIBChdBcHBsaWNhdGlvblRhcmdldE9wdGlvbhI1CgZ0YXJnZXQYASABKAsyJS5kb3Rjb3JlLmRlcGxveW1lbnQudjEuRXhhY3RQbGFjZW1lbnQSGgoSbWF0Y2hlc19wcmVmZXJlbmNlGAIgASgIEiYKGWFwcGxpY2F0aW9uX2RlcGxveW1lbnRfaWQYAyABKAlIAIgBAUIcChpfYXBwbGljYXRpb25fZGVwbG95bWVudF9pZCIjCiFMaXN0QXBwbGljYXRpb25EZXBsb3ltZW50c1JlcXVlc3QiaAoiTGlzdEFwcGxpY2F0aW9uRGVwbG95bWVudHNSZXNwb25zZRJCCgtkZXBsb3ltZW50cxgBIAMoCzItLmRvdGNvcmUuYXBwbGljYXRpb24udjEuQXBwbGljYXRpb25EZXBsb3ltZW50IkQKH0dldEFwcGxpY2F0aW9uRGVwbG95bWVudFJlcXVlc3QSIQoZYXBwbGljYXRpb25fZGVwbG95bWVudF9pZBgBIAEoCSJlCiBHZXRBcHBsaWNhdGlvbkRlcGxveW1lbnRSZXNwb25zZRJBCgpkZXBsb3ltZW50GAEgASgLMi0uZG90Y29yZS5hcHBsaWNhdGlvbi52MS5BcHBsaWNhdGlvbkRlcGxveW1lbnQi4wIKIkNyZWF0ZUFwcGxpY2F0aW9uRGVwbG95bWVudFJlcXVlc3QSFgoOYXBwbGljYXRpb25faWQYASABKAkSOAoJcGxhY2VtZW50GAIgASgLMiUuZG90Y29yZS5kZXBsb3ltZW50LnYxLkV4YWN0UGxhY2VtZW50Eh8KEmluZmVyZW5jZV9yb3V0ZV9pZBgDIAEoCUgAiAEBEisKI2V4cGVjdGVkX2luZmVyZW5jZV9yb3V0ZV9nZW5lcmF0aW9uGAQgASgEEigKG2FwcGxpY2F0aW9uX2RlcGxveW1lbnRfbmFtZRgGIAEoCUgBiAEBEjwKEHBhcmFtZXRlcl92YWx1ZXMYBSADKAsyIi5kb3Rjb3JlLmNhdGFsb2cudjEuUGFyYW1ldGVyVmFsdWVCFQoTX2luZmVyZW5jZV9yb3V0ZV9pZEIeChxfYXBwbGljYXRpb25fZGVwbG95bWVudF9uYW1lImgKI0NyZWF0ZUFwcGxpY2F0aW9uRGVwbG95bWVudFJlc3BvbnNlEkEKCmRlcGxveW1lbnQYASABKAsyLS5kb3Rjb3JlLmFwcGxpY2F0aW9uLnYxLkFwcGxpY2F0aW9uRGVwbG95bWVudCJHCiJSZW1vdmVBcHBsaWNhdGlvbkRlcGxveW1lbnRSZXF1ZXN0EiEKGWFwcGxpY2F0aW9uX2RlcGxveW1lbnRfaWQYASABKAkiaAojUmVtb3ZlQXBwbGljYXRpb25EZXBsb3ltZW50UmVzcG9uc2USQQoKZGVwbG95bWVudBgBIAEoCzItLmRvdGNvcmUuYXBwbGljYXRpb24udjEuQXBwbGljYXRpb25EZXBsb3ltZW50IjsKFlBhdXNlRGVwbG95bWVudFJlcXVlc3QSIQoZYXBwbGljYXRpb25fZGVwbG95bWVudF9pZBgBIAEoCSJcChdQYXVzZURlcGxveW1lbnRSZXNwb25zZRJBCgpkZXBsb3ltZW50GAEgASgLMi0uZG90Y29yZS5hcHBsaWNhdGlvbi52MS5BcHBsaWNhdGlvbkRlcGxveW1lbnQiPAoXUmVzdW1lRGVwbG95bWVudFJlcXVlc3QSIQoZYXBwbGljYXRpb25fZGVwbG95bWVudF9pZBgBIAEoCSJdChhSZXN1bWVEZXBsb3ltZW50UmVzcG9uc2USQQoKZGVwbG95bWVudBgBIAEoCzItLmRvdGNvcmUuYXBwbGljYXRpb24udjEuQXBwbGljYXRpb25EZXBsb3ltZW50IrcBChlSZWFkRGVwbG95bWVudExvZ3NSZXF1ZXN0EiEKGWFwcGxpY2F0aW9uX2RlcGxveW1lbnRfaWQYASABKAkSFAoMc291cmNlX25hbWVzGAIgAygJEhEKCXBhZ2Vfc2l6ZRgDIAEoDRI5ChBiZWZvcmVfdGltZXN0YW1wGAQgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcEgAiAEBQhMKEV9iZWZvcmVfdGltZXN0YW1wIqYDChpSZWFkRGVwbG95bWVudExvZ3NSZXNwb25zZRI8Cgdzb3VyY2VzGAEgAygLMisuZG90Y29yZS5hcHBsaWNhdGlvbi52MS5EZXBsb3ltZW50TG9nU291cmNlEjsKB2VudHJpZXMYAiADKAsyKi5kb3Rjb3JlLmFwcGxpY2F0aW9uLnYxLkRlcGxveW1lbnRMb2dFbnRyeRI+ChVuZXh0X2JlZm9yZV90aW1lc3RhbXAYAyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wSACIAQESSAoNaGlzdG9yeV9zdGF0ZRgEIAEoDjIxLmRvdGNvcmUuYXBwbGljYXRpb24udjEuRGVwbG95bWVudExvZ0hpc3RvcnlTdGF0ZRIaChJyZXNwb25zZV90cnVuY2F0ZWQYBSABKAgSTQoQbGF0ZXN0X3Bvc2l0aW9ucxgGIAMoCzIzLmRvdGNvcmUuYXBwbGljYXRpb24udjEuRGVwbG95bWVudExvZ1Jlc3VtZVBvc2l0aW9uQhgKFl9uZXh0X2JlZm9yZV90aW1lc3RhbXAipQEKG0ZvbGxvd0RlcGxveW1lbnRMb2dzUmVxdWVzdBIhChlhcHBsaWNhdGlvbl9kZXBsb3ltZW50X2lkGAEgASgJEhQKDHNvdXJjZV9uYW1lcxgCIAMoCRJNChByZXN1bWVfcG9zaXRpb25zGAMgAygLMjMuZG90Y29yZS5hcHBsaWNhdGlvbi52MS5EZXBsb3ltZW50TG9nUmVzdW1lUG9zaXRpb24irgEKHEZvbGxvd0RlcGxveW1lbnRMb2dzUmVzcG9uc2USSAoMc291cmNlX2V2ZW50GAEgASgLMjAuZG90Y29yZS5hcHBsaWNhdGlvbi52MS5EZXBsb3ltZW50TG9nU291cmNlRXZlbnRIABI7CgVlbnRyeRgCIAEoCzIqLmRvdGNvcmUuYXBwbGljYXRpb24udjEuRGVwbG95bWVudExvZ0VudHJ5SABCBwoFZXZlbnQigQEKE0RlcGxveW1lbnRMb2dTb3VyY2USEwoLc291cmNlX25hbWUYASABKAkSFAoMc291cmNlX2xhYmVsGAIgASgJEj8KBXN0YXRlGAMgASgOMjAuZG90Y29yZS5hcHBsaWNhdGlvbi52MS5EZXBsb3ltZW50TG9nU291cmNlU3RhdGUizwEKEkRlcGxveW1lbnRMb2dFbnRyeRITCgtzb3VyY2VfbmFtZRgBIAEoCRIUCgxzb3VyY2VfbGFiZWwYAiABKAkSOwoGc3RyZWFtGAMgASgOMisuZG90Y29yZS5hcHBsaWNhdGlvbi52MS5EZXBsb3ltZW50TG9nU3RyZWFtEi0KCXRpbWVzdGFtcBgEIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASDwoHY29udGVudBgFIAEoDBIRCgl0cnVuY2F0ZWQYBiABKAgiaAobRGVwbG95bWVudExvZ1Jlc3VtZVBvc2l0aW9uEhMKC3NvdXJjZV9uYW1lGAEgASgJEjQKEGxhdGVzdF90aW1lc3RhbXAYAiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIoYBChhEZXBsb3ltZW50TG9nU291cmNlRXZlbnQSEwoLc291cmNlX25hbWUYASABKAkSFAoMc291cmNlX2xhYmVsGAIgASgJEj8KBXN0YXRlGAMgASgOMjAuZG90Y29yZS5hcHBsaWNhdGlvbi52MS5EZXBsb3ltZW50TG9nU291cmNlU3RhdGUiyQEKKlVwZGF0ZUFwcGxpY2F0aW9uRGVwbG95bWVudE1ldGFkYXRhUmVxdWVzdBIhChlhcHBsaWNhdGlvbl9kZXBsb3ltZW50X2lkGAEgASgJEkcKCG1ldGFkYXRhGAIgASgLMjUuZG90Y29yZS5hcHBsaWNhdGlvbi52MS5BcHBsaWNhdGlvbkRlcGxveW1lbnRNZXRhZGF0YRIvCgt1cGRhdGVfbWFzaxgDIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5GaWVsZE1hc2siSwodQXBwbGljYXRpb25EZXBsb3ltZW50TWV0YWRhdGESGQoMZGlzcGxheV9uYW1lGAEgASgJSACIAQFCDwoNX2Rpc3BsYXlfbmFtZSJwCitVcGRhdGVBcHBsaWNhdGlvbkRlcGxveW1lbnRNZXRhZGF0YVJlc3BvbnNlEkEKCmRlcGxveW1lbnQYASABKAsyLS5kb3Rjb3JlLmFwcGxpY2F0aW9uLnYxLkFwcGxpY2F0aW9uRGVwbG95bWVudCq+AQoZRGVwbG95bWVudExvZ0hpc3RvcnlTdGF0ZRIsCihERVBMT1lNRU5UX0xPR19ISVNUT1JZX1NUQVRFX1VOU1BFQ0lGSUVEEAASOAo0REVQTE9ZTUVOVF9MT0dfSElTVE9SWV9TVEFURV9PTERFUl9PVVRQVVRfTUFZX1JFTUFJThABEjkKNURFUExPWU1FTlRfTE9HX0hJU1RPUllfU1RBVEVfTk9fT0xERVJfT1VUUFVUX09CU0VSVkVEEAIqxgEKGERlcGxveW1lbnRMb2dTb3VyY2VTdGF0ZRIrCidERVBMT1lNRU5UX0xPR19TT1VSQ0VfU1RBVEVfVU5TUEVDSUZJRUQQABIpCiVERVBMT1lNRU5UX0xPR19TT1VSQ0VfU1RBVEVfQVZBSUxBQkxFEAESKwonREVQTE9ZTUVOVF9MT0dfU09VUkNFX1NUQVRFX1VOQVZBSUxBQkxFEAISJQohREVQTE9ZTUVOVF9MT0dfU09VUkNFX1NUQVRFX0VOREVEEAMqgAEKE0RlcGxveW1lbnRMb2dTdHJlYW0SJQohREVQTE9ZTUVOVF9MT0dfU1RSRUFNX1VOU1BFQ0lGSUVEEAASIAocREVQTE9ZTUVOVF9MT0dfU1RSRUFNX1NURE9VVBABEiAKHERFUExPWU1FTlRfTE9HX1NUUkVBTV9TVERFUlIQAjKfCwocQXBwbGljYXRpb25EZXBsb3ltZW50U2VydmljZRKTAQoaTGlzdEFwcGxpY2F0aW9uRGVwbG95bWVudHMSOS5kb3Rjb3JlLmFwcGxpY2F0aW9uLnYxLkxpc3RBcHBsaWNhdGlvbkRlcGxveW1lbnRzUmVxdWVzdBo6LmRvdGNvcmUuYXBwbGljYXRpb24udjEuTGlzdEFwcGxpY2F0aW9uRGVwbG95bWVudHNSZXNwb25zZRKNAQoYR2V0QXBwbGljYXRpb25EZXBsb3ltZW50EjcuZG90Y29yZS5hcHBsaWNhdGlvbi52MS5HZXRBcHBsaWNhdGlvbkRlcGxveW1lbnRSZXF1ZXN0GjguZG90Y29yZS5hcHBsaWNhdGlvbi52MS5HZXRBcHBsaWNhdGlvbkRlcGxveW1lbnRSZXNwb25zZRKWAQobQ3JlYXRlQXBwbGljYXRpb25EZXBsb3ltZW50EjouZG90Y29yZS5hcHBsaWNhdGlvbi52MS5DcmVhdGVBcHBsaWNhdGlvbkRlcGxveW1lbnRSZXF1ZXN0GjsuZG90Y29yZS5hcHBsaWNhdGlvbi52MS5DcmVhdGVBcHBsaWNhdGlvbkRlcGxveW1lbnRSZXNwb25zZRKWAQobUmVtb3ZlQXBwbGljYXRpb25EZXBsb3ltZW50EjouZG90Y29yZS5hcHBsaWNhdGlvbi52MS5SZW1vdmVBcHBsaWNhdGlvbkRlcGxveW1lbnRSZXF1ZXN0GjsuZG90Y29yZS5hcHBsaWNhdGlvbi52MS5SZW1vdmVBcHBsaWNhdGlvbkRlcGxveW1lbnRSZXNwb25zZRJyCg9QYXVzZURlcGxveW1lbnQSLi5kb3Rjb3JlLmFwcGxpY2F0aW9uLnYxLlBhdXNlRGVwbG95bWVudFJlcXVlc3QaLy5kb3Rjb3JlLmFwcGxpY2F0aW9uLnYxLlBhdXNlRGVwbG95bWVudFJlc3BvbnNlEnUKEFJlc3VtZURlcGxveW1lbnQSLy5kb3Rjb3JlLmFwcGxpY2F0aW9uLnYxLlJlc3VtZURlcGxveW1lbnRSZXF1ZXN0GjAuZG90Y29yZS5hcHBsaWNhdGlvbi52MS5SZXN1bWVEZXBsb3ltZW50UmVzcG9uc2USewoSUmVhZERlcGxveW1lbnRMb2dzEjEuZG90Y29yZS5hcHBsaWNhdGlvbi52MS5SZWFkRGVwbG95bWVudExvZ3NSZXF1ZXN0GjIuZG90Y29yZS5hcHBsaWNhdGlvbi52MS5SZWFkRGVwbG95bWVudExvZ3NSZXNwb25zZRKDAQoURm9sbG93RGVwbG95bWVudExvZ3MSMy5kb3Rjb3JlLmFwcGxpY2F0aW9uLnYxLkZvbGxvd0RlcGxveW1lbnRMb2dzUmVxdWVzdBo0LmRvdGNvcmUuYXBwbGljYXRpb24udjEuRm9sbG93RGVwbG95bWVudExvZ3NSZXNwb25zZTABEq4BCiNVcGRhdGVBcHBsaWNhdGlvbkRlcGxveW1lbnRNZXRhZGF0YRJCLmRvdGNvcmUuYXBwbGljYXRpb24udjEuVXBkYXRlQXBwbGljYXRpb25EZXBsb3ltZW50TWV0YWRhdGFSZXF1ZXN0GkMuZG90Y29yZS5hcHBsaWNhdGlvbi52MS5VcGRhdGVBcHBsaWNhdGlvbkRlcGxveW1lbnRNZXRhZGF0YVJlc3BvbnNlEocBChZMaXN0QXBwbGljYXRpb25UYXJnZXRzEjUuZG90Y29yZS5hcHBsaWNhdGlvbi52MS5MaXN0QXBwbGljYXRpb25UYXJnZXRzUmVxdWVzdBo2LmRvdGNvcmUuYXBwbGljYXRpb24udjEuTGlzdEFwcGxpY2F0aW9uVGFyZ2V0c1Jlc3BvbnNlYgZwcm90bzM", [file_dotcore_application_v1_deployment, file_dotcore_catalog_v1_parameter, file_dotcore_deployment_v1_placement, file_google_protobuf_field_mask, file_google_protobuf_timestamp]); + +/** + * Asks where one catalog application can run now. A fast, advisory read of current target observations and + * deployments; it never changes deployments, admission or placement. + * + * @generated from message dotcore.application.v1.ListApplicationTargetsRequest + */ +export type ListApplicationTargetsRequest = Message<"dotcore.application.v1.ListApplicationTargetsRequest"> & { + /** + * Immutable catalog application release identifier. + * + * @generated from field: string application_id = 1; + */ + applicationId: string; +}; + +/** + * Describes the message dotcore.application.v1.ListApplicationTargetsRequest. + * Use `create(ListApplicationTargetsRequestSchema)` to create a new message. + */ +export const ListApplicationTargetsRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_dotcore_application_v1_service, 0); + +/** + * The application targets that can take the application now, best first; empty when none can. + * + * @generated from message dotcore.application.v1.ListApplicationTargetsResponse + */ +export type ListApplicationTargetsResponse = Message<"dotcore.application.v1.ListApplicationTargetsResponse"> & { + /** + * Targets ordered with deployed ones first, then those matching the application's preferred accelerators. + * + * @generated from field: repeated dotcore.application.v1.ApplicationTargetOption targets = 1; + */ + targets: ApplicationTargetOption[]; +}; + +/** + * Describes the message dotcore.application.v1.ListApplicationTargetsResponse. + * Use `create(ListApplicationTargetsResponseSchema)` to create a new message. + */ +export const ListApplicationTargetsResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_dotcore_application_v1_service, 1); + +/** + * One application target that can take the application. + * + * @generated from message dotcore.application.v1.ApplicationTargetOption + */ +export type ApplicationTargetOption = Message<"dotcore.application.v1.ApplicationTargetOption"> & { + /** + * Exact placement of the application target. + * + * @generated from field: dotcore.deployment.v1.ExactPlacement target = 1; + */ + target?: ExactPlacement; + + /** + * Whether the target holds accelerators of a vendor the application prefers; true for an application with no preference. + * + * @generated from field: bool matches_preference = 2; + */ + matchesPreference: boolean; + + /** + * The application's running or starting deployment on the target; absent when there is none. + * + * @generated from field: optional string application_deployment_id = 3; + */ + applicationDeploymentId?: string; +}; + +/** + * Describes the message dotcore.application.v1.ApplicationTargetOption. + * Use `create(ApplicationTargetOptionSchema)` to create a new message. + */ +export const ApplicationTargetOptionSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_dotcore_application_v1_service, 2); /** * Requests the controller-owned application deployments. @@ -33,7 +111,7 @@ export type ListApplicationDeploymentsRequest = Message<"dotcore.application.v1. * Use `create(ListApplicationDeploymentsRequestSchema)` to create a new message. */ export const ListApplicationDeploymentsRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_dotcore_application_v1_service, 0); + messageDesc(file_dotcore_application_v1_service, 3); /** * Returns controller-owned application deployments without catalog resolution. @@ -54,7 +132,7 @@ export type ListApplicationDeploymentsResponse = Message<"dotcore.application.v1 * Use `create(ListApplicationDeploymentsResponseSchema)` to create a new message. */ export const ListApplicationDeploymentsResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_dotcore_application_v1_service, 1); + messageDesc(file_dotcore_application_v1_service, 4); /** * Requests one controller-owned application deployment. @@ -75,7 +153,7 @@ export type GetApplicationDeploymentRequest = Message<"dotcore.application.v1.Ge * Use `create(GetApplicationDeploymentRequestSchema)` to create a new message. */ export const GetApplicationDeploymentRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_dotcore_application_v1_service, 2); + messageDesc(file_dotcore_application_v1_service, 5); /** * Returns one controller-owned application deployment. @@ -96,7 +174,7 @@ export type GetApplicationDeploymentResponse = Message<"dotcore.application.v1.G * Use `create(GetApplicationDeploymentResponseSchema)` to create a new message. */ export const GetApplicationDeploymentResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_dotcore_application_v1_service, 3); + messageDesc(file_dotcore_application_v1_service, 6); /** * Requests creation of one deployment from an immutable catalog application. @@ -152,7 +230,7 @@ export type CreateApplicationDeploymentRequest = Message<"dotcore.application.v1 * Use `create(CreateApplicationDeploymentRequestSchema)` to create a new message. */ export const CreateApplicationDeploymentRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_dotcore_application_v1_service, 4); + messageDesc(file_dotcore_application_v1_service, 7); /** * Returns the accepted controller-owned application deployment. @@ -173,7 +251,7 @@ export type CreateApplicationDeploymentResponse = Message<"dotcore.application.v * Use `create(CreateApplicationDeploymentResponseSchema)` to create a new message. */ export const CreateApplicationDeploymentResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_dotcore_application_v1_service, 5); + messageDesc(file_dotcore_application_v1_service, 8); /** * Requests removal of one controller-owned application deployment. @@ -194,7 +272,7 @@ export type RemoveApplicationDeploymentRequest = Message<"dotcore.application.v1 * Use `create(RemoveApplicationDeploymentRequestSchema)` to create a new message. */ export const RemoveApplicationDeploymentRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_dotcore_application_v1_service, 6); + messageDesc(file_dotcore_application_v1_service, 9); /** * Returns the deployment after the remove request is accepted. @@ -215,7 +293,7 @@ export type RemoveApplicationDeploymentResponse = Message<"dotcore.application.v * Use `create(RemoveApplicationDeploymentResponseSchema)` to create a new message. */ export const RemoveApplicationDeploymentResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_dotcore_application_v1_service, 7); + messageDesc(file_dotcore_application_v1_service, 10); /** * Requests pausing one application deployment while retaining its resources. @@ -236,7 +314,7 @@ export type PauseDeploymentRequest = Message<"dotcore.application.v1.PauseDeploy * Use `create(PauseDeploymentRequestSchema)` to create a new message. */ export const PauseDeploymentRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_dotcore_application_v1_service, 8); + messageDesc(file_dotcore_application_v1_service, 11); /** * Returns the deployment after the pause request is accepted. @@ -257,7 +335,7 @@ export type PauseDeploymentResponse = Message<"dotcore.application.v1.PauseDeplo * Use `create(PauseDeploymentResponseSchema)` to create a new message. */ export const PauseDeploymentResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_dotcore_application_v1_service, 9); + messageDesc(file_dotcore_application_v1_service, 12); /** * Requests resuming one paused application deployment. @@ -278,7 +356,7 @@ export type ResumeDeploymentRequest = Message<"dotcore.application.v1.ResumeDepl * Use `create(ResumeDeploymentRequestSchema)` to create a new message. */ export const ResumeDeploymentRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_dotcore_application_v1_service, 10); + messageDesc(file_dotcore_application_v1_service, 13); /** * Returns the deployment after the resume request is accepted. @@ -299,7 +377,7 @@ export type ResumeDeploymentResponse = Message<"dotcore.application.v1.ResumeDep * Use `create(ResumeDeploymentResponseSchema)` to create a new message. */ export const ResumeDeploymentResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_dotcore_application_v1_service, 11); + messageDesc(file_dotcore_application_v1_service, 14); /** * Requests a bounded retained-log page from the current application workload sources. @@ -341,7 +419,7 @@ export type ReadDeploymentLogsRequest = Message<"dotcore.application.v1.ReadDepl * Use `create(ReadDeploymentLogsRequestSchema)` to create a new message. */ export const ReadDeploymentLogsRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_dotcore_application_v1_service, 12); + messageDesc(file_dotcore_application_v1_service, 15); /** * Returns one bounded aggregate page and point-in-time source availability. @@ -397,7 +475,7 @@ export type ReadDeploymentLogsResponse = Message<"dotcore.application.v1.ReadDep * Use `create(ReadDeploymentLogsResponseSchema)` to create a new message. */ export const ReadDeploymentLogsResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_dotcore_application_v1_service, 13); + messageDesc(file_dotcore_application_v1_service, 16); /** * Requests a live best-effort follow of the sources of one application deployment. A follow @@ -436,7 +514,7 @@ export type FollowDeploymentLogsRequest = Message<"dotcore.application.v1.Follow * Use `create(FollowDeploymentLogsRequestSchema)` to create a new message. */ export const FollowDeploymentLogsRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_dotcore_application_v1_service, 14); + messageDesc(file_dotcore_application_v1_service, 17); /** * Returns one source-status transition or one line from a live follow. @@ -471,7 +549,7 @@ export type FollowDeploymentLogsResponse = Message<"dotcore.application.v1.Follo * Use `create(FollowDeploymentLogsResponseSchema)` to create a new message. */ export const FollowDeploymentLogsResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_dotcore_application_v1_service, 15); + messageDesc(file_dotcore_application_v1_service, 18); /** * Describes one public source and its point-in-time availability. @@ -506,7 +584,7 @@ export type DeploymentLogSource = Message<"dotcore.application.v1.DeploymentLogS * Use `create(DeploymentLogSourceSchema)` to create a new message. */ export const DeploymentLogSourceSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_dotcore_application_v1_service, 16); + messageDesc(file_dotcore_application_v1_service, 19); /** * Carries one normalized visual line without runtime-native identity. @@ -562,7 +640,7 @@ export type DeploymentLogEntry = Message<"dotcore.application.v1.DeploymentLogEn * Use `create(DeploymentLogEntrySchema)` to create a new message. */ export const DeploymentLogEntrySchema: GenMessage = /*@__PURE__*/ - messageDesc(file_dotcore_application_v1_service, 17); + messageDesc(file_dotcore_application_v1_service, 20); /** * Carries the full-precision timestamp of the latest entry delivered for one public source. @@ -591,7 +669,7 @@ export type DeploymentLogResumePosition = Message<"dotcore.application.v1.Deploy * Use `create(DeploymentLogResumePositionSchema)` to create a new message. */ export const DeploymentLogResumePositionSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_dotcore_application_v1_service, 18); + messageDesc(file_dotcore_application_v1_service, 21); /** * Reports a public source becoming available, unavailable, or ended. @@ -626,7 +704,7 @@ export type DeploymentLogSourceEvent = Message<"dotcore.application.v1.Deploymen * Use `create(DeploymentLogSourceEventSchema)` to create a new message. */ export const DeploymentLogSourceEventSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_dotcore_application_v1_service, 19); + messageDesc(file_dotcore_application_v1_service, 22); /** * Requests a typed metadata update to one application deployment. @@ -661,7 +739,7 @@ export type UpdateApplicationDeploymentMetadataRequest = Message<"dotcore.applic * Use `create(UpdateApplicationDeploymentMetadataRequestSchema)` to create a new message. */ export const UpdateApplicationDeploymentMetadataRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_dotcore_application_v1_service, 20); + messageDesc(file_dotcore_application_v1_service, 23); /** * Supplies writable presentation metadata for an application deployment update. @@ -682,7 +760,7 @@ export type ApplicationDeploymentMetadata = Message<"dotcore.application.v1.Appl * Use `create(ApplicationDeploymentMetadataSchema)` to create a new message. */ export const ApplicationDeploymentMetadataSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_dotcore_application_v1_service, 21); + messageDesc(file_dotcore_application_v1_service, 24); /** * Returns the accepted deployment after a metadata update is accepted. @@ -703,7 +781,7 @@ export type UpdateApplicationDeploymentMetadataResponse = Message<"dotcore.appli * Use `create(UpdateApplicationDeploymentMetadataResponseSchema)` to create a new message. */ export const UpdateApplicationDeploymentMetadataResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_dotcore_application_v1_service, 22); + messageDesc(file_dotcore_application_v1_service, 25); /** * States whether older matching output was observed during one bounded read. @@ -892,6 +970,14 @@ export const ApplicationDeploymentService: GenService<{ input: typeof UpdateApplicationDeploymentMetadataRequestSchema; output: typeof UpdateApplicationDeploymentMetadataResponseSchema; }, + /** + * @generated from rpc dotcore.application.v1.ApplicationDeploymentService.ListApplicationTargets + */ + listApplicationTargets: { + methodKind: "unary"; + input: typeof ListApplicationTargetsRequestSchema; + output: typeof ListApplicationTargetsResponseSchema; + }, }> = /*@__PURE__*/ serviceDesc(file_dotcore_application_v1_service, 0); diff --git a/frontend/src/gen/dotcore/catalog/v1/application_pb.ts b/frontend/src/gen/dotcore/catalog/v1/application_pb.ts index 2866da95..8d9264c9 100644 --- a/frontend/src/gen/dotcore/catalog/v1/application_pb.ts +++ b/frontend/src/gen/dotcore/catalog/v1/application_pb.ts @@ -10,6 +10,8 @@ import type { OciImageReference, OciPlatform } 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 { UseCase } from "./recommendation_pb"; +import { file_dotcore_catalog_v1_recommendation } from "./recommendation_pb"; import type { StorageBinding } from "./storage_pb"; import { file_dotcore_catalog_v1_storage } from "./storage_pb"; import type { Message } from "@bufbuild/protobuf"; @@ -18,7 +20,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("CiRkb3Rjb3JlL2NhdGFsb2cvdjEvYXBwbGljYXRpb24ucHJvdG8SEmRvdGNvcmUuY2F0YWxvZy52MSL1AQoLQXBwbGljYXRpb24SFgoOYXBwbGljYXRpb25faWQYASABKAkSFAoMZGlzcGxheV9uYW1lGAIgASgJEhMKC2Rlc2NyaXB0aW9uGAUgASgJEg0KBW5vdGVzGAYgASgJEg8KB3ZlcnNpb24YAyABKAkSMwoFZ3JhcGgYBCABKAsyJC5kb3Rjb3JlLmNhdGFsb2cudjEuQXBwbGljYXRpb25HcmFwaBIuCgl1c2VfY2FzZXMYByADKA4yGy5kb3Rjb3JlLmNhdGFsb2cudjEuVXNlQ2FzZRIeChZwcmVmZXJyZWRfYWNjZWxlcmF0b3JzGAggAygJItQBChBBcHBsaWNhdGlvbkdyYXBoEjgKCHNlcnZpY2VzGAEgAygLMiYuZG90Y29yZS5jYXRhbG9nLnYxLkFwcGxpY2F0aW9uU2VydmljZRJIChBwcmltYXJ5X2VuZHBvaW50GAIgASgLMi4uZG90Y29yZS5jYXRhbG9nLnYxLkFwcGxpY2F0aW9uUHJpbWFyeUVuZHBvaW50EjwKCnBhcmFtZXRlcnMYAyADKAsyKC5kb3Rjb3JlLmNhdGFsb2cudjEuQXBwbGljYXRpb25QYXJhbWV0ZXIiZQoUQXBwbGljYXRpb25QYXJhbWV0ZXISOwoKZGVmaW5pdGlvbhgBIAEoCzInLmRvdGNvcmUuY2F0YWxvZy52MS5QYXJhbWV0ZXJEZWZpbml0aW9uEhAKCGFkdmFuY2VkGAIgASgIIrEEChJBcHBsaWNhdGlvblNlcnZpY2USFAoMc2VydmljZV9uYW1lGAEgASgJEkAKCGFydGlmYWN0GAIgASgLMi4uZG90Y29yZS5jYXRhbG9nLnYxLkFwcGxpY2F0aW9uU2VydmljZUFydGlmYWN0Eg8KB2NvbW1hbmQYAyADKAkSEQoJYXJndW1lbnRzGAQgAygJEjkKBXBvcnRzGAUgAygLMiouZG90Y29yZS5jYXRhbG9nLnYxLkFwcGxpY2F0aW9uU2VydmljZVBvcnQSUwoWY29uZmlndXJhdGlvbl9iaW5kaW5ncxgGIAMoCzIzLmRvdGNvcmUuY2F0YWxvZy52MS5BcHBsaWNhdGlvbkNvbmZpZ3VyYXRpb25CaW5kaW5nEjwKEHN0b3JhZ2VfYmluZGluZ3MYByADKAsyIi5kb3Rjb3JlLmNhdGFsb2cudjEuU3RvcmFnZUJpbmRpbmcSRQoPc2VjcmV0X2JpbmRpbmdzGAggAygLMiwuZG90Y29yZS5jYXRhbG9nLnYxLkFwcGxpY2F0aW9uU2VjcmV0QmluZGluZxJGCgxkZXBlbmRlbmNpZXMYCSADKAsyMC5kb3Rjb3JlLmNhdGFsb2cudjEuQXBwbGljYXRpb25TZXJ2aWNlRGVwZW5kZW5jeRJCCglyZWFkaW5lc3MYCiABKAsyLy5kb3Rjb3JlLmNhdGFsb2cudjEuQXBwbGljYXRpb25TZXJ2aWNlUmVhZGluZXNzIsgBChpBcHBsaWNhdGlvblNlcnZpY2VBcnRpZmFjdBI0CgNvY2kYASABKAsyJS5kb3Rjb3JlLmNhdGFsb2cudjEuT2NpSW1hZ2VSZWZlcmVuY2VIABIuCgNuaXgYAyABKAsyHy5kb3Rjb3JlLmNhdGFsb2cudjEuTml4QXJ0aWZhY3RIABI8ChNzdXBwb3J0ZWRfcGxhdGZvcm1zGAIgAygLMh8uZG90Y29yZS5jYXRhbG9nLnYxLk9jaVBsYXRmb3JtQgYKBGtpbmQiQAoWQXBwbGljYXRpb25TZXJ2aWNlUG9ydBIRCglwb3J0X25hbWUYASABKAkSEwoLcG9ydF9udW1iZXIYAiABKA0iewocQXBwbGljYXRpb25TZXJ2aWNlRGVwZW5kZW5jeRIUCgxzZXJ2aWNlX25hbWUYASABKAkSRQoJY29uZGl0aW9uGAIgASgOMjIuZG90Y29yZS5jYXRhbG9nLnYxLkFwcGxpY2F0aW9uRGVwZW5kZW5jeUNvbmRpdGlvbiJoChtBcHBsaWNhdGlvblNlcnZpY2VSZWFkaW5lc3MSQAoIaHR0cF9nZXQYASABKAsyLC5kb3Rjb3JlLmNhdGFsb2cudjEuQXBwbGljYXRpb25IdHRwUmVhZGluZXNzSABCBwoFY2hlY2siOwoYQXBwbGljYXRpb25IdHRwUmVhZGluZXNzEhEKCXBvcnRfbmFtZRgBIAEoCRIMCgRwYXRoGAIgASgJIu4BCh9BcHBsaWNhdGlvbkNvbmZpZ3VyYXRpb25CaW5kaW5nEhQKDGJpbmRpbmdfbmFtZRgBIAEoCRIcChRlbnZpcm9ubWVudF92YXJpYWJsZRgCIAEoCRIXCg1saXRlcmFsX3ZhbHVlGAMgASgJSAASHQoTcmVzb2x2ZWRfdmFsdWVfbmFtZRgEIAEoCUgAEhYKDHBhcmFtZXRlcl9pZBgGIAEoCUgAEj0KBWlucHV0GAUgASgLMi4uZG90Y29yZS5jYXRhbG9nLnYxLkFwcGxpY2F0aW9uRGVwbG95bWVudElucHV0QggKBnNvdXJjZSJzChpBcHBsaWNhdGlvbkRlcGxveW1lbnRJbnB1dBJNCg9pbmZlcmVuY2Vfcm91dGUYAiABKAsyMi5kb3Rjb3JlLmNhdGFsb2cudjEuQXBwbGljYXRpb25JbmZlcmVuY2VSb3V0ZUlucHV0SABCBgoEa2luZCJtCh5BcHBsaWNhdGlvbkluZmVyZW5jZVJvdXRlSW5wdXQSSwoKcHJvamVjdGlvbhgBIAEoDjI3LmRvdGNvcmUuY2F0YWxvZy52MS5BcHBsaWNhdGlvbkluZmVyZW5jZVJvdXRlUHJvamVjdGlvbiJOChhBcHBsaWNhdGlvblNlY3JldEJpbmRpbmcSFAoMYmluZGluZ19uYW1lGAEgASgJEhwKFGVudmlyb25tZW50X3ZhcmlhYmxlGAIgASgJIogBChpBcHBsaWNhdGlvblByaW1hcnlFbmRwb2ludBIUCgxzZXJ2aWNlX25hbWUYASABKAkSEQoJcG9ydF9uYW1lGAIgASgJEkEKCHByb3RvY29sGAMgASgOMi8uZG90Y29yZS5jYXRhbG9nLnYxLkFwcGxpY2F0aW9uRW5kcG9pbnRQcm90b2NvbCqUAQojQXBwbGljYXRpb25JbmZlcmVuY2VSb3V0ZVByb2plY3Rpb24SNgoyQVBQTElDQVRJT05fSU5GRVJFTkNFX1JPVVRFX1BST0pFQ1RJT05fVU5TUEVDSUZJRUQQABI1CjFBUFBMSUNBVElPTl9JTkZFUkVOQ0VfUk9VVEVfUFJPSkVDVElPTl9ST1VURV9OQU1FEAEqrAEKHkFwcGxpY2F0aW9uRGVwZW5kZW5jeUNvbmRpdGlvbhIwCixBUFBMSUNBVElPTl9ERVBFTkRFTkNZX0NPTkRJVElPTl9VTlNQRUNJRklFRBAAEiwKKEFQUExJQ0FUSU9OX0RFUEVOREVOQ1lfQ09ORElUSU9OX1NUQVJURUQQARIqCiZBUFBMSUNBVElPTl9ERVBFTkRFTkNZX0NPTkRJVElPTl9SRUFEWRACKnQKG0FwcGxpY2F0aW9uRW5kcG9pbnRQcm90b2NvbBItCilBUFBMSUNBVElPTl9FTkRQT0lOVF9QUk9UT0NPTF9VTlNQRUNJRklFRBAAEiYKIkFQUExJQ0FUSU9OX0VORFBPSU5UX1BST1RPQ09MX0hUVFAQAWIGcHJvdG8z", [file_dotcore_catalog_v1_nix, file_dotcore_catalog_v1_oci, file_dotcore_catalog_v1_parameter, file_dotcore_catalog_v1_recommendation, file_dotcore_catalog_v1_storage]); /** * An immutable artifact-manager catalog declaration for an approved application release. @@ -67,6 +69,20 @@ export type Application = Message<"dotcore.catalog.v1.Application"> & { * @generated from field: dotcore.catalog.v1.ApplicationGraph graph = 4; */ graph?: ApplicationGraph; + + /** + * Use cases this application serves; recommendation and display metadata with no admission or placement authority. + * + * @generated from field: repeated dotcore.catalog.v1.UseCase use_cases = 7; + */ + useCases: UseCase[]; + + /** + * Accelerator vendors this application prefers, named as in hardware class selectors; advisory, never used for admission or placement. + * + * @generated from field: repeated string preferred_accelerators = 8; + */ + preferredAccelerators: string[]; }; /** diff --git a/frontend/src/gen/dotcore/catalog/v1/recommendation_pb.ts b/frontend/src/gen/dotcore/catalog/v1/recommendation_pb.ts index a41a2d9f..67567814 100644 --- a/frontend/src/gen/dotcore/catalog/v1/recommendation_pb.ts +++ b/frontend/src/gen/dotcore/catalog/v1/recommendation_pb.ts @@ -9,7 +9,7 @@ import { enumDesc, fileDesc } from "@bufbuild/protobuf/codegenv2"; * Describes the file dotcore/catalog/v1/recommendation.proto. */ export const file_dotcore_catalog_v1_recommendation: GenFile = /*@__PURE__*/ - fileDesc("Cidkb3Rjb3JlL2NhdGFsb2cvdjEvcmVjb21tZW5kYXRpb24ucHJvdG8SEmRvdGNvcmUuY2F0YWxvZy52MSqbAQoHVXNlQ2FzZRIYChRVU0VfQ0FTRV9VTlNQRUNJRklFRBAAEh4KGlVTRV9DQVNFX0NPRElOR19BTkRfQUdFTlRTEAESHgoaVVNFX0NBU0VfR0VORVJBTF9BU1NJU1RBTlQQAhIYChRVU0VfQ0FTRV9UUkFOU0xBVElPThADEhwKGFVTRV9DQVNFX0RFQ0lTSU9OX01BS0lORxAEYgZwcm90bzM"); + fileDesc("Cidkb3Rjb3JlL2NhdGFsb2cvdjEvcmVjb21tZW5kYXRpb24ucHJvdG8SEmRvdGNvcmUuY2F0YWxvZy52MSp0CgdVc2VDYXNlEhgKFFVTRV9DQVNFX1VOU1BFQ0lGSUVEEAASGwoXVVNFX0NBU0VfQ0hBVF9BU1NJU1RBTlQQARITCg9VU0VfQ0FTRV9DT0RJTkcQAhIdChlVU0VfQ0FTRV9JTUFHRV9HRU5FUkFUSU9OEANiBnByb3RvMw"); /** * What an operator runs models for. Distinct from inference request kinds. @@ -22,33 +22,26 @@ export enum UseCase { */ UNSPECIFIED = 0, - /** - * Coding assistants and autonomous agents. - * - * @generated from enum value: USE_CASE_CODING_AND_AGENTS = 1; - */ - CODING_AND_AGENTS = 1, - /** * General chat assistance. * - * @generated from enum value: USE_CASE_GENERAL_ASSISTANT = 2; + * @generated from enum value: USE_CASE_CHAT_ASSISTANT = 1; */ - GENERAL_ASSISTANT = 2, + CHAT_ASSISTANT = 1, /** - * Translating text between languages. + * Coding assistants and autonomous agents. * - * @generated from enum value: USE_CASE_TRANSLATION = 3; + * @generated from enum value: USE_CASE_CODING = 2; */ - TRANSLATION = 3, + CODING = 2, /** - * Typed decisions with per-option probabilities. + * Generating images. * - * @generated from enum value: USE_CASE_DECISION_MAKING = 4; + * @generated from enum value: USE_CASE_IMAGE_GENERATION = 3; */ - DECISION_MAKING = 4, + IMAGE_GENERATION = 3, } /** diff --git a/frontend/src/gen/dotcore/model/v1/service_pb.ts b/frontend/src/gen/dotcore/model/v1/service_pb.ts index ebc6738e..ea87fe23 100644 --- a/frontend/src/gen/dotcore/model/v1/service_pb.ts +++ b/frontend/src/gen/dotcore/model/v1/service_pb.ts @@ -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("Ch5kb3Rjb3JlL21vZGVsL3YxL3NlcnZpY2UucHJvdG8SEGRvdGNvcmUubW9kZWwudjEiHQobTGlzdE1vZGVsRGVwbG95bWVudHNSZXF1ZXN0IlYKHExpc3RNb2RlbERlcGxveW1lbnRzUmVzcG9uc2USNgoLZGVwbG95bWVudHMYASADKAsyIS5kb3Rjb3JlLm1vZGVsLnYxLk1vZGVsRGVwbG95bWVudCK1AgocQ3JlYXRlTW9kZWxEZXBsb3ltZW50UmVxdWVzdBJECgVsb2NhbBgBIAEoCzIzLmRvdGNvcmUubW9kZWwudjEuQ3JlYXRlTG9jYWxNb2RlbERlcGxveW1lbnRSZXF1ZXN0SAASSgoIdXBzdHJlYW0YAiABKAsyNi5kb3Rjb3JlLm1vZGVsLnYxLkNyZWF0ZVVwc3RyZWFtTW9kZWxEZXBsb3ltZW50UmVxdWVzdEgAEjsKCGV4cG9zdXJlGAMgASgLMikuZG90Y29yZS5tb2RlbC52MS5Nb2RlbERlcGxveW1lbnRFeHBvc3VyZRIiChVtb2RlbF9kZXBsb3ltZW50X25hbWUYBCABKAlIAYgBAUIICgZzb3VyY2VCGAoWX21vZGVsX2RlcGxveW1lbnRfbmFtZSLPAQohQ3JlYXRlTG9jYWxNb2RlbERlcGxveW1lbnRSZXF1ZXN0EhAKCG1vZGVsX2lkGAEgASgJEhIKCnByb2ZpbGVfaWQYAiABKAkSOAoJcGxhY2VtZW50GAMgASgLMiUuZG90Y29yZS5kZXBsb3ltZW50LnYxLkV4YWN0UGxhY2VtZW50EkoKDWNvbmZpZ3VyYXRpb24YBCABKAsyMy5kb3Rjb3JlLm1vZGVsLnYxLkxvY2FsTW9kZWxEZXBsb3ltZW50Q29uZmlndXJhdGlvbiLzAQohTG9jYWxNb2RlbERlcGxveW1lbnRDb25maWd1cmF0aW9uEiEKGW1vZGVsX2FydGlmYWN0X2JpbmRpbmdfaWQYASABKAkSSgoXZW5naW5lX3BhcmFtZXRlcl92YWx1ZXMYAiABKAsyKS5kb3Rjb3JlLmNhdGFsb2cudjEuRW5naW5lUGFyYW1ldGVyVmFsdWVzEh4KFmN1c3RvbV9hcmd1bWVudF90b2tlbnMYAyADKAkSPwoQb3B0aW9uX292ZXJyaWRlcxgEIAMoCzIlLmRvdGNvcmUuY2F0YWxvZy52MS5Nb2RlbE9wdGlvblZhbHVlcyKeAQokQ3JlYXRlVXBzdHJlYW1Nb2RlbERlcGxveW1lbnRSZXF1ZXN0EiIKGnVwc3RyZWFtX21vZGVsX3RlbXBsYXRlX2lkGAEgASgJElIKFWRlc2lyZWRfY29uZmlndXJhdGlvbhgCIAEoCzIzLmRvdGNvcmUubW9kZWwudjEuVXBzdHJlYW1Nb2RlbERlc2lyZWRDb25maWd1cmF0aW9uIpgBChdNb2RlbERlcGxveW1lbnRFeHBvc3VyZRI3CgZkaXJlY3QYASABKAsyJS5kb3Rjb3JlLm1vZGVsLnYxLkRpcmVjdFJvdXRlRXhwb3N1cmVIABI8CgZoaWRkZW4YAiABKAsyKi5kb3Rjb3JlLm1vZGVsLnYxLkhpZGRlbkRlcGxveW1lbnRFeHBvc3VyZUgAQgYKBGtpbmQiKQoTRGlyZWN0Um91dGVFeHBvc3VyZRISCgpyb3V0ZV9uYW1lGAEgASgJIhoKGEhpZGRlbkRlcGxveW1lbnRFeHBvc3VyZSJWCh1DcmVhdGVNb2RlbERlcGxveW1lbnRSZXNwb25zZRI1CgpkZXBsb3ltZW50GAEgASgLMiEuZG90Y29yZS5tb2RlbC52MS5Nb2RlbERlcGxveW1lbnQiaAoiUHJldmlld0xvY2FsTW9kZWxEZXBsb3ltZW50UmVxdWVzdBJCCgVsb2NhbBgBIAEoCzIzLmRvdGNvcmUubW9kZWwudjEuQ3JlYXRlTG9jYWxNb2RlbERlcGxveW1lbnRSZXF1ZXN0IrwBCiNQcmV2aWV3TG9jYWxNb2RlbERlcGxveW1lbnRSZXNwb25zZRJRChFyZXNvbHZlZF9zbmFwc2hvdBgBIAEoCzI2LmRvdGNvcmUubW9kZWwudjEuUmVzb2x2ZWRMb2NhbE1vZGVsRGVwbG95bWVudFNuYXBzaG90EkIKCmFkdmlzb3JpZXMYAiADKA4yLi5kb3Rjb3JlLm1vZGVsLnYxLkxvY2FsTW9kZWxEZXBsb3ltZW50QWR2aXNvcnki0AEKIkVzdGltYXRlTG9jYWxNb2RlbFJlc291cmNlc1JlcXVlc3QSEAoIbW9kZWxfaWQYASABKAkSEgoKcHJvZmlsZV9pZBgCIAEoCRJKCg1jb25maWd1cmF0aW9uGAMgASgLMjMuZG90Y29yZS5tb2RlbC52MS5Mb2NhbE1vZGVsRGVwbG95bWVudENvbmZpZ3VyYXRpb24SOAoJcGxhY2VtZW50GAQgASgLMiUuZG90Y29yZS5kZXBsb3ltZW50LnYxLkV4YWN0UGxhY2VtZW50IroBCiNFc3RpbWF0ZUxvY2FsTW9kZWxSZXNvdXJjZXNSZXNwb25zZRJRChhoYXJkd2FyZV9jbGFzc19lc3RpbWF0ZXMYASADKAsyLy5kb3Rjb3JlLm1vZGVsLnYxLkhhcmR3YXJlQ2xhc3NSZXNvdXJjZUVzdGltYXRlEkAKEXBsYWNlbWVudF9wcmV2aWV3GAIgASgLMiUuZG90Y29yZS5jYXBhY2l0eS52MS5QbGFjZW1lbnRQcmV2aWV3Iv8BCh1IYXJkd2FyZUNsYXNzUmVzb3VyY2VFc3RpbWF0ZRIZChFoYXJkd2FyZV9jbGFzc19pZBgBIAEoCRI7Cgllc3RpbWF0ZXMYAiADKAsyKC5kb3Rjb3JlLm1vZGVsLnYxLlNjYWxhclJlc291cmNlRXN0aW1hdGUSUgofZGVyaXZlZF9lbmdpbmVfcGFyYW1ldGVyX3ZhbHVlcxgDIAEoCzIpLmRvdGNvcmUuY2F0YWxvZy52MS5FbmdpbmVQYXJhbWV0ZXJWYWx1ZXMSHQoQbGF1bmNoX3JlamVjdGlvbhgEIAEoCUgAiAEBQhMKEV9sYXVuY2hfcmVqZWN0aW9uIkcKFlNjYWxhclJlc291cmNlRXN0aW1hdGUSEAoIcmVzb3VyY2UYASABKAkSDQoFdmFsdWUYAiABKAQSDAoEdW5pdBgDIAEoCSK1AQokTGlzdE1vZGVsUHJvZmlsZUNvbXBhdGliaWxpdHlSZXF1ZXN0EhUKCG1vZGVsX2lkGAEgASgJSACIAQESNQoGdGFyZ2V0GAIgASgLMiUuZG90Y29yZS5kZXBsb3ltZW50LnYxLkV4YWN0UGxhY2VtZW50EjIKCHNjZW5hcmlvGAMgASgLMiAuZG90Y29yZS5tb2RlbC52MS5TaXppbmdTY2VuYXJpb0ILCglfbW9kZWxfaWQidgoOU2l6aW5nU2NlbmFyaW8SHAoUdG90YWxfY29udGV4dF90b2tlbnMYASABKA0SJwoaY29udGV4dF90b2tlbnNfcGVyX3JlcXVlc3QYAiABKA1IAIgBAUIdChtfY29udGV4dF90b2tlbnNfcGVyX3JlcXVlc3Qi1QEKJUxpc3RNb2RlbFByb2ZpbGVDb21wYXRpYmlsaXR5UmVzcG9uc2USNgoHdGFyZ2V0cxgBIAMoCzIlLmRvdGNvcmUubW9kZWwudjEuVGFyZ2V0Q29tcGF0aWJpbGl0eRI8CgdlbnRyaWVzGAIgAygLMisuZG90Y29yZS5tb2RlbC52MS5Nb2RlbFByb2ZpbGVDb21wYXRpYmlsaXR5EjYKCHByb2ZpbGVzGAMgAygLMiQuZG90Y29yZS5tb2RlbC52MS5Nb2RlbFByb2ZpbGVTaXppbmciaAofTGlzdE1vZGVsUmVjb21tZW5kYXRpb25zUmVxdWVzdBIuCgl1c2VfY2FzZXMYASADKA4yGy5kb3Rjb3JlLmNhdGFsb2cudjEuVXNlQ2FzZRIVCg1jYW5fc3RhcnRfbm93GAIgASgIImQKIExpc3RNb2RlbFJlY29tbWVuZGF0aW9uc1Jlc3BvbnNlEkAKCXVzZV9jYXNlcxgBIAMoCzItLmRvdGNvcmUubW9kZWwudjEuVXNlQ2FzZU1vZGVsUmVjb21tZW5kYXRpb25zIoUBChtVc2VDYXNlTW9kZWxSZWNvbW1lbmRhdGlvbnMSLQoIdXNlX2Nhc2UYASABKA4yGy5kb3Rjb3JlLmNhdGFsb2cudjEuVXNlQ2FzZRI3CgRyb3dzGAIgAygLMikuZG90Y29yZS5tb2RlbC52MS5SZWNvbW1lbmRlZE1vZGVsUHJvZmlsZSKgAQoXUmVjb21tZW5kZWRNb2RlbFByb2ZpbGUSEAoIbW9kZWxfaWQYASABKAkSEgoKcHJvZmlsZV9pZBgCIAEoCRIiChpjb250ZXh0X3Rva2Vuc19wZXJfcmVxdWVzdBgEIAEoBBI7Cgd0YXJnZXRzGAMgAygLMiouZG90Y29yZS5tb2RlbC52MS5SZWNvbW1lbmRlZFByb2ZpbGVUYXJnZXQikAIKGFJlY29tbWVuZGVkUHJvZmlsZVRhcmdldBI1CgZ0YXJnZXQYASABKAsyJS5kb3Rjb3JlLmRlcGxveW1lbnQudjEuRXhhY3RQbGFjZW1lbnQSNQoDZml0GAIgASgOMiguZG90Y29yZS5tb2RlbC52MS5Nb2RlbFJlY29tbWVuZGF0aW9uRml0EiAKE21vZGVsX2RlcGxveW1lbnRfaWQYAyABKAlIAIgBARIqCh1zdGFydGluZ190b3RhbF9jb250ZXh0X3Rva2VucxgEIAEoBEgBiAEBQhYKFF9tb2RlbF9kZXBsb3ltZW50X2lkQiAKHl9zdGFydGluZ190b3RhbF9jb250ZXh0X3Rva2VucyL1AQoTVGFyZ2V0Q29tcGF0aWJpbGl0eRI1CgZ0YXJnZXQYASABKAsyJS5kb3Rjb3JlLmRlcGxveW1lbnQudjEuRXhhY3RQbGFjZW1lbnQSOgoGc3RhdHVzGAIgASgOMiouZG90Y29yZS5tb2RlbC52MS5Nb2RlbENvbXBhdGliaWxpdHlTdGF0dXMSOgoGcmVhc29uGAMgASgLMiouZG90Y29yZS5tb2RlbC52MS5Nb2RlbENvbXBhdGliaWxpdHlSZWFzb24SLwoLb2JzZXJ2ZWRfYXQYBCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIv4FChlNb2RlbFByb2ZpbGVDb21wYXRpYmlsaXR5EhAKCG1vZGVsX2lkGAEgASgJEhIKCnByb2ZpbGVfaWQYAiABKAkSNQoGdGFyZ2V0GAMgASgLMiUuZG90Y29yZS5kZXBsb3ltZW50LnYxLkV4YWN0UGxhY2VtZW50EjoKBnN0YXR1cxgEIAEoDjIqLmRvdGNvcmUubW9kZWwudjEuTW9kZWxDb21wYXRpYmlsaXR5U3RhdHVzEjoKBnJlYXNvbhgFIAEoCzIqLmRvdGNvcmUubW9kZWwudjEuTW9kZWxDb21wYXRpYmlsaXR5UmVhc29uEkIKCmFkdmlzb3JpZXMYBiADKA4yLi5kb3Rjb3JlLm1vZGVsLnYxLkxvY2FsTW9kZWxEZXBsb3ltZW50QWR2aXNvcnkSGQoRaGFyZHdhcmVfY2xhc3NfaWQYByABKAkSNAoKbWVtb3J5X2ZpdBgIIAEoDjIgLmRvdGNvcmUuZGVwbG95bWVudC52MS5NZW1vcnlGaXQSOQoPZnJlZV9tZW1vcnlfZml0GAsgASgOMiAuZG90Y29yZS5kZXBsb3ltZW50LnYxLk1lbW9yeUZpdBJNChJ1bnN1cHBvcnRlZF9yZWFzb24YCSABKA4yMS5kb3Rjb3JlLm1vZGVsLnYxLlNpemluZ1NjZW5hcmlvVW5zdXBwb3J0ZWRSZWFzb24SKQocbGFyZ2VzdF90b3RhbF9jb250ZXh0X3Rva2VucxgKIAEoDUgAiAEBEioKHXN0YXJ0aW5nX3RvdGFsX2NvbnRleHRfdG9rZW5zGAwgASgNSAGIAQESUwodc3RhcnRpbmdfdG90YWxfY29udGV4dF9zb3VyY2UYDSABKA4yLC5kb3Rjb3JlLm1vZGVsLnYxLlN0YXJ0aW5nVG90YWxDb250ZXh0U291cmNlQh8KHV9sYXJnZXN0X3RvdGFsX2NvbnRleHRfdG9rZW5zQiAKHl9zdGFydGluZ190b3RhbF9jb250ZXh0X3Rva2VucyLQAgoSTW9kZWxQcm9maWxlU2l6aW5nEhAKCG1vZGVsX2lkGAEgASgJEhIKCnByb2ZpbGVfaWQYAiABKAkSSgoXZW5naW5lX3BhcmFtZXRlcl92YWx1ZXMYAyABKAsyKS5kb3Rjb3JlLmNhdGFsb2cudjEuRW5naW5lUGFyYW1ldGVyVmFsdWVzElEKGGhhcmR3YXJlX2NsYXNzX2VzdGltYXRlcxgEIAMoCzIvLmRvdGNvcmUubW9kZWwudjEuSGFyZHdhcmVDbGFzc1Jlc291cmNlRXN0aW1hdGUSTQoSdW5zdXBwb3J0ZWRfcmVhc29uGAUgASgOMjEuZG90Y29yZS5tb2RlbC52MS5TaXppbmdTY2VuYXJpb1Vuc3VwcG9ydGVkUmVhc29uEiYKHnVuc3VwcG9ydGVkX2hhcmR3YXJlX2NsYXNzX2lkcxgGIAMoCSK1AwoYTW9kZWxDb21wYXRpYmlsaXR5UmVhc29uEjwKBGNvZGUYASABKA4yLi5kb3Rjb3JlLm1vZGVsLnYxLk1vZGVsQ29tcGF0aWJpbGl0eVJlYXNvbkNvZGUSPAoTY29uZmlndXJlZF9wbGF0Zm9ybRgCIAEoCzIfLmRvdGNvcmUuY2F0YWxvZy52MS5PY2lQbGF0Zm9ybRI6ChFvYnNlcnZlZF9wbGF0Zm9ybRgDIAEoCzIfLmRvdGNvcmUuY2F0YWxvZy52MS5PY2lQbGF0Zm9ybRI5ChNzdXBwb3J0ZWRfc2VsZWN0b3JzGAQgAygLMhwuZG90Y29yZS5jYXRhbG9nLnYxLlNlbGVjdG9yEisKI21pbmltdW1fbnZpZGlhX2RyaXZlcl9tYWpvcl92ZXJzaW9uGAUgASgNEiwKJG9ic2VydmVkX252aWRpYV9kcml2ZXJfbWFqb3JfdmVyc2lvbhgGIAEoDRIgChhhY2NlbGVyYXRvcl9kZXZpY2VfY291bnQYByABKA0SKQohcmVxdWlyZWRfYWNjZWxlcmF0b3JfZGV2aWNlX2NvdW50GAggASgNIpgBChxVcGRhdGVNb2RlbERlcGxveW1lbnRSZXF1ZXN0EhsKE21vZGVsX2RlcGxveW1lbnRfaWQYASABKAkSWwoedXBzdHJlYW1fZGVzaXJlZF9jb25maWd1cmF0aW9uGAIgASgLMjMuZG90Y29yZS5tb2RlbC52MS5VcHN0cmVhbU1vZGVsRGVzaXJlZENvbmZpZ3VyYXRpb24iVgodVXBkYXRlTW9kZWxEZXBsb3ltZW50UmVzcG9uc2USNQoKZGVwbG95bWVudBgBIAEoCzIhLmRvdGNvcmUubW9kZWwudjEuTW9kZWxEZXBsb3ltZW50IjsKHFJlbW92ZU1vZGVsRGVwbG95bWVudFJlcXVlc3QSGwoTbW9kZWxfZGVwbG95bWVudF9pZBgBIAEoCSJWCh1SZW1vdmVNb2RlbERlcGxveW1lbnRSZXNwb25zZRI1CgpkZXBsb3ltZW50GAEgASgLMiEuZG90Y29yZS5tb2RlbC52MS5Nb2RlbERlcGxveW1lbnQiMAoRUGF1c2VNb2RlbFJlcXVlc3QSGwoTbW9kZWxfZGVwbG95bWVudF9pZBgBIAEoCSJLChJQYXVzZU1vZGVsUmVzcG9uc2USNQoKZGVwbG95bWVudBgBIAEoCzIhLmRvdGNvcmUubW9kZWwudjEuTW9kZWxEZXBsb3ltZW50IjEKElJlc3VtZU1vZGVsUmVxdWVzdBIbChNtb2RlbF9kZXBsb3ltZW50X2lkGAEgASgJIkwKE1Jlc3VtZU1vZGVsUmVzcG9uc2USNQoKZGVwbG95bWVudBgBIAEoCzIhLmRvdGNvcmUubW9kZWwudjEuTW9kZWxEZXBsb3ltZW50IrEBChlSZWFkRGVwbG95bWVudExvZ3NSZXF1ZXN0EhsKE21vZGVsX2RlcGxveW1lbnRfaWQYASABKAkSFAoMc291cmNlX25hbWVzGAIgAygJEhEKCXBhZ2Vfc2l6ZRgDIAEoDRI5ChBiZWZvcmVfdGltZXN0YW1wGAQgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcEgAiAEBQhMKEV9iZWZvcmVfdGltZXN0YW1wIo4DChpSZWFkRGVwbG95bWVudExvZ3NSZXNwb25zZRI2Cgdzb3VyY2VzGAEgAygLMiUuZG90Y29yZS5tb2RlbC52MS5EZXBsb3ltZW50TG9nU291cmNlEjUKB2VudHJpZXMYAiADKAsyJC5kb3Rjb3JlLm1vZGVsLnYxLkRlcGxveW1lbnRMb2dFbnRyeRI+ChVuZXh0X2JlZm9yZV90aW1lc3RhbXAYAyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wSACIAQESQgoNaGlzdG9yeV9zdGF0ZRgEIAEoDjIrLmRvdGNvcmUubW9kZWwudjEuRGVwbG95bWVudExvZ0hpc3RvcnlTdGF0ZRIaChJyZXNwb25zZV90cnVuY2F0ZWQYBSABKAgSRwoQbGF0ZXN0X3Bvc2l0aW9ucxgGIAMoCzItLmRvdGNvcmUubW9kZWwudjEuRGVwbG95bWVudExvZ1Jlc3VtZVBvc2l0aW9uQhgKFl9uZXh0X2JlZm9yZV90aW1lc3RhbXAimQEKG0ZvbGxvd0RlcGxveW1lbnRMb2dzUmVxdWVzdBIbChNtb2RlbF9kZXBsb3ltZW50X2lkGAEgASgJEhQKDHNvdXJjZV9uYW1lcxgCIAMoCRJHChByZXN1bWVfcG9zaXRpb25zGAMgAygLMi0uZG90Y29yZS5tb2RlbC52MS5EZXBsb3ltZW50TG9nUmVzdW1lUG9zaXRpb24iogEKHEZvbGxvd0RlcGxveW1lbnRMb2dzUmVzcG9uc2USQgoMc291cmNlX2V2ZW50GAEgASgLMiouZG90Y29yZS5tb2RlbC52MS5EZXBsb3ltZW50TG9nU291cmNlRXZlbnRIABI1CgVlbnRyeRgCIAEoCzIkLmRvdGNvcmUubW9kZWwudjEuRGVwbG95bWVudExvZ0VudHJ5SABCBwoFZXZlbnQiewoTRGVwbG95bWVudExvZ1NvdXJjZRITCgtzb3VyY2VfbmFtZRgBIAEoCRIUCgxzb3VyY2VfbGFiZWwYAiABKAkSOQoFc3RhdGUYAyABKA4yKi5kb3Rjb3JlLm1vZGVsLnYxLkRlcGxveW1lbnRMb2dTb3VyY2VTdGF0ZSLJAQoSRGVwbG95bWVudExvZ0VudHJ5EhMKC3NvdXJjZV9uYW1lGAEgASgJEhQKDHNvdXJjZV9sYWJlbBgCIAEoCRI1CgZzdHJlYW0YAyABKA4yJS5kb3Rjb3JlLm1vZGVsLnYxLkRlcGxveW1lbnRMb2dTdHJlYW0SLQoJdGltZXN0YW1wGAQgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIPCgdjb250ZW50GAUgASgMEhEKCXRydW5jYXRlZBgGIAEoCCJoChtEZXBsb3ltZW50TG9nUmVzdW1lUG9zaXRpb24SEwoLc291cmNlX25hbWUYASABKAkSNAoQbGF0ZXN0X3RpbWVzdGFtcBgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAigAEKGERlcGxveW1lbnRMb2dTb3VyY2VFdmVudBITCgtzb3VyY2VfbmFtZRgBIAEoCRIUCgxzb3VyY2VfbGFiZWwYAiABKAkSOQoFc3RhdGUYAyABKA4yKi5kb3Rjb3JlLm1vZGVsLnYxLkRlcGxveW1lbnRMb2dTb3VyY2VTdGF0ZSKxAQokVXBkYXRlTW9kZWxEZXBsb3ltZW50TWV0YWRhdGFSZXF1ZXN0EhsKE21vZGVsX2RlcGxveW1lbnRfaWQYASABKAkSOwoIbWV0YWRhdGEYAiABKAsyKS5kb3Rjb3JlLm1vZGVsLnYxLk1vZGVsRGVwbG95bWVudE1ldGFkYXRhEi8KC3VwZGF0ZV9tYXNrGAMgASgLMhouZ29vZ2xlLnByb3RvYnVmLkZpZWxkTWFzayJFChdNb2RlbERlcGxveW1lbnRNZXRhZGF0YRIZCgxkaXNwbGF5X25hbWUYASABKAlIAIgBAUIPCg1fZGlzcGxheV9uYW1lIl4KJVVwZGF0ZU1vZGVsRGVwbG95bWVudE1ldGFkYXRhUmVzcG9uc2USNQoKZGVwbG95bWVudBgBIAEoCzIhLmRvdGNvcmUubW9kZWwudjEuTW9kZWxEZXBsb3ltZW50KuABChxMb2NhbE1vZGVsRGVwbG95bWVudEFkdmlzb3J5Ei8KK0xPQ0FMX01PREVMX0RFUExPWU1FTlRfQURWSVNPUllfVU5TUEVDSUZJRUQQABJBCj1MT0NBTF9NT0RFTF9ERVBMT1lNRU5UX0FEVklTT1JZX0hPU1RfS1ZfT0ZGTE9BRF9TSEFSRVNfTUVNT1JZEAESTApITE9DQUxfTU9ERUxfREVQTE9ZTUVOVF9BRFZJU09SWV9VTklGSUVEX01FTU9SWV9QT09MX0JFTE9XX0RFVklDRV9NSU5JTVVNEAIq6gEKFk1vZGVsUmVjb21tZW5kYXRpb25GaXQSKAokTU9ERUxfUkVDT01NRU5EQVRJT05fRklUX1VOU1BFQ0lGSUVEEAASJQohTU9ERUxfUkVDT01NRU5EQVRJT05fRklUX0ZJVFNfTk9XEAESLworTU9ERUxfUkVDT01NRU5EQVRJT05fRklUX05FRURTX01FTU9SWV9GUkVFRBACEiQKIE1PREVMX1JFQ09NTUVOREFUSU9OX0ZJVF9VTktOT1dOEAMSKAokTU9ERUxfUkVDT01NRU5EQVRJT05fRklUX1VOUkVBQ0hBQkxFEAQqvgEKGE1vZGVsQ29tcGF0aWJpbGl0eVN0YXR1cxIqCiZNT0RFTF9DT01QQVRJQklMSVRZX1NUQVRVU19VTlNQRUNJRklFRBAAEiMKH01PREVMX0NPTVBBVElCSUxJVFlfU1RBVFVTX1JVTlMQARIpCiVNT0RFTF9DT01QQVRJQklMSVRZX1NUQVRVU19DQU5OT1RfUlVOEAISJgoiTU9ERUxfQ09NUEFUSUJJTElUWV9TVEFUVVNfVU5LTk9XThADKvAFChxNb2RlbENvbXBhdGliaWxpdHlSZWFzb25Db2RlEi8KK01PREVMX0NPTVBBVElCSUxJVFlfUkVBU09OX0NPREVfVU5TUEVDSUZJRUQQABI4CjRNT0RFTF9DT01QQVRJQklMSVRZX1JFQVNPTl9DT0RFX1RBUkdFVF9OT1RfUkVQT1JUSU5HEAESOQo1TU9ERUxfQ09NUEFUSUJJTElUWV9SRUFTT05fQ09ERV9OVklESUFfRFJJVkVSX1VOS05PV04QAhI+CjpNT0RFTF9DT01QQVRJQklMSVRZX1JFQVNPTl9DT0RFX0FDQ0VMRVJBVE9SX01FTU9SWV9VTktOT1dOEAMSOwo3TU9ERUxfQ09NUEFUSUJJTElUWV9SRUFTT05fQ09ERV9UQVJHRVRfUExBVEZPUk1fSU5WQUxJRBAEEjUKMU1PREVMX0NPTVBBVElCSUxJVFlfUkVBU09OX0NPREVfUExBVEZPUk1fTUlTTUFUQ0gQBRIyCi5NT0RFTF9DT01QQVRJQklMSVRZX1JFQVNPTl9DT0RFX05PX0FDQ0VMRVJBVE9SEAYSPwo7TU9ERUxfQ09NUEFUSUJJTElUWV9SRUFTT05fQ09ERV9OT19NQVRDSElOR19FTkdJTkVfQVJUSUZBQ1QQBxI/CjtNT0RFTF9DT01QQVRJQklMSVRZX1JFQVNPTl9DT0RFX05PX1NVUFBPUlRFRF9IQVJEV0FSRV9DTEFTUxALEj8KO01PREVMX0NPTVBBVElCSUxJVFlfUkVBU09OX0NPREVfTlZJRElBX0RSSVZFUl9CRUxPV19NSU5JTVVNEAgSRQpBTU9ERUxfQ09NUEFUSUJJTElUWV9SRUFTT05fQ09ERV9BQ0NFTEVSQVRPUl9ERVZJQ0VfQ09VTlRfTUlTTUFUQ0gQCRI4CjRNT0RFTF9DT01QQVRJQklMSVRZX1JFQVNPTl9DT0RFX1BST0ZJTEVfVU5SRVNPTFZBQkxFEAoqmQIKH1NpemluZ1NjZW5hcmlvVW5zdXBwb3J0ZWRSZWFzb24SMgouU0laSU5HX1NDRU5BUklPX1VOU1VQUE9SVEVEX1JFQVNPTl9VTlNQRUNJRklFRBAAEjwKOFNJWklOR19TQ0VOQVJJT19VTlNVUFBPUlRFRF9SRUFTT05fQ09OVEVYVF9BQk9WRV9NQVhJTVVNEAESPQo5U0laSU5HX1NDRU5BUklPX1VOU1VQUE9SVEVEX1JFQVNPTl9QQVJBTUVURVJfT1VUX09GX1JBTkdFEAMSRQpBU0laSU5HX1NDRU5BUklPX1VOU1VQUE9SVEVEX1JFQVNPTl9ERVJJVkVEX1BBUkFNRVRFUl9PVVRfT0ZfUkFOR0UQBCqtAQoaU3RhcnRpbmdUb3RhbENvbnRleHRTb3VyY2USLQopU1RBUlRJTkdfVE9UQUxfQ09OVEVYVF9TT1VSQ0VfVU5TUEVDSUZJRUQQABItCilTVEFSVElOR19UT1RBTF9DT05URVhUX1NPVVJDRV9MQVJHRVNUX0ZJVBABEjEKLVNUQVJUSU5HX1RPVEFMX0NPTlRFWFRfU09VUkNFX1BST0ZJTEVfREVGQVVMVBACKr4BChlEZXBsb3ltZW50TG9nSGlzdG9yeVN0YXRlEiwKKERFUExPWU1FTlRfTE9HX0hJU1RPUllfU1RBVEVfVU5TUEVDSUZJRUQQABI4CjRERVBMT1lNRU5UX0xPR19ISVNUT1JZX1NUQVRFX09MREVSX09VVFBVVF9NQVlfUkVNQUlOEAESOQo1REVQTE9ZTUVOVF9MT0dfSElTVE9SWV9TVEFURV9OT19PTERFUl9PVVRQVVRfT0JTRVJWRUQQAirGAQoYRGVwbG95bWVudExvZ1NvdXJjZVN0YXRlEisKJ0RFUExPWU1FTlRfTE9HX1NPVVJDRV9TVEFURV9VTlNQRUNJRklFRBAAEikKJURFUExPWU1FTlRfTE9HX1NPVVJDRV9TVEFURV9BVkFJTEFCTEUQARIrCidERVBMT1lNRU5UX0xPR19TT1VSQ0VfU1RBVEVfVU5BVkFJTEFCTEUQAhIlCiFERVBMT1lNRU5UX0xPR19TT1VSQ0VfU1RBVEVfRU5ERUQQAyqAAQoTRGVwbG95bWVudExvZ1N0cmVhbRIlCiFERVBMT1lNRU5UX0xPR19TVFJFQU1fVU5TUEVDSUZJRUQQABIgChxERVBMT1lNRU5UX0xPR19TVFJFQU1fU1RET1VUEAESIAocREVQTE9ZTUVOVF9MT0dfU1RSRUFNX1NUREVSUhACMuAMChZNb2RlbERlcGxveW1lbnRTZXJ2aWNlEnUKFExpc3RNb2RlbERlcGxveW1lbnRzEi0uZG90Y29yZS5tb2RlbC52MS5MaXN0TW9kZWxEZXBsb3ltZW50c1JlcXVlc3QaLi5kb3Rjb3JlLm1vZGVsLnYxLkxpc3RNb2RlbERlcGxveW1lbnRzUmVzcG9uc2USeAoVQ3JlYXRlTW9kZWxEZXBsb3ltZW50Ei4uZG90Y29yZS5tb2RlbC52MS5DcmVhdGVNb2RlbERlcGxveW1lbnRSZXF1ZXN0Gi8uZG90Y29yZS5tb2RlbC52MS5DcmVhdGVNb2RlbERlcGxveW1lbnRSZXNwb25zZRKKAQobUHJldmlld0xvY2FsTW9kZWxEZXBsb3ltZW50EjQuZG90Y29yZS5tb2RlbC52MS5QcmV2aWV3TG9jYWxNb2RlbERlcGxveW1lbnRSZXF1ZXN0GjUuZG90Y29yZS5tb2RlbC52MS5QcmV2aWV3TG9jYWxNb2RlbERlcGxveW1lbnRSZXNwb25zZRKKAQobRXN0aW1hdGVMb2NhbE1vZGVsUmVzb3VyY2VzEjQuZG90Y29yZS5tb2RlbC52MS5Fc3RpbWF0ZUxvY2FsTW9kZWxSZXNvdXJjZXNSZXF1ZXN0GjUuZG90Y29yZS5tb2RlbC52MS5Fc3RpbWF0ZUxvY2FsTW9kZWxSZXNvdXJjZXNSZXNwb25zZRKQAQodTGlzdE1vZGVsUHJvZmlsZUNvbXBhdGliaWxpdHkSNi5kb3Rjb3JlLm1vZGVsLnYxLkxpc3RNb2RlbFByb2ZpbGVDb21wYXRpYmlsaXR5UmVxdWVzdBo3LmRvdGNvcmUubW9kZWwudjEuTGlzdE1vZGVsUHJvZmlsZUNvbXBhdGliaWxpdHlSZXNwb25zZRKBAQoYTGlzdE1vZGVsUmVjb21tZW5kYXRpb25zEjEuZG90Y29yZS5tb2RlbC52MS5MaXN0TW9kZWxSZWNvbW1lbmRhdGlvbnNSZXF1ZXN0GjIuZG90Y29yZS5tb2RlbC52MS5MaXN0TW9kZWxSZWNvbW1lbmRhdGlvbnNSZXNwb25zZRJ4ChVVcGRhdGVNb2RlbERlcGxveW1lbnQSLi5kb3Rjb3JlLm1vZGVsLnYxLlVwZGF0ZU1vZGVsRGVwbG95bWVudFJlcXVlc3QaLy5kb3Rjb3JlLm1vZGVsLnYxLlVwZGF0ZU1vZGVsRGVwbG95bWVudFJlc3BvbnNlEngKFVJlbW92ZU1vZGVsRGVwbG95bWVudBIuLmRvdGNvcmUubW9kZWwudjEuUmVtb3ZlTW9kZWxEZXBsb3ltZW50UmVxdWVzdBovLmRvdGNvcmUubW9kZWwudjEuUmVtb3ZlTW9kZWxEZXBsb3ltZW50UmVzcG9uc2USVwoKUGF1c2VNb2RlbBIjLmRvdGNvcmUubW9kZWwudjEuUGF1c2VNb2RlbFJlcXVlc3QaJC5kb3Rjb3JlLm1vZGVsLnYxLlBhdXNlTW9kZWxSZXNwb25zZRJaCgtSZXN1bWVNb2RlbBIkLmRvdGNvcmUubW9kZWwudjEuUmVzdW1lTW9kZWxSZXF1ZXN0GiUuZG90Y29yZS5tb2RlbC52MS5SZXN1bWVNb2RlbFJlc3BvbnNlEm8KElJlYWREZXBsb3ltZW50TG9ncxIrLmRvdGNvcmUubW9kZWwudjEuUmVhZERlcGxveW1lbnRMb2dzUmVxdWVzdBosLmRvdGNvcmUubW9kZWwudjEuUmVhZERlcGxveW1lbnRMb2dzUmVzcG9uc2USdwoURm9sbG93RGVwbG95bWVudExvZ3MSLS5kb3Rjb3JlLm1vZGVsLnYxLkZvbGxvd0RlcGxveW1lbnRMb2dzUmVxdWVzdBouLmRvdGNvcmUubW9kZWwudjEuRm9sbG93RGVwbG95bWVudExvZ3NSZXNwb25zZTABEpABCh1VcGRhdGVNb2RlbERlcGxveW1lbnRNZXRhZGF0YRI2LmRvdGNvcmUubW9kZWwudjEuVXBkYXRlTW9kZWxEZXBsb3ltZW50TWV0YWRhdGFSZXF1ZXN0GjcuZG90Y29yZS5tb2RlbC52MS5VcGRhdGVNb2RlbERlcGxveW1lbnRNZXRhZGF0YVJlc3BvbnNlYgZwcm90bzM", [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]); /** * Requests the controller-owned model deployments. @@ -636,6 +636,13 @@ export type ListModelRecommendationsRequest = Message<"dotcore.model.v1.ListMode * @generated from field: repeated dotcore.catalog.v1.UseCase use_cases = 1; */ useCases: UseCase[]; + + /** + * Keep only targets that run the profile or fit its memory now, and rows left with none are dropped; deployed rows come first. + * + * @generated from field: bool can_start_now = 2; + */ + canStartNow: boolean; }; /** @@ -714,6 +721,13 @@ export type RecommendedModelProfile = Message<"dotcore.model.v1.RecommendedModel */ profileId: string; + /** + * Context per request in tokens the row is sized with: the entry's own, else the profile's default. + * + * @generated from field: uint64 context_tokens_per_request = 4; + */ + contextTokensPerRequest: bigint; + /** * Targets that can run the profile, ordered by node then target identifier. * @@ -750,11 +764,11 @@ export type RecommendedProfileTarget = Message<"dotcore.model.v1.RecommendedProf fit: ModelRecommendationFit; /** - * Whether a deployment of the same model and profile is placed on the target. + * The running or starting deployment of the same model and profile on the target; absent when there is none. * - * @generated from field: bool deployed = 3; + * @generated from field: optional string model_deployment_id = 3; */ - deployed: boolean; + modelDeploymentId?: string; /** * Total context in tokens a deployment of the profile starts from on the target; absent without a largest fit or a profile default. diff --git a/frontend/src/test/catalog-model-site-contract.test.ts b/frontend/src/test/catalog-model-site-contract.test.ts index 1a2f7e1f..8a0440f2 100644 --- a/frontend/src/test/catalog-model-site-contract.test.ts +++ b/frontend/src/test/catalog-model-site-contract.test.ts @@ -51,6 +51,7 @@ describe("separate catalog and model read contracts", () => { "ReadDeploymentLogs", "FollowDeploymentLogs", "UpdateApplicationDeploymentMetadata", + "ListApplicationTargets", ]); expect( fieldNumbers( diff --git a/proto/dotcore/application/v1/service.proto b/proto/dotcore/application/v1/service.proto index 408ed57e..f8091cdb 100644 --- a/proto/dotcore/application/v1/service.proto +++ b/proto/dotcore/application/v1/service.proto @@ -19,6 +19,32 @@ service ApplicationDeploymentService { rpc ReadDeploymentLogs(ReadDeploymentLogsRequest) returns (ReadDeploymentLogsResponse); rpc FollowDeploymentLogs(FollowDeploymentLogsRequest) returns (stream FollowDeploymentLogsResponse); rpc UpdateApplicationDeploymentMetadata(UpdateApplicationDeploymentMetadataRequest) returns (UpdateApplicationDeploymentMetadataResponse); + rpc ListApplicationTargets(ListApplicationTargetsRequest) returns (ListApplicationTargetsResponse); +} + +// Asks where one catalog application can run now. A fast, advisory read of current target observations and +// deployments; it never changes deployments, admission or placement. +message ListApplicationTargetsRequest { + // Immutable catalog application release identifier. + string application_id = 1; +} + +// The application targets that can take the application now, best first; empty when none can. +message ListApplicationTargetsResponse { + // Targets ordered with deployed ones first, then those matching the application's preferred accelerators. + repeated ApplicationTargetOption targets = 1; +} + +// One application target that can take the application. +message ApplicationTargetOption { + // Exact placement of the application target. + dotcore.deployment.v1.ExactPlacement target = 1; + + // Whether the target holds accelerators of a vendor the application prefers; true for an application with no preference. + bool matches_preference = 2; + + // The application's running or starting deployment on the target; absent when there is none. + optional string application_deployment_id = 3; } // Requests the controller-owned application deployments. diff --git a/proto/dotcore/catalog/v1/application.proto b/proto/dotcore/catalog/v1/application.proto index 06387a96..13b2d6d1 100644 --- a/proto/dotcore/catalog/v1/application.proto +++ b/proto/dotcore/catalog/v1/application.proto @@ -5,6 +5,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/recommendation.proto"; import "dotcore/catalog/v1/storage.proto"; // An immutable artifact-manager catalog declaration for an approved application release. @@ -26,6 +27,12 @@ message Application { // Immutable runtime-neutral service graph for this application release. ApplicationGraph graph = 4; + + // Use cases this application serves; recommendation and display metadata with no admission or placement authority. + repeated UseCase use_cases = 7; + + // Accelerator vendors this application prefers, named as in hardware class selectors; advisory, never used for admission or placement. + repeated string preferred_accelerators = 8; } // An immutable graph of named services with exactly one primary endpoint. diff --git a/proto/dotcore/catalog/v1/recommendation.proto b/proto/dotcore/catalog/v1/recommendation.proto index 2b00db5f..ae8485e6 100644 --- a/proto/dotcore/catalog/v1/recommendation.proto +++ b/proto/dotcore/catalog/v1/recommendation.proto @@ -34,21 +34,21 @@ message ModelRecommendation { // Optional memory minimums a machine needs for this entry, on top of the configuration's own. HardwareRequirements hardware_requirements = 3; + + // Optional context per request in tokens to size this entry with; the profile's default applies when absent. + optional uint64 context_tokens_per_request = 4; } // What an operator runs models for. Distinct from inference request kinds. enum UseCase { USE_CASE_UNSPECIFIED = 0; - // Coding assistants and autonomous agents. - USE_CASE_CODING_AND_AGENTS = 1; - // General chat assistance. - USE_CASE_GENERAL_ASSISTANT = 2; + USE_CASE_CHAT_ASSISTANT = 1; - // Translating text between languages. - USE_CASE_TRANSLATION = 3; + // Coding assistants and autonomous agents. + USE_CASE_CODING = 2; - // Typed decisions with per-option probabilities. - USE_CASE_DECISION_MAKING = 4; + // Generating images. + USE_CASE_IMAGE_GENERATION = 3; } diff --git a/proto/dotcore/model/v1/service.proto b/proto/dotcore/model/v1/service.proto index a9fe1466..181b79bb 100644 --- a/proto/dotcore/model/v1/service.proto +++ b/proto/dotcore/model/v1/service.proto @@ -258,6 +258,9 @@ message ListModelProfileCompatibilityResponse { message ListModelRecommendationsRequest { // Use cases to rank; empty ranks every use case. Unspecified is rejected. repeated dotcore.catalog.v1.UseCase use_cases = 1; + + // Keep only targets that run the profile or fit its memory now, and rows left with none are dropped; deployed rows come first. + bool can_start_now = 2; } // One ranked list of recommended model profiles per requested use case. @@ -283,6 +286,9 @@ message RecommendedModelProfile { // Model-local profile identifier. string profile_id = 2; + // Context per request in tokens the row is sized with: the entry's own, else the profile's default. + uint64 context_tokens_per_request = 4; + // Targets that can run the profile, ordered by node then target identifier. repeated RecommendedProfileTarget targets = 3; } @@ -295,8 +301,8 @@ message RecommendedProfileTarget { // How the profile fits the target's memory now. ModelRecommendationFit fit = 2; - // Whether a deployment of the same model and profile is placed on the target. - bool deployed = 3; + // The running or starting deployment of the same model and profile on the target; absent when there is none. + optional string model_deployment_id = 3; // Total context in tokens a deployment of the profile starts from on the target; absent without a largest fit or a profile default. optional uint64 starting_total_context_tokens = 4; From 7b433f80afcf4bc190f1d4c00216add4e585d244 Mon Sep 17 00:00:00 2001 From: Andre-Philippe Paquet Date: Wed, 11 Nov 2026 13:25:20 -0500 Subject: [PATCH 02/25] feat(backend): rename recommendation use cases --- backend/catalog/src/lib.rs | 36 +++--- backend/catalog/src/service.rs | 2 + backend/catalog/src/validation/application.rs | 2 + .../catalog/src/validation/recommendation.rs | 11 +- backend/cli/src/hardware/recommend.rs | 26 ++-- backend/cli/tests/command_contract.rs | 12 +- backend/cli/tests/support/fake_manager.rs | 7 ++ .../application/controller/test_support.rs | 2 + backend/core/src/application/repository.rs | 4 + backend/core/src/application/service.rs | 13 +- backend/core/src/composition/isolated.rs | 11 +- .../core/src/model/recommendation/machine.rs | 12 +- .../core/src/model/recommendation/tests.rs | 118 +++++------------- backend/core/src/model/repository.rs | 5 +- backend/core/src/model/service.rs | 27 ++-- .../core/tests/backend/backend_integration.rs | 23 ++-- .../core/tests/backend/catalog_contracts.rs | 8 +- .../recommendations/nvidia-dedicated.yaml | 5 +- backend/proto/tests/application_contract.rs | 5 + backend/proto/tests/model_contract.rs | 16 ++- backend/proto/tests/site_contract.rs | 2 + 21 files changed, 172 insertions(+), 175 deletions(-) diff --git a/backend/catalog/src/lib.rs b/backend/catalog/src/lib.rs index 3ea5813f..32497481 100644 --- a/backend/catalog/src/lib.rs +++ b/backend/catalog/src/lib.rs @@ -2129,7 +2129,7 @@ artifacts: const NVIDIA_RECOMMENDATIONS: &str = r#"hardwareClassId: "nvidia" useCases: - - useCase: "USE_CASE_CODING_AND_AGENTS" + - useCase: "USE_CASE_CODING" entries: - modelId: "fixture-model" profileId: "balanced" @@ -2137,7 +2137,7 @@ useCases: minimumMemoryBytesPerDevice: "32000000000" - modelId: "fixture-model" profileId: "fast" - - useCase: "USE_CASE_TRANSLATION" + - useCase: "USE_CASE_CHAT_ASSISTANT" entries: - modelId: "fixture-model" profileId: "fast" @@ -2187,10 +2187,10 @@ useCases: [ ( "nvidia", - UseCase::CodingAndAgents, + UseCase::Coding, vec![("balanced", Some(32_000_000_000)), ("fast", None)] ), - ("nvidia", UseCase::Translation, vec![("fast", None)]), + ("nvidia", UseCase::ChatAssistant, vec![("fast", None)]), ] ); fs::remove_dir_all(root).expect("Catalog fixture cleanup"); @@ -2265,7 +2265,7 @@ useCases: &root, class, &format!( - "hardwareClassId: \"{class}\"\nuseCases:\n - useCase: \"USE_CASE_GENERAL_ASSISTANT\"\n entries:\n - modelId: \"fixture-model\"\n profileId: \"balanced\"\n" + "hardwareClassId: \"{class}\"\nuseCases:\n - useCase: \"USE_CASE_CHAT_ASSISTANT\"\n entries:\n - modelId: \"fixture-model\"\n profileId: \"balanced\"\n" ), ); } @@ -2310,9 +2310,9 @@ useCases: lists.concat() ) }; - let assistant = |entries: &[(&str, &str)]| list("USE_CASE_GENERAL_ASSISTANT", entries); + let assistant = |entries: &[(&str, &str)]| list("USE_CASE_CHAT_ASSISTANT", entries); let balanced = [("fixture-model", "balanced")]; - let entry = "use case USE_CASE_GENERAL_ASSISTANT entry 1 (model \"fixture-model\" profile \"balanced\")"; + let entry = "use case USE_CASE_CHAT_ASSISTANT entry 1 (model \"fixture-model\" profile \"balanced\")"; let nvidia_support = " - hardwareClassId: \"nvidia\"\n"; let minimum_support = " - hardwareClassId: \"nvidia\"\n hardwareRequirements:\n minimumMemoryBytesPerDevice: \"16000000000\"\n"; @@ -2333,19 +2333,19 @@ useCases: "repeated use case", nvidia_support, vec![("nvidia", file("nvidia", &[assistant(&balanced), assistant(&[("fixture-model", "fast")])]))], - "recommendations/nvidia.yaml: model recommendations for hardware class \"nvidia\" list use case USE_CASE_GENERAL_ASSISTANT more than once".to_owned(), + "recommendations/nvidia.yaml: model recommendations for hardware class \"nvidia\" list use case USE_CASE_CHAT_ASSISTANT more than once".to_owned(), ), ( "unknown model", nvidia_support, vec![("nvidia", file("nvidia", &[assistant(&[("missing", "balanced")])]))], - "recommendations/nvidia.yaml: model recommendations for hardware class \"nvidia\" use case USE_CASE_GENERAL_ASSISTANT entry 1 (model \"missing\" profile \"balanced\") references an unknown model".to_owned(), + "recommendations/nvidia.yaml: model recommendations for hardware class \"nvidia\" use case USE_CASE_CHAT_ASSISTANT entry 1 (model \"missing\" profile \"balanced\") references an unknown model".to_owned(), ), ( "unknown profile", nvidia_support, vec![("nvidia", file("nvidia", &[assistant(&[("fixture-model", "missing")])]))], - "recommendations/nvidia.yaml: model recommendations for hardware class \"nvidia\" use case USE_CASE_GENERAL_ASSISTANT entry 1 (model \"fixture-model\" profile \"missing\") references an unknown profile".to_owned(), + "recommendations/nvidia.yaml: model recommendations for hardware class \"nvidia\" use case USE_CASE_CHAT_ASSISTANT entry 1 (model \"fixture-model\" profile \"missing\") references an unknown profile".to_owned(), ), ( "unrelated class", @@ -2353,12 +2353,6 @@ useCases: vec![("apple", file("apple", &[assistant(&balanced)]))], format!("recommendations/apple.yaml: model recommendations for hardware class \"apple\" {entry} runs a configuration that supports neither this hardware class, a class containing it, nor a class nested in it"), ), - ( - "decision making without decision requests", - nvidia_support, - vec![("nvidia", file("nvidia", &[list("USE_CASE_DECISION_MAKING", &balanced)]))], - "recommendations/nvidia.yaml: model recommendations for hardware class \"nvidia\" use case USE_CASE_DECISION_MAKING entry 1 (model \"fixture-model\" profile \"balanced\") runs a configuration that doesn't accept INFERENCE_REQUEST_KIND_DECISION requests".to_owned(), - ), ( "entry minimum below the configuration's", minimum_support, @@ -2375,7 +2369,7 @@ useCases: "repeated entry", nvidia_support, vec![("nvidia", file("nvidia", &[assistant(&[balanced[0], balanced[0]])]))], - "recommendations/nvidia.yaml: model recommendations for hardware class \"nvidia\" use case USE_CASE_GENERAL_ASSISTANT entry 2 (model \"fixture-model\" profile \"balanced\") repeats an earlier entry".to_owned(), + "recommendations/nvidia.yaml: model recommendations for hardware class \"nvidia\" use case USE_CASE_CHAT_ASSISTANT entry 2 (model \"fixture-model\" profile \"balanced\") repeats an earlier entry".to_owned(), ), ( "class declared twice", @@ -2420,7 +2414,7 @@ useCases: } #[test] - fn chat_use_cases_require_a_configuration_accepting_chat_requests() { + fn coding_entries_require_a_configuration_accepting_chat_requests() { let root = recommendation_fixture(" - hardwareClassId: \"nvidia\"\n"); let path = root.join("models/fixture.yaml"); let model = fs::read_to_string(&path).expect("fixture model"); @@ -2435,7 +2429,7 @@ useCases: super::test_support::write_recommendations( &root, "nvidia", - "hardwareClassId: \"nvidia\"\nuseCases:\n - useCase: \"USE_CASE_DECISION_MAKING\"\n entries:\n - modelId: \"fixture-model\"\n profileId: \"balanced\"\n - useCase: \"USE_CASE_TRANSLATION\"\n entries:\n - modelId: \"fixture-model\"\n profileId: \"balanced\"\n", + "hardwareClassId: \"nvidia\"\nuseCases:\n - useCase: \"USE_CASE_CODING\"\n entries:\n - modelId: \"fixture-model\"\n profileId: \"balanced\"\n", ); let error = Catalog::load_with_policy( @@ -2443,12 +2437,12 @@ useCases: OciArtifactPolicy::Development, &ACCEPT_ENGINE_DECLARATIONS, ) - .expect_err("a chat use case on a decision-only configuration must reject") + .expect_err("a coding entry on a decision-only configuration must reject") .to_string(); assert_eq!( error, - "Catalog diagnostics: recommendations/nvidia.yaml: model recommendations for hardware class \"nvidia\" use case USE_CASE_TRANSLATION entry 1 (model \"fixture-model\" profile \"balanced\") runs a configuration that doesn't accept INFERENCE_REQUEST_KIND_CHAT requests" + "Catalog diagnostics: recommendations/nvidia.yaml: model recommendations for hardware class \"nvidia\" use case USE_CASE_CODING entry 1 (model \"fixture-model\" profile \"balanced\") runs a configuration that doesn't accept INFERENCE_REQUEST_KIND_CHAT requests" ); fs::remove_dir_all(root).expect("Catalog fixture cleanup"); } diff --git a/backend/catalog/src/service.rs b/backend/catalog/src/service.rs index 756e6dc8..56ac7c0a 100644 --- a/backend/catalog/src/service.rs +++ b/backend/catalog/src/service.rs @@ -297,6 +297,8 @@ mod tests { description: String::new(), notes: String::new(), version: "1".to_owned(), + use_cases: Vec::new(), + preferred_accelerators: Vec::new(), graph: Some(ApplicationGraph { services: vec![ApplicationService { service_name: "web".to_owned(), diff --git a/backend/catalog/src/validation/application.rs b/backend/catalog/src/validation/application.rs index 4a73f6cf..fdc19c82 100644 --- a/backend/catalog/src/validation/application.rs +++ b/backend/catalog/src/validation/application.rs @@ -83,6 +83,8 @@ mod tests { description: String::new(), notes: String::new(), version: "0.10.1".to_owned(), + use_cases: Vec::new(), + preferred_accelerators: Vec::new(), graph: Some(ApplicationGraph { services: vec![ ApplicationService { diff --git a/backend/catalog/src/validation/recommendation.rs b/backend/catalog/src/validation/recommendation.rs index b48a0ba7..d150d423 100644 --- a/backend/catalog/src/validation/recommendation.rs +++ b/backend/catalog/src/validation/recommendation.rs @@ -61,7 +61,6 @@ pub(super) fn validate_recommendations( } validate_entry( entry, - use_case, hardware_class, models, hardware_classes, @@ -103,7 +102,6 @@ pub(super) fn report_overlapping_recommendation_classes( fn validate_entry( entry: &ModelRecommendation, - use_case: UseCase, hardware_class: &HardwareClass, models: &BTreeMap, hardware_classes: &BTreeMap, @@ -136,18 +134,13 @@ fn validate_entry( )); } - let required_kind = if use_case == UseCase::DecisionMaking { - InferenceRequestKind::Decision - } else { - InferenceRequestKind::Chat - }; if !configuration .request_kinds - .contains(&(required_kind as i32)) + .contains(&(InferenceRequestKind::Chat as i32)) { errors.push(anyhow::anyhow!( "{label} runs a configuration that doesn't accept {} requests", - required_kind.as_str_name() + InferenceRequestKind::Chat.as_str_name() )); } diff --git a/backend/cli/src/hardware/recommend.rs b/backend/cli/src/hardware/recommend.rs index 85ab026f..cd7e9c4f 100644 --- a/backend/cli/src/hardware/recommend.rs +++ b/backend/cli/src/hardware/recommend.rs @@ -34,25 +34,18 @@ pub(super) struct RecommendArgs { #[derive(Clone, Copy, Debug, ValueEnum)] enum UseCaseArg { Coding, - Assistant, - Translation, - Decision, + Chat, + Image, } impl UseCaseArg { - const ALL: [Self; 4] = [ - Self::Coding, - Self::Assistant, - Self::Translation, - Self::Decision, - ]; + const ALL: [Self; 3] = [Self::Coding, Self::Chat, Self::Image]; fn use_case(self) -> UseCase { match self { - Self::Coding => UseCase::CodingAndAgents, - Self::Assistant => UseCase::GeneralAssistant, - Self::Translation => UseCase::Translation, - Self::Decision => UseCase::DecisionMaking, + Self::Coding => UseCase::Coding, + Self::Chat => UseCase::ChatAssistant, + Self::Image => UseCase::ImageGeneration, } } } @@ -149,10 +142,9 @@ fn table(catalog: &Catalog, machine: &IdleMachine, list: &UseCaseRecommendations fn use_case_label(use_case: UseCase) -> &'static str { match use_case { - UseCase::CodingAndAgents => "Coding and agents", - UseCase::GeneralAssistant => "General assistant", - UseCase::Translation => "Translation", - UseCase::DecisionMaking => "Decision making", + UseCase::Coding => "Coding", + UseCase::ChatAssistant => "Chat assistant", + UseCase::ImageGeneration => "Image generation", UseCase::Unspecified => "Unspecified", } } diff --git a/backend/cli/tests/command_contract.rs b/backend/cli/tests/command_contract.rs index 2c75d5ce..3750031b 100644 --- a/backend/cli/tests/command_contract.rs +++ b/backend/cli/tests/command_contract.rs @@ -6156,11 +6156,19 @@ fn hardware_recommend_prints_exact_tables_for_a_described_machine() { // The model that needs 24 GB per device is listed first on the roomy GPU and dropped on the tight one. assert_eq!( recommend(&roomy, &[]), - "Coding and agents\n MODEL PROFILE TARGET STATUS CONTEXT TOKENS\n Fixture Big Fixture Q4 Fixture GPU X fits now 174080\n Fixture Small Fixture Q4 Fixture GPU X fits now 314368\n\nGeneral assistant\nNo recommendation for this machine\n\nTranslation\nNo recommendation for this machine\n\nDecision making\nNo recommendation for this machine\n" + "Coding\n MODEL PROFILE TARGET STATUS CONTEXT TOKENS\n Fixture Big Fixture Q4 Fixture GPU X fits now 174080\n Fixture Small Fixture Q4 Fixture GPU X fits now 314368\n\nChat assistant\n MODEL PROFILE TARGET STATUS CONTEXT TOKENS\n Fixture Small Fixture Q4 Fixture GPU X fits now 314368\n\nImage generation\nNo recommendation for this machine\n" ); assert_eq!( recommend(&tight, &["--use-case", "coding"]), - "Coding and agents\n MODEL PROFILE TARGET STATUS CONTEXT TOKENS\n Fixture Small Fixture Q4 Fixture GPU X fits now 59392\n" + "Coding\n MODEL PROFILE TARGET STATUS CONTEXT TOKENS\n Fixture Small Fixture Q4 Fixture GPU X fits now 59392\n" + ); + assert_eq!( + recommend(&tight, &["--use-case", "chat"]), + "Chat assistant\n MODEL PROFILE TARGET STATUS CONTEXT TOKENS\n Fixture Small Fixture Q4 Fixture GPU X fits now 59392\n" + ); + assert_eq!( + recommend(&tight, &["--use-case", "image"]), + "Image generation\nNo recommendation for this machine\n" ); } diff --git a/backend/cli/tests/support/fake_manager.rs b/backend/cli/tests/support/fake_manager.rs index 2b6d9e36..5da93a97 100644 --- a/backend/cli/tests/support/fake_manager.rs +++ b/backend/cli/tests/support/fake_manager.rs @@ -725,6 +725,13 @@ impl ApplicationDeploymentService for FakeApplications { ))) } + async fn list_application_targets( + &self, + _request: Request, + ) -> Result, Status> { + Err(not_faked()) + } + async fn update_application_deployment_metadata( &self, _request: Request, diff --git a/backend/core/src/application/controller/test_support.rs b/backend/core/src/application/controller/test_support.rs index e2c76ba4..20616c07 100644 --- a/backend/core/src/application/controller/test_support.rs +++ b/backend/core/src/application/controller/test_support.rs @@ -3023,6 +3023,8 @@ pub(super) fn application() -> Application { description: String::new(), notes: String::new(), version: "1.0.0".to_owned(), + use_cases: Vec::new(), + preferred_accelerators: Vec::new(), graph: Some(ApplicationGraph { services: vec![ApplicationService { service_name: "web".to_owned(), diff --git a/backend/core/src/application/repository.rs b/backend/core/src/application/repository.rs index 09818443..3b0748a2 100644 --- a/backend/core/src/application/repository.rs +++ b/backend/core/src/application/repository.rs @@ -602,6 +602,8 @@ fn validate_resolved_snapshot( notes: snapshot.notes.clone(), version: snapshot.version.clone(), graph: snapshot.graph.clone(), + use_cases: Vec::new(), + preferred_accelerators: Vec::new(), }; if !validate_application_with_policy(&application, OciArtifactPolicy::Development).is_empty() || snapshot.placement.as_ref().is_none_or(|placement| { @@ -1462,6 +1464,8 @@ mod tests { description: String::new(), notes: String::new(), version: "1.0.0".to_owned(), + use_cases: Vec::new(), + preferred_accelerators: Vec::new(), graph: Some(ApplicationGraph { services: vec![ApplicationService { service_name: "service".to_owned(), diff --git a/backend/core/src/application/service.rs b/backend/core/src/application/service.rs index 377428e4..24d31aae 100644 --- a/backend/core/src/application/service.rs +++ b/backend/core/src/application/service.rs @@ -10,7 +10,8 @@ use dotcore_proto::dotcore::application::v1::{ CreateApplicationDeploymentRequest, CreateApplicationDeploymentResponse, FollowDeploymentLogsRequest, FollowDeploymentLogsResponse, GetApplicationDeploymentRequest, GetApplicationDeploymentResponse, ListApplicationDeploymentsRequest, - ListApplicationDeploymentsResponse, PauseDeploymentRequest, PauseDeploymentResponse, + ListApplicationDeploymentsResponse, ListApplicationTargetsRequest, + ListApplicationTargetsResponse, PauseDeploymentRequest, PauseDeploymentResponse, ReadDeploymentLogsRequest, ReadDeploymentLogsResponse, RemoveApplicationDeploymentRequest, RemoveApplicationDeploymentResponse, ResumeDeploymentRequest, ResumeDeploymentResponse, UpdateApplicationDeploymentMetadataRequest, UpdateApplicationDeploymentMetadataResponse, @@ -86,6 +87,16 @@ where .map_err(|error| error.to_tonic_status()) } + async fn list_application_targets( + &self, + _request: Request, + ) -> Result, Status> { + // TODO: answer from the apps controller's application target read. + Ok(Response::new(ListApplicationTargetsResponse { + targets: Vec::new(), + })) + } + async fn get_application_deployment( &self, request: Request, diff --git a/backend/core/src/composition/isolated.rs b/backend/core/src/composition/isolated.rs index 120ee2c2..3b298346 100644 --- a/backend/core/src/composition/isolated.rs +++ b/backend/core/src/composition/isolated.rs @@ -16,8 +16,8 @@ use dotcore_proto::dotcore::{ FollowDeploymentLogsRequest as FollowApplicationLogsRequest, GetApplicationDeploymentRequest, GetApplicationDeploymentResponse, ListApplicationDeploymentsRequest, ListApplicationDeploymentsResponse, - PauseDeploymentRequest, PauseDeploymentResponse, - ReadDeploymentLogsRequest as ReadApplicationLogsRequest, + ListApplicationTargetsRequest, ListApplicationTargetsResponse, PauseDeploymentRequest, + PauseDeploymentResponse, ReadDeploymentLogsRequest as ReadApplicationLogsRequest, ReadDeploymentLogsResponse as ReadApplicationLogsResponse, RemoveApplicationDeploymentRequest, RemoveApplicationDeploymentResponse, ResumeDeploymentRequest, ResumeDeploymentResponse, @@ -276,6 +276,13 @@ impl ApplicationDeploymentServiceContract for IsolatedApplicationService { self.service.list_application_deployments(request).await } + async fn list_application_targets( + &self, + request: Request, + ) -> Result, Status> { + self.service.list_application_targets(request).await + } + async fn get_application_deployment( &self, request: Request, diff --git a/backend/core/src/model/recommendation/machine.rs b/backend/core/src/model/recommendation/machine.rs index 71c84f5f..3f8bcf17 100644 --- a/backend/core/src/model/recommendation/machine.rs +++ b/backend/core/src/model/recommendation/machine.rs @@ -192,10 +192,7 @@ mod tests { assert_eq!( rows( &mac, - &mac.recommend( - &catalog, - &[UseCase::CodingAndAgents, UseCase::DecisionMaking] - ) + &mac.recommend(&catalog, &[UseCase::Coding, UseCase::ChatAssistant]) ), [ vec![ @@ -203,7 +200,10 @@ mod tests { "mid/q4 on Fixture Apple GPU: FitsNow from 454656", "small/q4 on Fixture Apple GPU: FitsNow from 494592", ], - vec!["decider/q8 on Fixture Apple GPU: FitsNow from 504832"], + vec![ + "mid/q4 on Fixture Apple GPU: FitsNow from 454656", + "small/q4 on Fixture Apple GPU: FitsNow from 494592", + ], ] ); } @@ -220,7 +220,7 @@ mod tests { assert_eq!( rows( &gpu, - &gpu.recommend(&catalog, &[UseCase::CodingAndAgents, UseCase::Translation]) + &gpu.recommend(&catalog, &[UseCase::Coding, UseCase::ChatAssistant]) ), [ vec![ diff --git a/backend/core/src/model/recommendation/tests.rs b/backend/core/src/model/recommendation/tests.rs index 7366e95f..b716f6ca 100644 --- a/backend/core/src/model/recommendation/tests.rs +++ b/backend/core/src/model/recommendation/tests.rs @@ -24,7 +24,7 @@ use dotcore_site::node::target_inventory::{ use super::{RecommendationFit, UseCaseRecommendations, recommend_models}; use RecommendationFit::{FitsNow, NeedsMemoryFreed, Unknown, Unreachable}; -use UseCase::{CodingAndAgents, DecisionMaking, GeneralAssistant, Translation}; +use UseCase::{ChatAssistant, Coding, ImageGeneration}; /// Expected recommendations for one site of declared machines under one occupancy and use case. struct Case { @@ -65,30 +65,24 @@ type Row = ( fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy() { let cases = [ // 8 GiB GPU: the GPU-model entry is unsupported and the entries above its memory or their - // minimum are dropped; translation and decision making fall back to the vendor class. + // minimum are dropped; the chat assistant falls back to the vendor class. Case { machines: &["s8"], occupancy: Occupancy::Idle, - use_case: CodingAndAgents, + use_case: Coding, rows: vec![("small/q4", vec![("s8", FitsNow, false, 59_392)])], }, Case { machines: &["s8"], occupancy: Occupancy::Idle, - use_case: Translation, + use_case: ChatAssistant, rows: vec![("small/q4", vec![("s8", FitsNow, false, 59_392)])], }, - Case { - machines: &["s8"], - occupancy: Occupancy::Idle, - use_case: DecisionMaking, - rows: vec![("decider/q8", vec![("s8", FitsNow, false, 69_632)])], - }, // 32 GiB GPU of the model the GPU-model class names: its only configuration comes first. Case { machines: &["x32"], occupancy: Occupancy::Idle, - use_case: CodingAndAgents, + use_case: Coding, rows: vec![ ("x-only/x", vec![("x32", FitsNow, false, 293_888)]), ("big/q4", vec![("x32", FitsNow, false, 174_080)]), @@ -99,24 +93,18 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy Case { machines: &["x32"], occupancy: Occupancy::Idle, - use_case: Translation, + use_case: ChatAssistant, rows: vec![ ("mid/q4", vec![("x32", FitsNow, false, 274_432)]), ("small/q4", vec![("x32", FitsNow, false, 314_368)]), ], }, - Case { - machines: &["x32"], - occupancy: Occupancy::Idle, - use_case: DecisionMaking, - rows: vec![("decider/q8", vec![("x32", FitsNow, false, 324_608)])], - }, // The running model is deployed and a second one needs its memory freed; it starts from // the profile default without room for a largest fit. Case { machines: &["x32"], occupancy: Occupancy::Running("big", "q4"), - use_case: CodingAndAgents, + use_case: Coding, rows: vec![ ("x-only/x", vec![("x32", FitsNow, false, 101_376)]), ("big/q4", vec![("x32", NeedsMemoryFreed, true, 32_768)]), @@ -127,7 +115,7 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy Case { machines: &["x32"], occupancy: Occupancy::Running("big", "q4"), - use_case: Translation, + use_case: ChatAssistant, rows: vec![ ("mid/q4", vec![("x32", FitsNow, false, 81_920)]), ("small/q4", vec![("x32", FitsNow, false, 121_856)]), @@ -137,25 +125,19 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy Case { machines: &["mac16"], occupancy: Occupancy::Idle, - use_case: CodingAndAgents, + use_case: Coding, rows: vec![("small/q4", vec![("mac16", FitsNow, false, 87_040)])], }, Case { machines: &["mac16"], occupancy: Occupancy::Idle, - use_case: Translation, + use_case: ChatAssistant, rows: vec![("small/q4", vec![("mac16", FitsNow, false, 87_040)])], }, - Case { - machines: &["mac16"], - occupancy: Occupancy::Idle, - use_case: DecisionMaking, - rows: vec![("decider/q8", vec![("mac16", FitsNow, false, 97_280)])], - }, Case { machines: &["mac32"], occupancy: Occupancy::Idle, - use_case: CodingAndAgents, + use_case: Coding, rows: vec![ ("mid/q4", vec![("mac32", FitsNow, false, 176_128)]), ("small/q4", vec![("mac32", FitsNow, false, 216_064)]), @@ -164,7 +146,7 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy Case { machines: &["mac32"], occupancy: Occupancy::Idle, - use_case: Translation, + use_case: ChatAssistant, rows: vec![ ("mid/q4", vec![("mac32", FitsNow, false, 176_128)]), ("small/q4", vec![("mac32", FitsNow, false, 216_064)]), @@ -173,7 +155,7 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy Case { machines: &["mac64"], occupancy: Occupancy::Idle, - use_case: CodingAndAgents, + use_case: Coding, rows: vec![ ("big/q4", vec![("mac64", FitsNow, false, 355_328)]), ("mid/q4", vec![("mac64", FitsNow, false, 454_656)]), @@ -183,24 +165,18 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy Case { machines: &["mac64"], occupancy: Occupancy::Running("mid", "q4"), - use_case: CodingAndAgents, + use_case: Coding, rows: vec![ ("big/q4", vec![("mac64", FitsNow, false, 262_144)]), ("mid/q4", vec![("mac64", FitsNow, true, 362_496)]), ("small/q4", vec![("mac64", FitsNow, false, 402_432)]), ], }, - Case { - machines: &["mac64"], - occupancy: Occupancy::Idle, - use_case: DecisionMaking, - rows: vec![("decider/q8", vec![("mac64", FitsNow, false, 504_832)])], - }, - // Unified 128 GiB: its own coding list, the vendor class's translation list. + // Unified 128 GiB: its own coding list, the vendor class's chat assistant list. Case { machines: &["uni128"], occupancy: Occupancy::Idle, - use_case: CodingAndAgents, + use_case: Coding, rows: vec![ ("big/q4", vec![("uni128", FitsNow, false, 1_020_928)]), ("mid/q4", vec![("uni128", FitsNow, false, 1_120_256)]), @@ -209,24 +185,18 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy Case { machines: &["uni128"], occupancy: Occupancy::Idle, - use_case: Translation, + use_case: ChatAssistant, rows: vec![ ("mid/q4", vec![("uni128", FitsNow, false, 1_120_256)]), ("small/q4", vec![("uni128", FitsNow, false, 1_160_192)]), ], }, - Case { - machines: &["uni128"], - occupancy: Occupancy::Idle, - use_case: DecisionMaking, - rows: vec![("decider/q8", vec![("uni128", FitsNow, false, 1_170_432)])], - }, // A deployment of unknown size still starting leaves free memory unknown, while the // total memory still decides what is kept. Case { machines: &["uni128"], occupancy: Occupancy::StartingUnsized, - use_case: CodingAndAgents, + use_case: Coding, rows: vec![ ("big/q4", vec![("uni128", Unknown, false, 32_768)]), ("mid/q4", vec![("uni128", Unknown, false, 32_768)]), @@ -237,7 +207,7 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy Case { machines: &["mac64", "x32"], occupancy: Occupancy::Idle, - use_case: CodingAndAgents, + use_case: Coding, rows: vec![ ("x-only/x", vec![("x32", FitsNow, false, 293_888)]), ( @@ -266,7 +236,7 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy Case { machines: &["mac64", "x32"], occupancy: Occupancy::Idle, - use_case: Translation, + use_case: ChatAssistant, rows: vec![ ( "mid/q4", @@ -284,11 +254,11 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy ), ], }, - // No class has a list for the general assistant. + // Image generation has no model lists. Case { machines: &["mac64", "x32"], occupancy: Occupancy::Idle, - use_case: GeneralAssistant, + use_case: ImageGeneration, rows: vec![], }, // A machine that stopped reporting keeps the rows its last reported hardware holds, each @@ -296,7 +266,7 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy Case { machines: &["x32"], occupancy: Occupancy::Stale, - use_case: CodingAndAgents, + use_case: Coding, rows: vec![ ("x-only/x", vec![("x32", Unreachable, false, 32_768)]), ("big/q4", vec![("x32", Unreachable, false, 32_768)]), @@ -309,7 +279,7 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy Case { machines: &["x32"], occupancy: Occupancy::StaleRunning("big", "q4"), - use_case: CodingAndAgents, + use_case: Coding, rows: vec![ ("x-only/x", vec![("x32", Unreachable, false, 32_768)]), ("big/q4", vec![("x32", Unreachable, true, 32_768)]), @@ -321,7 +291,7 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy Case { machines: &["mac64", "x32"], occupancy: Occupancy::Stale, - use_case: CodingAndAgents, + use_case: Coding, rows: vec![ ("x-only/x", vec![("x32", FitsNow, false, 293_888)]), ( @@ -389,21 +359,21 @@ fn every_use_case_asked_gets_its_own_list_in_the_order_asked() { &targets, Some(&inventory), &deployments, - &[DecisionMaking, GeneralAssistant, CodingAndAgents], + &[ImageGeneration, ChatAssistant, Coding], ); assert_eq!( recommendations.iter().map(rows).collect::>(), [ - owned(vec![("decider/q8", vec![("s8", FitsNow, false, 69_632)])]), Vec::new(), owned(vec![("small/q4", vec![("s8", FitsNow, false, 59_392)])]), + owned(vec![("small/q4", vec![("s8", FitsNow, false, 59_392)])]), ] ); // Without an inventory every target is unknown and nothing is recommended. assert_eq!( - recommend_models(&catalog, &targets, None, &deployments, &[CodingAndAgents],) + recommend_models(&catalog, &targets, None, &deployments, &[Coding],) .iter() .map(rows) .collect::>(), @@ -712,7 +682,6 @@ struct FixtureModel { weight_bytes: u64, intelligence: u32, classes: &'static [&'static str], - request_kind: &'static str, profile: &'static str, } @@ -722,7 +691,6 @@ const MODELS: &[FixtureModel] = &[ weight_bytes: 4_000_000_000, intelligence: 45, classes: &["nvidia-x"], - request_kind: "INFERENCE_REQUEST_KIND_CHAT", profile: "x", }, FixtureModel { @@ -730,7 +698,6 @@ const MODELS: &[FixtureModel] = &[ weight_bytes: 16_000_000_000, intelligence: 40, classes: CHAT_CLASSES, - request_kind: "INFERENCE_REQUEST_KIND_CHAT", profile: "q4", }, FixtureModel { @@ -738,7 +705,6 @@ const MODELS: &[FixtureModel] = &[ weight_bytes: 6_000_000_000, intelligence: 25, classes: CHAT_CLASSES, - request_kind: "INFERENCE_REQUEST_KIND_CHAT", profile: "q4", }, FixtureModel { @@ -746,17 +712,8 @@ const MODELS: &[FixtureModel] = &[ weight_bytes: 2_000_000_000, intelligence: 10, classes: CHAT_CLASSES, - request_kind: "INFERENCE_REQUEST_KIND_CHAT", profile: "q4", }, - FixtureModel { - id: "decider", - weight_bytes: 1_000_000_000, - intelligence: 5, - classes: CHAT_CLASSES, - request_kind: "INFERENCE_REQUEST_KIND_DECISION", - profile: "q8", - }, ]; const CHAT_CLASSES: &[&str] = &["nvidia-dedicated", "nvidia-unified", "apple"]; @@ -837,7 +794,6 @@ fn model_yaml(model: &FixtureModel) -> String { weight_bytes: base, intelligence, classes, - request_kind, profile, } = model; let support = classes @@ -890,7 +846,7 @@ configurations: engineKind: "ENGINE_KIND_LLAMA_CPP" llamaCpp: {{ fixedSettings: {{ gpuLayers: 0 }} }} executionCapabilities: {{ supportedParallelShapes: ["MODEL_PARALLEL_SHAPE_ONE"] }} - requestKinds: ["{request_kind}"] + requestKinds: ["INFERENCE_REQUEST_KIND_CHAT"] inputCapabilities: {{ supportedModalities: ["MODEL_INPUT_MODALITY_TEXT"] }} profiles: - profileId: "{profile}" @@ -942,20 +898,17 @@ artifacts: const NVIDIA_LISTS: &str = r#" hardwareClassId: "nvidia" useCases: - - useCase: "USE_CASE_TRANSLATION" + - useCase: "USE_CASE_CHAT_ASSISTANT" entries: - { modelId: "mid", profileId: "q4" } - { modelId: "small", profileId: "q4" } - - useCase: "USE_CASE_DECISION_MAKING" - entries: - - { modelId: "decider", profileId: "q8" } "#; /// Dedicated GPUs' coding list: the GPU-model entry first, then minimums on the GPU's memory. const DEDICATED_LISTS: &str = r#" hardwareClassId: "nvidia-dedicated" useCases: - - useCase: "USE_CASE_CODING_AND_AGENTS" + - useCase: "USE_CASE_CODING" entries: - { modelId: "x-only", profileId: "x" } - { modelId: "big", profileId: "q4", hardwareRequirements: { minimumMemoryBytesPerDevice: "24000000000" } } @@ -966,7 +919,7 @@ useCases: const UNIFIED_LISTS: &str = r#" hardwareClassId: "nvidia-unified" useCases: - - useCase: "USE_CASE_CODING_AND_AGENTS" + - useCase: "USE_CASE_CODING" entries: - { modelId: "big", profileId: "q4" } - { modelId: "mid", profileId: "q4" } @@ -976,16 +929,13 @@ useCases: const APPLE_LISTS: &str = r#" hardwareClassId: "apple" useCases: - - useCase: "USE_CASE_CODING_AND_AGENTS" + - useCase: "USE_CASE_CODING" entries: - { modelId: "big", profileId: "q4", hardwareRequirements: { minimumSystemMemoryBytes: "48000000000" } } - { modelId: "mid", profileId: "q4", hardwareRequirements: { minimumSystemMemoryBytes: "24000000000" } } - { modelId: "small", profileId: "q4" } - - useCase: "USE_CASE_TRANSLATION" + - useCase: "USE_CASE_CHAT_ASSISTANT" entries: - { modelId: "mid", profileId: "q4", hardwareRequirements: { minimumSystemMemoryBytes: "24000000000" } } - { modelId: "small", profileId: "q4" } - - useCase: "USE_CASE_DECISION_MAKING" - entries: - - { modelId: "decider", profileId: "q8" } "#; diff --git a/backend/core/src/model/repository.rs b/backend/core/src/model/repository.rs index ab7767fa..32bae216 100644 --- a/backend/core/src/model/repository.rs +++ b/backend/core/src/model/repository.rs @@ -697,6 +697,8 @@ fn recommendations_response( .map(|row| RecommendedModelProfile { model_id: row.model_id, profile_id: row.profile_id, + // TODO: the recommender returns each row's context per request. + context_tokens_per_request: 0, targets: row .targets .into_iter() @@ -713,7 +715,8 @@ fn recommendations_response( } } .into(), - deployed: target.deployed, + // TODO: the recommender returns the deployment id. + model_deployment_id: None, starting_total_context_tokens: target.starting_total_context_tokens, }) .collect(), diff --git a/backend/core/src/model/service.rs b/backend/core/src/model/service.rs index fcd807a2..769d7ad0 100644 --- a/backend/core/src/model/service.rs +++ b/backend/core/src/model/service.rs @@ -864,11 +864,10 @@ fn parse_model_recommendations_request( } /// Every use case a recommendations read ranks when the request names none. -const ALL_USE_CASES: [UseCase; 4] = [ - UseCase::CodingAndAgents, - UseCase::GeneralAssistant, - UseCase::Translation, - UseCase::DecisionMaking, +const ALL_USE_CASES: [UseCase; 3] = [ + UseCase::Coding, + UseCase::ChatAssistant, + UseCase::ImageGeneration, ]; /// Validated compatibility read selection: the model, the target and the sizing scenario. @@ -1348,29 +1347,29 @@ mod tests { let parse = |use_cases: &[i32]| { super::parse_model_recommendations_request(ListModelRecommendationsRequest { use_cases: use_cases.to_vec(), + can_start_now: false, }) }; assert_eq!( parse(&[]).expect("all use cases"), [ - UseCase::CodingAndAgents, - UseCase::GeneralAssistant, - UseCase::Translation, - UseCase::DecisionMaking, + UseCase::Coding, + UseCase::ChatAssistant, + UseCase::ImageGeneration, ] ); assert_eq!( parse(&[ - UseCase::Translation as i32, - UseCase::CodingAndAgents as i32, - UseCase::Translation as i32, + UseCase::ImageGeneration as i32, + UseCase::Coding as i32, + UseCase::ImageGeneration as i32, ]) .expect("named use cases"), - [UseCase::Translation, UseCase::CodingAndAgents] + [UseCase::ImageGeneration, UseCase::Coding] ); for invalid in [UseCase::Unspecified as i32, 99] { - let status = parse(&[UseCase::Translation as i32, invalid]).expect_err("invalid"); + let status = parse(&[UseCase::ImageGeneration as i32, invalid]).expect_err("invalid"); assert_eq!(status.code(), tonic::Code::InvalidArgument); assert_eq!( status.message(), diff --git a/backend/core/tests/backend/backend_integration.rs b/backend/core/tests/backend/backend_integration.rs index 7c9bf21a..1b4170d3 100644 --- a/backend/core/tests/backend/backend_integration.rs +++ b/backend/core/tests/backend/backend_integration.rs @@ -5043,7 +5043,7 @@ async fn profile_compatibility_reads_the_simulated_site_through_the_model_servic } /// Master catalog copy whose only recommendations are the synthetic ones the recommendation read -/// test expects: coding lists the fixture profile on the fixture class, and decision making lists +/// test expects: coding lists the fixture profile on the fixture class, and the other use cases list /// nothing. fn recommendation_master_catalog(root: &Path) -> PathBuf { let master = root.join("cache/recommendation-master-catalog"); @@ -5055,7 +5055,7 @@ fn recommendation_master_catalog(root: &Path) -> PathBuf { "fixture-nvidia", r#"hardwareClassId: "fixture-nvidia" useCases: - - useCase: "USE_CASE_CODING_AND_AGENTS" + - useCase: "USE_CASE_CODING" entries: - modelId: "fixture-model" profileId: "fixture-profile" @@ -5081,6 +5081,7 @@ async fn model_recommendations_rank_the_simulated_site_through_the_model_service ModelServiceContract::list_model_recommendations( &service, Request::new(ListModelRecommendationsRequest { + can_start_now: false, use_cases: use_cases .into_iter() .map(|use_case| use_case as i32) @@ -5092,7 +5093,7 @@ async fn model_recommendations_rank_the_simulated_site_through_the_model_service // The manager's own node reports its first observation asynchronously after composition. let deadline = Instant::now() + Duration::from_secs(10); let response = loop { - let response = read(vec![UseCase::CodingAndAgents]) + let response = read(vec![UseCase::Coding]) .await .expect("recommendation read") .into_inner(); @@ -5129,21 +5130,22 @@ async fn model_recommendations_rank_the_simulated_site_through_the_model_service assert_eq!( response.use_cases, [UseCaseModelRecommendations { - use_case: UseCase::CodingAndAgents as i32, + use_case: UseCase::Coding as i32, rows: vec![RecommendedModelProfile { model_id: "fixture-model".to_owned(), profile_id: "fixture-profile".to_owned(), + context_tokens_per_request: 0, targets: vec![RecommendedProfileTarget { target: Some(support::exact_placement("fixture-node", "fixture-target")), fit: ModelRecommendationFit::FitsNow as i32, - deployed: false, + model_deployment_id: None, starting_total_context_tokens: Some(starting_total_context_tokens), }], }], }] ); - // No use case asks for all four, each ranked even when nothing is recommended. + // No use case asks for all three, each ranked even when nothing is recommended. let all = read(Vec::new()).await.expect("all use cases").into_inner(); assert_eq!( all.use_cases @@ -5151,14 +5153,13 @@ async fn model_recommendations_rank_the_simulated_site_through_the_model_service .map(|ranked| (ranked.use_case, ranked.rows.len())) .collect::>(), [ - (UseCase::CodingAndAgents as i32, 1), - (UseCase::GeneralAssistant as i32, 0), - (UseCase::Translation as i32, 0), - (UseCase::DecisionMaking as i32, 0), + (UseCase::Coding as i32, 1), + (UseCase::ChatAssistant as i32, 0), + (UseCase::ImageGeneration as i32, 0), ] ); - let unspecified = read(vec![UseCase::CodingAndAgents, UseCase::Unspecified]) + let unspecified = read(vec![UseCase::Coding, UseCase::Unspecified]) .await .expect_err("unspecified use case"); assert_eq!(unspecified.code(), tonic::Code::InvalidArgument); diff --git a/backend/core/tests/backend/catalog_contracts.rs b/backend/core/tests/backend/catalog_contracts.rs index 1d8e1e38..6c6115d2 100644 --- a/backend/core/tests/backend/catalog_contracts.rs +++ b/backend/core/tests/backend/catalog_contracts.rs @@ -433,7 +433,7 @@ fn repository_recommendation_entries_fit_idle_machines_of_their_class() { assert_eq!(failures, Vec::::new()); } -/// ninfer leads coding and agents on each card of the GB202 chip it is built for, and is never +/// ninfer leads coding on each card of the GB202 chip it is built for, and is never /// offered on another dedicated GPU, even one with more memory. #[test] fn repository_ninfer_leads_coding_on_gb202_cards_only() { @@ -443,7 +443,7 @@ fn repository_ninfer_leads_coding_on_gb202_cards_only() { let coding = |machine: &dotcore_node_hardware::MachineDescription| { IdleMachine::new(machine, Timing::test().node_control) .expect("idle simulated machine") - .recommend(&catalog, &[UseCase::CodingAndAgents]) + .recommend(&catalog, &[UseCase::Coding]) .into_iter() .flat_map(|recommendations| recommendations.rows) .map(|row| (row.model_id, row.profile_id)) @@ -487,7 +487,7 @@ fn recommendation_entry_checks_fail_on_a_minimum_set_too_low() { format!( r#"hardwareClassId: "nvidia-dedicated" useCases: - - useCase: "USE_CASE_CODING_AND_AGENTS" + - useCase: "USE_CASE_CODING" entries: - {{ modelId: "big", profileId: "q4", hardwareRequirements: {{ minimumMemoryBytesPerDevice: "{minimum}" }} }} - {{ modelId: "small", profileId: "q4" }} @@ -520,7 +520,7 @@ useCases: assert_eq!( recommendation_entry_failures(&catalog_with("8000000000")), [ - "recommendations/nvidia-dedicated.yaml, USE_CASE_CODING_AND_AGENTS, big/q4: \ + "recommendations/nvidia-dedicated.yaml, USE_CASE_CODING, big/q4: \ not recommended on idle simulated hardware rtx-4080, rtx-4090, rtx-5090, rtx-pro-6000 \ resized to its minimum of 8000000000 bytes" ] diff --git a/backend/fixtures/recommendation-catalog/recommendations/nvidia-dedicated.yaml b/backend/fixtures/recommendation-catalog/recommendations/nvidia-dedicated.yaml index 480a4898..6688d5a6 100644 --- a/backend/fixtures/recommendation-catalog/recommendations/nvidia-dedicated.yaml +++ b/backend/fixtures/recommendation-catalog/recommendations/nvidia-dedicated.yaml @@ -1,6 +1,9 @@ hardwareClassId: "nvidia-dedicated" useCases: - - useCase: "USE_CASE_CODING_AND_AGENTS" + - useCase: "USE_CASE_CODING" entries: - { modelId: "big", profileId: "q4", hardwareRequirements: { minimumMemoryBytesPerDevice: "24000000000" } } - { modelId: "small", profileId: "q4" } + - useCase: "USE_CASE_CHAT_ASSISTANT" + entries: + - { modelId: "small", profileId: "q4" } diff --git a/backend/proto/tests/application_contract.rs b/backend/proto/tests/application_contract.rs index 8a60aeeb..47c0d9e5 100644 --- a/backend/proto/tests/application_contract.rs +++ b/backend/proto/tests/application_contract.rs @@ -402,6 +402,11 @@ fn application_descriptors_define_portable_graph_and_owner_separated_records() { "UpdateApplicationDeploymentMetadataRequest", "UpdateApplicationDeploymentMetadataResponse", ), + ( + "ListApplicationTargets", + "ListApplicationTargetsRequest", + "ListApplicationTargetsResponse", + ), ], ); // The application contracts share types through owner-neutral packages, never the model diff --git a/backend/proto/tests/model_contract.rs b/backend/proto/tests/model_contract.rs index e7d72a5c..84908345 100644 --- a/backend/proto/tests/model_contract.rs +++ b/backend/proto/tests/model_contract.rs @@ -1385,14 +1385,26 @@ fn model_descriptors_define_typed_configuration_and_resolved_plan_contracts() { ); assert_eq!( field_layout(service, "ListModelRecommendationsRequest"), - [("use_cases", 1, ".dotcore.catalog.v1.UseCase")] + [ + ("use_cases", 1, ".dotcore.catalog.v1.UseCase"), + ("can_start_now", 2, "TYPE_BOOL"), + ] + ); + assert_eq!( + field_layout(service, "RecommendedModelProfile"), + [ + ("model_id", 1, "TYPE_STRING"), + ("profile_id", 2, "TYPE_STRING"), + ("context_tokens_per_request", 4, "TYPE_UINT64"), + ("targets", 3, ".dotcore.model.v1.RecommendedProfileTarget"), + ] ); assert_eq!( field_layout(service, "RecommendedProfileTarget"), [ ("target", 1, ".dotcore.deployment.v1.ExactPlacement"), ("fit", 2, ".dotcore.model.v1.ModelRecommendationFit"), - ("deployed", 3, "TYPE_BOOL"), + ("model_deployment_id", 3, "TYPE_STRING"), ("starting_total_context_tokens", 4, "TYPE_UINT64"), ] ); diff --git a/backend/proto/tests/site_contract.rs b/backend/proto/tests/site_contract.rs index 5671f938..3e36a5e7 100644 --- a/backend/proto/tests/site_contract.rs +++ b/backend/proto/tests/site_contract.rs @@ -560,6 +560,8 @@ fn complete_application() -> Application { display_name: "Application One".to_owned(), description: String::new(), notes: "First line.\nSecond line.".to_owned(), + use_cases: Vec::new(), + preferred_accelerators: Vec::new(), version: "1.0.0".to_owned(), graph: Some(ApplicationGraph { services: vec![ From 67751c97c11d634e9cf8e9fc66b653ace5a90bcc Mon Sep 17 00:00:00 2001 From: Andre-Philippe Paquet Date: Wed, 11 Nov 2026 14:41:56 -0500 Subject: [PATCH 03/25] feat(catalog): rename recommendation use cases --- catalog/recommendations/apple.yaml | 24 +++---------------- catalog/recommendations/nvidia-dedicated.yaml | 16 ++----------- catalog/recommendations/nvidia-unified.yaml | 16 ++++--------- catalog/recommendations/nvidia.yaml | 10 -------- 4 files changed, 9 insertions(+), 57 deletions(-) delete mode 100644 catalog/recommendations/nvidia.yaml diff --git a/catalog/recommendations/apple.yaml b/catalog/recommendations/apple.yaml index 06688bf8..d852042e 100644 --- a/catalog/recommendations/apple.yaml +++ b/catalog/recommendations/apple.yaml @@ -1,9 +1,9 @@ # Model recommendations for Apple Silicon Macs, best first. Minimums on system memory separate Mac -# sizes: 16, 24, 32, 64 and 128 GB. Memory bandwidth limits generation, so the general assistant +# sizes: 16, 24, 32, 64 and 128 GB. Memory bandwidth limits generation, so the chat assistant # prefers a mixture-of-experts model on Macs large enough for it. hardwareClassId: "apple" useCases: - - useCase: "USE_CASE_CODING_AND_AGENTS" + - useCase: "USE_CASE_CODING" entries: - modelId: "qwen3-8-27b" profileId: "q5-xl" @@ -19,7 +19,7 @@ useCases: minimumSystemMemoryBytes: "16000000000" - modelId: "qwen3-5-4b" profileId: "q4-xl" - - useCase: "USE_CASE_GENERAL_ASSISTANT" + - useCase: "USE_CASE_CHAT_ASSISTANT" entries: - modelId: "qwen3-6-35b-a3b" profileId: "q4-xl" @@ -35,21 +35,3 @@ useCases: minimumSystemMemoryBytes: "16000000000" - modelId: "gemma-4-e4b-it-qat" profileId: "gpu" - - useCase: "USE_CASE_TRANSLATION" - entries: - - modelId: "gemma-4-26b-a4b-it" - profileId: "q4-xl" - hardwareRequirements: - minimumSystemMemoryBytes: "32000000000" - - modelId: "gemma-4-12b-it-qat" - profileId: "gpu" - hardwareRequirements: - minimumSystemMemoryBytes: "16000000000" - - modelId: "gemma-4-e4b-it-qat" - profileId: "gpu" - - useCase: "USE_CASE_DECISION_MAKING" - entries: - - modelId: "clef-flash" - profileId: "q8-0" - - modelId: "clef-flash" - profileId: "q4-k-m" diff --git a/catalog/recommendations/nvidia-dedicated.yaml b/catalog/recommendations/nvidia-dedicated.yaml index f51e1841..b76c9b5c 100644 --- a/catalog/recommendations/nvidia-dedicated.yaml +++ b/catalog/recommendations/nvidia-dedicated.yaml @@ -3,7 +3,7 @@ # skipped for it. hardwareClassId: "nvidia-dedicated" useCases: - - useCase: "USE_CASE_CODING_AND_AGENTS" + - useCase: "USE_CASE_CODING" entries: - modelId: "qwen3-8-27b" profileId: "ninfer-nvfp4" @@ -17,7 +17,7 @@ useCases: minimumMemoryBytesPerDevice: "12000000000" - modelId: "qwen3-5-4b" profileId: "q4-xl" - - useCase: "USE_CASE_GENERAL_ASSISTANT" + - useCase: "USE_CASE_CHAT_ASSISTANT" entries: - modelId: "qwen3-8-27b" profileId: "ninfer-nvfp4" @@ -31,15 +31,3 @@ useCases: minimumMemoryBytesPerDevice: "12000000000" - modelId: "qwen3-5-4b" profileId: "q4-xl" - - useCase: "USE_CASE_TRANSLATION" - entries: - - modelId: "gemma-4-26b-a4b-it" - profileId: "q4-xl" - hardwareRequirements: - minimumMemoryBytesPerDevice: "24000000000" - - modelId: "gemma-4-12b-it-qat" - profileId: "gpu" - hardwareRequirements: - minimumMemoryBytesPerDevice: "12000000000" - - modelId: "qwen3-5-4b" - profileId: "q4-xl" diff --git a/catalog/recommendations/nvidia-unified.yaml b/catalog/recommendations/nvidia-unified.yaml index edeaa9ca..8f081345 100644 --- a/catalog/recommendations/nvidia-unified.yaml +++ b/catalog/recommendations/nvidia-unified.yaml @@ -1,9 +1,9 @@ # Model recommendations for NVIDIA GPUs sharing the host's memory, such as the GB10, best first. -# Memory bandwidth limits generation there, so the general assistant prefers mixture-of-experts -# models, which read fewer weights per token. Decision making uses the nvidia list. +# Memory bandwidth limits generation there, so the chat assistant prefers mixture-of-experts +# models, which read fewer weights per token. hardwareClassId: "nvidia-unified" useCases: - - useCase: "USE_CASE_CODING_AND_AGENTS" + - useCase: "USE_CASE_CODING" entries: - modelId: "qwen3-8-27b" profileId: "nvfp4-text" @@ -11,7 +11,7 @@ useCases: profileId: "nvfp4" - modelId: "qwen3-5-9b" profileId: "q4-xl" - - useCase: "USE_CASE_GENERAL_ASSISTANT" + - useCase: "USE_CASE_CHAT_ASSISTANT" entries: - modelId: "ornith-1-5-35b-a3b" profileId: "nvfp4-multimodal" @@ -19,11 +19,3 @@ useCases: profileId: "nvfp4-multimodal" - modelId: "gemma-4-12b-it-qat" profileId: "gpu" - - useCase: "USE_CASE_TRANSLATION" - entries: - - modelId: "gemma-4-31b-it" - profileId: "nvfp4" - - modelId: "gemma-4-26b-a4b-it" - profileId: "nvfp4" - - modelId: "gemma-4-12b-it-qat" - profileId: "gpu" diff --git a/catalog/recommendations/nvidia.yaml b/catalog/recommendations/nvidia.yaml deleted file mode 100644 index 8a358c23..00000000 --- a/catalog/recommendations/nvidia.yaml +++ /dev/null @@ -1,10 +0,0 @@ -# Model recommendations shared by every NVIDIA target. Dedicated and unified targets use their own -# class's list for a use case first and fall back to this one. -hardwareClassId: "nvidia" -useCases: - - useCase: "USE_CASE_DECISION_MAKING" - entries: - - modelId: "clef-flash" - profileId: "q8-0" - - modelId: "clef-flash" - profileId: "q4-k-m" From 4b3685c77f64438fd308856268f7cf91f585af50 Mon Sep 17 00:00:00 2001 From: Andre-Philippe Paquet Date: Wed, 11 Nov 2026 14:41:56 -0500 Subject: [PATCH 04/25] feat(backend): size recommendations with context per request --- backend/catalog/src/lib.rs | 24 ++ .../catalog/src/validation/recommendation.rs | 21 +- backend/core/src/model/recommendation/mod.rs | 100 +++++-- .../core/src/model/recommendation/tests.rs | 275 ++++++++++++++---- backend/core/src/model/repository.rs | 3 +- backend/core/src/model/sizing_scenario/mod.rs | 16 + .../core/tests/backend/backend_integration.rs | 3 +- 7 files changed, 355 insertions(+), 87 deletions(-) diff --git a/backend/catalog/src/lib.rs b/backend/catalog/src/lib.rs index 32497481..ddd0c068 100644 --- a/backend/catalog/src/lib.rs +++ b/backend/catalog/src/lib.rs @@ -2365,6 +2365,30 @@ useCases: )], format!("recommendations/nvidia.yaml: model recommendations for hardware class \"nvidia\" {entry} sets minimum memory per device 8000000000 bytes, below its configuration's 16000000000 bytes"), ), + ( + "context per request above the maximum context", + nvidia_support, + vec![( + "nvidia", + file("nvidia", &[assistant(&balanced)]).replace( + "profileId: \"balanced\"\n", + "profileId: \"balanced\"\n contextTokensPerRequest: 262145\n", + ), + )], + format!("recommendations/nvidia.yaml: model recommendations for hardware class \"nvidia\" {entry} sets context per request 262145 tokens, above its configuration's maximum context of 262144 tokens"), + ), + ( + "context per request below the minimum context", + nvidia_support, + vec![( + "nvidia", + file("nvidia", &[assistant(&balanced)]).replace( + "profileId: \"balanced\"\n", + "profileId: \"balanced\"\n contextTokensPerRequest: 1023\n", + ), + )], + format!("recommendations/nvidia.yaml: model recommendations for hardware class \"nvidia\" {entry} sets context per request 1023 tokens, below the minimum context of 1024 tokens"), + ), ( "repeated entry", nvidia_support, diff --git a/backend/catalog/src/validation/recommendation.rs b/backend/catalog/src/validation/recommendation.rs index d150d423..852e7cf3 100644 --- a/backend/catalog/src/validation/recommendation.rs +++ b/backend/catalog/src/validation/recommendation.rs @@ -2,7 +2,8 @@ //! they name. //! //! Each hardware class's file lists, per use case, models and profiles that must be able to run -//! on that class and accept the requests the use case sends. The classes that declare +//! on that class and accept the requests the use case sends, each with an optional context per +//! request within its configuration's context limits. The classes that declare //! recommendations must be disjoint or strictly nested, so the list a target uses is never //! ambiguous. Every problem is reported rather than only the first. @@ -14,7 +15,10 @@ use dotcore_proto::dotcore::catalog::v1::{ }; use super::{CatalogDiagnostic, model::non_nested_classes}; -use crate::selector::{SelectorRelation, selector_relation}; +use crate::{ + selector::{SelectorRelation, selector_relation}, + shared_parameters::{MINIMUM_CONTEXT_TOKENS, maximum_context_tokens}, +}; pub(super) fn validate_recommendations( recommendations: &HardwareClassRecommendations, @@ -144,6 +148,19 @@ fn validate_entry( )); } + if let Some(context_tokens) = entry.context_tokens_per_request { + let maximum = maximum_context_tokens(model, configuration); + if context_tokens < MINIMUM_CONTEXT_TOKENS { + errors.push(anyhow::anyhow!( + "{label} sets context per request {context_tokens} tokens, below the minimum context of {MINIMUM_CONTEXT_TOKENS} tokens" + )); + } else if context_tokens > u64::from(maximum) { + errors.push(anyhow::anyhow!( + "{label} sets context per request {context_tokens} tokens, above its configuration's maximum context of {maximum} tokens" + )); + } + } + if let Some(requirements) = entry.hardware_requirements.as_ref() && !related_support.is_empty() { diff --git a/backend/core/src/model/recommendation/mod.rs b/backend/core/src/model/recommendation/mod.rs index 19339f00..b8a1fe98 100644 --- a/backend/core/src/model/recommendation/mod.rs +++ b/backend/core/src/model/recommendation/mod.rs @@ -3,9 +3,11 @@ //! //! Each target reads the list of the most specific hardware class that has one for the use case, //! and each entry is judged with the same profile resolution and memory fit as the compatibility -//! read, the entry's own minimums added to its configuration's. Entries that can't run on a target -//! or exceed its total memory are left out there, and the rest merge across targets by their best -//! declared position. The answer is advisory: it never admits, reserves, or places a deployment. +//! read, the entry's own minimums added to its configuration's. An entry is sized for one request +//! using its whole total context, at the entry's context per request or else the profile's +//! default. Entries that can't run on a target or exceed its total memory are left out there, and +//! the rest merge across targets by their best declared position and context per request. The +//! answer is advisory: it never admits, reserves, or places a deployment. use std::{cmp::Ordering, collections::BTreeMap}; @@ -16,7 +18,10 @@ use dotcore_proto::dotcore::{ UseCase, }, deployment::v1::{ExactPlacement, MemoryFit}, - model::v1::{ModelCompatibilityStatus, ModelDeployment, model_deployment_snapshot}, + model::v1::{ + ModelCompatibilityStatus, ModelDeployment, SizingScenarioUnsupportedReason, + model_deployment_snapshot, + }, site::v1::ExecutionTarget, }; use dotcore_site::node::target_inventory::TargetCapabilityInventory; @@ -36,7 +41,9 @@ use crate::{ evaluate_profile, node_workloads, }, engine::EngineIntegrationRegistry, - sizing_scenario::{ProfileSizing, size_profile}, + sizing_scenario::{ + ProfileSizing, SizingScenario, default_context_per_request, size_profile, + }, }, }; @@ -55,6 +62,10 @@ pub struct Recommendation { pub profile_id: String, + /// Context per request in tokens the profile is sized with: the entry's own, else the + /// profile's default. + pub context_tokens_per_request: u64, + /// Targets whose list has the profile and that can run it, by node then target. pub targets: Vec, } @@ -128,12 +139,12 @@ pub(crate) fn recommend_models( } /// What one call reads for every use case: the catalog, its declarations indexed by id, and each -/// profile's sizing once it has been needed. +/// profile's sizing at each context per request once it has been needed. struct Recommender<'a> { catalog: &'a Catalog, declarations: &'a Declarations, registry: EngineIntegrationRegistry, - sizings: BTreeMap<(String, String), ProfileSizing>, + sizings: BTreeMap, } impl Recommender<'_> { @@ -153,7 +164,7 @@ impl Recommender<'_> { hardware_requirements: None, }) .collect::>(); - let mut rows = BTreeMap::<(String, String), RankedRow>::new(); + let mut rows = BTreeMap::::new(); for checked in checked { let Ok(selected) = &checked.check else { continue; @@ -174,8 +185,11 @@ impl Recommender<'_> { let Some((model, profile)) = declarations.profile(entry) else { continue; }; + let Some(scenario) = entry_scenario(self.catalog, model, profile, entry) else { + continue; + }; let Some((fit, starting_total_context_tokens)) = - self.judge(model, profile, entry, checked.target, selected) + self.judge(model, profile, entry, scenario, checked.target, selected) else { continue; }; @@ -189,7 +203,7 @@ impl Recommender<'_> { execution_target_id: checked.target.execution_target_id.clone(), }; let row = rows - .entry((entry.model_id.clone(), entry.profile_id.clone())) + .entry(row_key(entry, scenario)) .or_insert_with(|| RankedRow { position, intelligence: model @@ -210,21 +224,22 @@ impl Recommender<'_> { ranked(rows) } - /// Judges one entry on one target with the compatibility read's evaluation, its minimums - /// added to its configuration's: its fit and starting total context there, or none when it is - /// left out there. + /// Judges one entry sized for `scenario` on one target with the compatibility read's + /// evaluation, its minimums added to its configuration's: its fit and starting total context + /// there, or none when it is left out there. fn judge( &mut self, model: &Model, profile: &ModelProfile, entry: &ModelRecommendation, + scenario: SizingScenario, target: &ExecutionTarget, selected: &SelectedTargetCapabilities, ) -> Option<(RecommendationFit, Option)> { let sizing = self .sizings - .entry((entry.model_id.clone(), entry.profile_id.clone())) - .or_insert_with(|| size_profile(self.catalog, model, profile, None)); + .entry(row_key(entry, scenario)) + .or_insert_with(|| size_profile(self.catalog, model, profile, Some(&scenario))); let evaluation = evaluate_profile( self.catalog, &self.registry, @@ -241,6 +256,9 @@ impl Recommender<'_> { } } +/// A row's model id, profile id and context per request in tokens; also keys each sizing. +type RowKey = (String, String, u64); + /// One row while targets are merged: its best declared position, its model's intelligence score, /// and the targets that can run it. struct RankedRow { @@ -300,9 +318,35 @@ impl Declarations { } } +/// The sizing scenario an entry is judged with: one request using the whole total context, both +/// the entry's context per request or else the profile's default. Returns none when the profile's +/// defaults don't resolve. +fn entry_scenario( + catalog: &Catalog, + model: &Model, + profile: &ModelProfile, + entry: &ModelRecommendation, +) -> Option { + let context_tokens = entry + .context_tokens_per_request + .or_else(|| default_context_per_request(catalog, model, profile))?; + // Catalog validation keeps an entry's context within the configuration's maximum context. + let context_tokens = u32::try_from(context_tokens).ok()?; + SizingScenario::new(context_tokens, Some(context_tokens)).ok() +} + +/// The row an entry sized for `scenario` merges into. +fn row_key(entry: &ModelRecommendation, scenario: SizingScenario) -> RowKey { + ( + entry.model_id.clone(), + entry.profile_id.clone(), + scenario.total_context_tokens(), + ) +} + /// The fit of an evaluated entry on a target and the total context a deployment starts from -/// there, or none when the entry is left out there: its profile can't run there, or its memory -/// fit, with the entry's minimums, exceeds the target's total. +/// there, or none when the entry is left out there: its profile can't run there or serve its +/// context there, or its memory fit, with the entry's minimums, exceeds the target's total. fn target_fit(evaluation: &ProfileEvaluation) -> Option<(RecommendationFit, Option)> { let running = match evaluation { ProfileEvaluation::Runs(running) => running, @@ -312,6 +356,9 @@ fn target_fit(evaluation: &ProfileEvaluation) -> Option<(RecommendationFit, Opti } ProfileEvaluation::Fails(_) => return None, }; + if running.fit.unsupported_reason != SizingScenarioUnsupportedReason::Unspecified { + return None; + } let fit = match (running.fit.memory_fit, running.fit.free_memory_fit) { (MemoryFit::Exceeds, _) => return None, (MemoryFit::Fits, MemoryFit::Fits) => RecommendationFit::FitsNow, @@ -347,9 +394,9 @@ fn deployed( }) } -/// Orders merged rows by best declared position, then higher intelligence score, then model and -/// profile id. -fn ranked(rows: BTreeMap<(String, String), RankedRow>) -> Vec { +/// Orders merged rows by best declared position, then higher intelligence score, then model id, +/// profile id and context per request. +fn ranked(rows: BTreeMap) -> Vec { let mut rows = rows.into_iter().collect::>(); rows.sort_by(|(left_id, left), (right_id, right)| { left.position @@ -363,11 +410,14 @@ fn ranked(rows: BTreeMap<(String, String), RankedRow>) -> Vec { .then_with(|| left_id.cmp(right_id)) }); rows.into_iter() - .map(|((model_id, profile_id), row)| Recommendation { - model_id, - profile_id, - targets: row.targets, - }) + .map( + |((model_id, profile_id, context_tokens_per_request), row)| Recommendation { + model_id, + profile_id, + context_tokens_per_request, + targets: row.targets, + }, + ) .collect() } diff --git a/backend/core/src/model/recommendation/tests.rs b/backend/core/src/model/recommendation/tests.rs index b716f6ca..4efbc13f 100644 --- a/backend/core/src/model/recommendation/tests.rs +++ b/backend/core/src/model/recommendation/tests.rs @@ -54,10 +54,11 @@ enum Occupancy { StaleRunning(&'static str, &'static str), } -/// One expected row: `model/profile`, then each target with its fit, whether the profile is -/// deployed there, and the total context a deployment starts from. +/// One expected row: `model/profile`, its context per request, then each target with its fit, +/// whether the profile is deployed there, and the total context a deployment starts from. type Row = ( &'static str, + u64, Vec<(&'static str, RecommendationFit, bool, u64)>, ); @@ -70,13 +71,13 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy machines: &["s8"], occupancy: Occupancy::Idle, use_case: Coding, - rows: vec![("small/q4", vec![("s8", FitsNow, false, 59_392)])], + rows: vec![("small/q4", 32_768, vec![("s8", FitsNow, false, 59_392)])], }, Case { machines: &["s8"], occupancy: Occupancy::Idle, use_case: ChatAssistant, - rows: vec![("small/q4", vec![("s8", FitsNow, false, 59_392)])], + rows: vec![("small/q4", 32_768, vec![("s8", FitsNow, false, 59_392)])], }, // 32 GiB GPU of the model the GPU-model class names: its only configuration comes first. Case { @@ -84,10 +85,10 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy occupancy: Occupancy::Idle, use_case: Coding, rows: vec![ - ("x-only/x", vec![("x32", FitsNow, false, 293_888)]), - ("big/q4", vec![("x32", FitsNow, false, 174_080)]), - ("mid/q4", vec![("x32", FitsNow, false, 274_432)]), - ("small/q4", vec![("x32", FitsNow, false, 314_368)]), + ("x-only/x", 32_768, vec![("x32", FitsNow, false, 293_888)]), + ("big/q4", 32_768, vec![("x32", FitsNow, false, 174_080)]), + ("mid/q4", 32_768, vec![("x32", FitsNow, false, 274_432)]), + ("small/q4", 32_768, vec![("x32", FitsNow, false, 314_368)]), ], }, Case { @@ -95,8 +96,8 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy occupancy: Occupancy::Idle, use_case: ChatAssistant, rows: vec![ - ("mid/q4", vec![("x32", FitsNow, false, 274_432)]), - ("small/q4", vec![("x32", FitsNow, false, 314_368)]), + ("mid/q4", 32_768, vec![("x32", FitsNow, false, 274_432)]), + ("small/q4", 32_768, vec![("x32", FitsNow, false, 314_368)]), ], }, // The running model is deployed and a second one needs its memory freed; it starts from @@ -106,10 +107,14 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy occupancy: Occupancy::Running("big", "q4"), use_case: Coding, rows: vec![ - ("x-only/x", vec![("x32", FitsNow, false, 101_376)]), - ("big/q4", vec![("x32", NeedsMemoryFreed, true, 32_768)]), - ("mid/q4", vec![("x32", FitsNow, false, 81_920)]), - ("small/q4", vec![("x32", FitsNow, false, 121_856)]), + ("x-only/x", 32_768, vec![("x32", FitsNow, false, 101_376)]), + ( + "big/q4", + 32_768, + vec![("x32", NeedsMemoryFreed, true, 32_768)], + ), + ("mid/q4", 32_768, vec![("x32", FitsNow, false, 81_920)]), + ("small/q4", 32_768, vec![("x32", FitsNow, false, 121_856)]), ], }, Case { @@ -117,8 +122,8 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy occupancy: Occupancy::Running("big", "q4"), use_case: ChatAssistant, rows: vec![ - ("mid/q4", vec![("x32", FitsNow, false, 81_920)]), - ("small/q4", vec![("x32", FitsNow, false, 121_856)]), + ("mid/q4", 32_768, vec![("x32", FitsNow, false, 81_920)]), + ("small/q4", 32_768, vec![("x32", FitsNow, false, 121_856)]), ], }, // Macs: entry minimums on system memory separate 16, 32 and 64 GiB. @@ -126,21 +131,21 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy machines: &["mac16"], occupancy: Occupancy::Idle, use_case: Coding, - rows: vec![("small/q4", vec![("mac16", FitsNow, false, 87_040)])], + rows: vec![("small/q4", 32_768, vec![("mac16", FitsNow, false, 87_040)])], }, Case { machines: &["mac16"], occupancy: Occupancy::Idle, use_case: ChatAssistant, - rows: vec![("small/q4", vec![("mac16", FitsNow, false, 87_040)])], + rows: vec![("small/q4", 32_768, vec![("mac16", FitsNow, false, 87_040)])], }, Case { machines: &["mac32"], occupancy: Occupancy::Idle, use_case: Coding, rows: vec![ - ("mid/q4", vec![("mac32", FitsNow, false, 176_128)]), - ("small/q4", vec![("mac32", FitsNow, false, 216_064)]), + ("mid/q4", 32_768, vec![("mac32", FitsNow, false, 176_128)]), + ("small/q4", 32_768, vec![("mac32", FitsNow, false, 216_064)]), ], }, Case { @@ -148,8 +153,8 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy occupancy: Occupancy::Idle, use_case: ChatAssistant, rows: vec![ - ("mid/q4", vec![("mac32", FitsNow, false, 176_128)]), - ("small/q4", vec![("mac32", FitsNow, false, 216_064)]), + ("mid/q4", 32_768, vec![("mac32", FitsNow, false, 176_128)]), + ("small/q4", 32_768, vec![("mac32", FitsNow, false, 216_064)]), ], }, Case { @@ -157,9 +162,9 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy occupancy: Occupancy::Idle, use_case: Coding, rows: vec![ - ("big/q4", vec![("mac64", FitsNow, false, 355_328)]), - ("mid/q4", vec![("mac64", FitsNow, false, 454_656)]), - ("small/q4", vec![("mac64", FitsNow, false, 494_592)]), + ("big/q4", 32_768, vec![("mac64", FitsNow, false, 355_328)]), + ("mid/q4", 32_768, vec![("mac64", FitsNow, false, 454_656)]), + ("small/q4", 32_768, vec![("mac64", FitsNow, false, 494_592)]), ], }, Case { @@ -167,9 +172,9 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy occupancy: Occupancy::Running("mid", "q4"), use_case: Coding, rows: vec![ - ("big/q4", vec![("mac64", FitsNow, false, 262_144)]), - ("mid/q4", vec![("mac64", FitsNow, true, 362_496)]), - ("small/q4", vec![("mac64", FitsNow, false, 402_432)]), + ("big/q4", 32_768, vec![("mac64", FitsNow, false, 262_144)]), + ("mid/q4", 32_768, vec![("mac64", FitsNow, true, 362_496)]), + ("small/q4", 32_768, vec![("mac64", FitsNow, false, 402_432)]), ], }, // Unified 128 GiB: its own coding list, the vendor class's chat assistant list. @@ -178,8 +183,16 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy occupancy: Occupancy::Idle, use_case: Coding, rows: vec![ - ("big/q4", vec![("uni128", FitsNow, false, 1_020_928)]), - ("mid/q4", vec![("uni128", FitsNow, false, 1_120_256)]), + ( + "big/q4", + 32_768, + vec![("uni128", FitsNow, false, 1_020_928)], + ), + ( + "mid/q4", + 32_768, + vec![("uni128", FitsNow, false, 1_120_256)], + ), ], }, Case { @@ -187,8 +200,16 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy occupancy: Occupancy::Idle, use_case: ChatAssistant, rows: vec![ - ("mid/q4", vec![("uni128", FitsNow, false, 1_120_256)]), - ("small/q4", vec![("uni128", FitsNow, false, 1_160_192)]), + ( + "mid/q4", + 32_768, + vec![("uni128", FitsNow, false, 1_120_256)], + ), + ( + "small/q4", + 32_768, + vec![("uni128", FitsNow, false, 1_160_192)], + ), ], }, // A deployment of unknown size still starting leaves free memory unknown, while the @@ -198,8 +219,8 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy occupancy: Occupancy::StartingUnsized, use_case: Coding, rows: vec![ - ("big/q4", vec![("uni128", Unknown, false, 32_768)]), - ("mid/q4", vec![("uni128", Unknown, false, 32_768)]), + ("big/q4", 32_768, vec![("uni128", Unknown, false, 32_768)]), + ("mid/q4", 32_768, vec![("uni128", Unknown, false, 32_768)]), ], }, // Two machines: rows merge by their best position in either list, then the higher @@ -209,9 +230,10 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy occupancy: Occupancy::Idle, use_case: Coding, rows: vec![ - ("x-only/x", vec![("x32", FitsNow, false, 293_888)]), + ("x-only/x", 32_768, vec![("x32", FitsNow, false, 293_888)]), ( "big/q4", + 32_768, vec![ ("mac64", FitsNow, false, 355_328), ("x32", FitsNow, false, 174_080), @@ -219,6 +241,7 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy ), ( "mid/q4", + 32_768, vec![ ("mac64", FitsNow, false, 454_656), ("x32", FitsNow, false, 274_432), @@ -226,6 +249,7 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy ), ( "small/q4", + 32_768, vec![ ("mac64", FitsNow, false, 494_592), ("x32", FitsNow, false, 314_368), @@ -240,6 +264,7 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy rows: vec![ ( "mid/q4", + 32_768, vec![ ("mac64", FitsNow, false, 454_656), ("x32", FitsNow, false, 274_432), @@ -247,6 +272,7 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy ), ( "small/q4", + 32_768, vec![ ("mac64", FitsNow, false, 494_592), ("x32", FitsNow, false, 314_368), @@ -268,10 +294,18 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy occupancy: Occupancy::Stale, use_case: Coding, rows: vec![ - ("x-only/x", vec![("x32", Unreachable, false, 32_768)]), - ("big/q4", vec![("x32", Unreachable, false, 32_768)]), - ("mid/q4", vec![("x32", Unreachable, false, 32_768)]), - ("small/q4", vec![("x32", Unreachable, false, 32_768)]), + ( + "x-only/x", + 32_768, + vec![("x32", Unreachable, false, 32_768)], + ), + ("big/q4", 32_768, vec![("x32", Unreachable, false, 32_768)]), + ("mid/q4", 32_768, vec![("x32", Unreachable, false, 32_768)]), + ( + "small/q4", + 32_768, + vec![("x32", Unreachable, false, 32_768)], + ), ], }, // What runs on an unreachable machine is deployed there, and never drops an entry its @@ -281,10 +315,18 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy occupancy: Occupancy::StaleRunning("big", "q4"), use_case: Coding, rows: vec![ - ("x-only/x", vec![("x32", Unreachable, false, 32_768)]), - ("big/q4", vec![("x32", Unreachable, true, 32_768)]), - ("mid/q4", vec![("x32", Unreachable, false, 32_768)]), - ("small/q4", vec![("x32", Unreachable, false, 32_768)]), + ( + "x-only/x", + 32_768, + vec![("x32", Unreachable, false, 32_768)], + ), + ("big/q4", 32_768, vec![("x32", Unreachable, true, 32_768)]), + ("mid/q4", 32_768, vec![("x32", Unreachable, false, 32_768)]), + ( + "small/q4", + 32_768, + vec![("x32", Unreachable, false, 32_768)], + ), ], }, // An unreachable machine beside a reachable one: each target keeps its own fit in a row. @@ -293,9 +335,10 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy occupancy: Occupancy::Stale, use_case: Coding, rows: vec![ - ("x-only/x", vec![("x32", FitsNow, false, 293_888)]), + ("x-only/x", 32_768, vec![("x32", FitsNow, false, 293_888)]), ( "big/q4", + 32_768, vec![ ("mac64", Unreachable, false, 32_768), ("x32", FitsNow, false, 174_080), @@ -303,6 +346,7 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy ), ( "mid/q4", + 32_768, vec![ ("mac64", Unreachable, false, 32_768), ("x32", FitsNow, false, 274_432), @@ -310,6 +354,7 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy ), ( "small/q4", + 32_768, vec![ ("mac64", Unreachable, false, 32_768), ("x32", FitsNow, false, 314_368), @@ -366,8 +411,16 @@ fn every_use_case_asked_gets_its_own_list_in_the_order_asked() { recommendations.iter().map(rows).collect::>(), [ Vec::new(), - owned(vec![("small/q4", vec![("s8", FitsNow, false, 59_392)])]), - owned(vec![("small/q4", vec![("s8", FitsNow, false, 59_392)])]), + owned(vec![( + "small/q4", + 32_768, + vec![("s8", FitsNow, false, 59_392)] + )]), + owned(vec![( + "small/q4", + 32_768, + vec![("s8", FitsNow, false, 59_392)] + )]), ] ); @@ -381,6 +434,93 @@ fn every_use_case_asked_gets_its_own_list_in_the_order_asked() { ); } +#[test] +fn entries_are_sized_with_their_context_per_request() { + // The dedicated coding entry asks for 131072 tokens per request, the dedicated chat entry + // leaves it to the profile's default, and the unified coding entry asks for 32768. + let catalog = catalog_with_lists(&[ + ( + "nvidia-dedicated", + r#" +hardwareClassId: "nvidia-dedicated" +useCases: + - useCase: "USE_CASE_CODING" + entries: + - { modelId: "mid", profileId: "q4", contextTokensPerRequest: 131072 } + - useCase: "USE_CASE_CHAT_ASSISTANT" + entries: + - { modelId: "mid", profileId: "q4" } +"#, + ), + ( + "nvidia-unified", + r#" +hardwareClassId: "nvidia-unified" +useCases: + - useCase: "USE_CASE_CODING" + entries: + - { modelId: "mid", profileId: "q4", contextTokensPerRequest: 32768 } +"#, + ), + ]); + let read = |machines, occupancy| { + let (targets, inventory, deployments) = site(machines, occupancy); + recommend_models( + &catalog, + &targets, + Some(&inventory), + &deployments, + &[Coding, ChatAssistant], + ) + .iter() + .map(rows) + .collect::>() + }; + + // Idle 32 GiB GPU: both contexts fit, and the starting total context, bounded by free memory + // alone, is the same. + assert_eq!( + read(&["x32"], Occupancy::Idle), + [ + owned(vec![( + "mid/q4", + 131_072, + vec![("x32", FitsNow, false, 274_432)] + )]), + owned(vec![( + "mid/q4", + 32_768, + vec![("x32", FitsNow, false, 274_432)] + )]), + ] + ); + + // Beside a running model, 131072 tokens per request need memory freed where 32768 fit, and + // the two coding contexts stay separate rows. + assert_eq!( + read(&["x32", "uni128"], Occupancy::Running("big", "q4")), + [ + owned(vec![ + ( + "mid/q4", + 32_768, + vec![("uni128", FitsNow, false, 1_120_256)] + ), + ( + "mid/q4", + 131_072, + vec![("x32", NeedsMemoryFreed, false, 32_768)] + ), + ]), + owned(vec![( + "mid/q4", + 32_768, + vec![("x32", FitsNow, false, 81_920)] + )]), + ] + ); +} + /// The rows of one use case's recommendations, in the form `Row` describes. fn rows(recommendations: &UseCaseRecommendations) -> Vec { recommendations @@ -399,7 +539,11 @@ fn rows(recommendations: &UseCaseRecommendations) -> Vec { ) }) .collect(); - (format!("{}/{}", row.model_id, row.profile_id), targets) + ( + format!("{}/{}", row.model_id, row.profile_id), + row.context_tokens_per_request, + targets, + ) }) .collect() } @@ -407,19 +551,23 @@ fn rows(recommendations: &UseCaseRecommendations) -> Vec { /// Expected rows in the form `rows` reads them. fn owned(rows: Vec) -> Vec { rows.into_iter() - .map(|(name, targets)| { + .map(|(name, context_tokens_per_request, targets)| { let targets = targets .into_iter() .map(|(target, fit, deployed, starting)| { (target.to_owned(), fit, deployed, Some(starting)) }) .collect(); - (name.to_owned(), targets) + (name.to_owned(), context_tokens_per_request, targets) }) .collect() } -type OwnedRow = (String, Vec<(String, RecommendationFit, bool, Option)>); +type OwnedRow = ( + String, + u64, + Vec<(String, RecommendationFit, bool, Option)>, +); /// One target per machine, named after it, with the inventory the machines report under /// `occupancy` and the site's deployments. @@ -722,6 +870,17 @@ const CHAT_CLASSES: &[&str] = &["nvidia-dedicated", "nvidia-unified", "apple"]; /// one llama.cpp engine for linux/amd64, linux/arm64 and darwin/arm64, the fixture models, and /// their recommendation lists. pub(super) fn catalog() -> Catalog { + catalog_with_lists(&[ + ("nvidia", NVIDIA_LISTS), + ("nvidia-dedicated", DEDICATED_LISTS), + ("nvidia-unified", UNIFIED_LISTS), + ("apple", APPLE_LISTS), + ]) +} + +/// Writes and loads the made-up catalog with `lists`, each a hardware class id and its +/// recommendation file. +fn catalog_with_lists(lists: &[(&str, &str)]) -> Catalog { let root = tempfile::tempdir().expect("catalog root"); let mut files = vec![ ("hardware-classes/nvidia.yaml", class("nvidia", "")), @@ -749,14 +908,16 @@ pub(super) fn catalog() -> Catalog { .to_owned(), ), ("engines/llama.yaml", ENGINE.to_owned()), - ("recommendations/nvidia.yaml", NVIDIA_LISTS.to_owned()), - ( - "recommendations/nvidia-dedicated.yaml", - DEDICATED_LISTS.to_owned(), - ), - ("recommendations/nvidia-unified.yaml", UNIFIED_LISTS.to_owned()), - ("recommendations/apple.yaml", APPLE_LISTS.to_owned()), ]; + let list_files = lists + .iter() + .map(|(class, body)| (format!("recommendations/{class}.yaml"), (*body).to_owned())) + .collect::>(); + files.extend( + list_files + .iter() + .map(|(path, body)| (path.as_str(), body.clone())), + ); let models = MODELS .iter() .map(|model| (format!("models/{}.yaml", model.id), model_yaml(model))) diff --git a/backend/core/src/model/repository.rs b/backend/core/src/model/repository.rs index 32bae216..564abb31 100644 --- a/backend/core/src/model/repository.rs +++ b/backend/core/src/model/repository.rs @@ -697,8 +697,7 @@ fn recommendations_response( .map(|row| RecommendedModelProfile { model_id: row.model_id, profile_id: row.profile_id, - // TODO: the recommender returns each row's context per request. - context_tokens_per_request: 0, + context_tokens_per_request: row.context_tokens_per_request, targets: row .targets .into_iter() diff --git a/backend/core/src/model/sizing_scenario/mod.rs b/backend/core/src/model/sizing_scenario/mod.rs index 8969b702..701bc330 100644 --- a/backend/core/src/model/sizing_scenario/mod.rs +++ b/backend/core/src/model/sizing_scenario/mod.rs @@ -262,6 +262,22 @@ pub(crate) fn size_profile( } } +/// Context per request a profile's defaults resolve to: the engine's default total context, capped +/// at the configuration's maximum context. Returns none when the defaults don't resolve or hold no +/// total context. +pub(crate) fn default_context_per_request( + catalog: &Catalog, + model: &Model, + profile: &ModelProfile, +) -> Option { + let (defaults, declarations) = profile_defaults(catalog, model, profile)?; + let values = with_filled_context_per_request( + &declarations, + defaults.engine_parameter_values.unwrap_or_default(), + ); + unsigned_parameter_value(&values, CONTEXT_PER_REQUEST_PARAMETER_ID) +} + /// Judges the memory fit of a profile that runs with its defaults, resolved as `snapshot`, on the /// target capabilities `selected_target` the compatibility read selected, and the largest total /// context that fits there. Both fits compare one estimate, of the profile's sizing scenario on the diff --git a/backend/core/tests/backend/backend_integration.rs b/backend/core/tests/backend/backend_integration.rs index 1b4170d3..01b3173c 100644 --- a/backend/core/tests/backend/backend_integration.rs +++ b/backend/core/tests/backend/backend_integration.rs @@ -5134,7 +5134,8 @@ async fn model_recommendations_rank_the_simulated_site_through_the_model_service rows: vec![RecommendedModelProfile { model_id: "fixture-model".to_owned(), profile_id: "fixture-profile".to_owned(), - context_tokens_per_request: 0, + // The entry sets none, so the row reads the profile's default. + context_tokens_per_request: 32_768, targets: vec![RecommendedProfileTarget { target: Some(support::exact_placement("fixture-node", "fixture-target")), fit: ModelRecommendationFit::FitsNow as i32, From 5c1e28b12228b9593efd4db1830eaf723e120701 Mon Sep 17 00:00:00 2001 From: Andre-Philippe Paquet Date: Wed, 11 Nov 2026 14:51:13 -0500 Subject: [PATCH 05/25] feat(catalog): set context per request on recommendations --- catalog/recommendations/apple.yaml | 8 ++++++++ catalog/recommendations/nvidia-dedicated.yaml | 8 ++++++++ catalog/recommendations/nvidia-unified.yaml | 6 ++++++ 3 files changed, 22 insertions(+) diff --git a/catalog/recommendations/apple.yaml b/catalog/recommendations/apple.yaml index d852042e..b52acb91 100644 --- a/catalog/recommendations/apple.yaml +++ b/catalog/recommendations/apple.yaml @@ -7,31 +7,39 @@ useCases: entries: - modelId: "qwen3-8-27b" profileId: "q5-xl" + contextTokensPerRequest: 131072 hardwareRequirements: minimumSystemMemoryBytes: "64000000000" - modelId: "qwen3-8-27b" profileId: "q4-xl" + contextTokensPerRequest: 131072 hardwareRequirements: minimumSystemMemoryBytes: "32000000000" - modelId: "qwen3-5-9b" profileId: "q4-xl" + contextTokensPerRequest: 131072 hardwareRequirements: minimumSystemMemoryBytes: "16000000000" - modelId: "qwen3-5-4b" profileId: "q4-xl" + contextTokensPerRequest: 131072 - useCase: "USE_CASE_CHAT_ASSISTANT" entries: - modelId: "qwen3-6-35b-a3b" profileId: "q4-xl" + contextTokensPerRequest: 32768 hardwareRequirements: minimumSystemMemoryBytes: "64000000000" - modelId: "qwen3-8-27b" profileId: "q4-xl" + contextTokensPerRequest: 32768 hardwareRequirements: minimumSystemMemoryBytes: "32000000000" - modelId: "gemma-4-12b-it-qat" profileId: "gpu" + contextTokensPerRequest: 32768 hardwareRequirements: minimumSystemMemoryBytes: "16000000000" - modelId: "gemma-4-e4b-it-qat" profileId: "gpu" + contextTokensPerRequest: 32768 diff --git a/catalog/recommendations/nvidia-dedicated.yaml b/catalog/recommendations/nvidia-dedicated.yaml index b76c9b5c..ec4aa28d 100644 --- a/catalog/recommendations/nvidia-dedicated.yaml +++ b/catalog/recommendations/nvidia-dedicated.yaml @@ -7,27 +7,35 @@ useCases: entries: - modelId: "qwen3-8-27b" profileId: "ninfer-nvfp4" + contextTokensPerRequest: 131072 - modelId: "qwen3-8-27b" profileId: "q4-xl" + contextTokensPerRequest: 131072 hardwareRequirements: minimumMemoryBytesPerDevice: "24000000000" - modelId: "qwen3-5-9b" profileId: "q4-xl" + contextTokensPerRequest: 131072 hardwareRequirements: minimumMemoryBytesPerDevice: "12000000000" - modelId: "qwen3-5-4b" profileId: "q4-xl" + contextTokensPerRequest: 131072 - useCase: "USE_CASE_CHAT_ASSISTANT" entries: - modelId: "qwen3-8-27b" profileId: "ninfer-nvfp4" + contextTokensPerRequest: 32768 - modelId: "qwen3-8-27b" profileId: "q4-xl" + contextTokensPerRequest: 32768 hardwareRequirements: minimumMemoryBytesPerDevice: "24000000000" - modelId: "gemma-4-12b-it-qat" profileId: "gpu" + contextTokensPerRequest: 32768 hardwareRequirements: minimumMemoryBytesPerDevice: "12000000000" - modelId: "qwen3-5-4b" profileId: "q4-xl" + contextTokensPerRequest: 32768 diff --git a/catalog/recommendations/nvidia-unified.yaml b/catalog/recommendations/nvidia-unified.yaml index 8f081345..51673bc3 100644 --- a/catalog/recommendations/nvidia-unified.yaml +++ b/catalog/recommendations/nvidia-unified.yaml @@ -7,15 +7,21 @@ useCases: entries: - modelId: "qwen3-8-27b" profileId: "nvfp4-text" + contextTokensPerRequest: 131072 - modelId: "qwen3-6-35b-a3b" profileId: "nvfp4" + contextTokensPerRequest: 131072 - modelId: "qwen3-5-9b" profileId: "q4-xl" + contextTokensPerRequest: 131072 - useCase: "USE_CASE_CHAT_ASSISTANT" entries: - modelId: "ornith-1-5-35b-a3b" profileId: "nvfp4-multimodal" + contextTokensPerRequest: 32768 - modelId: "qwen3-8-27b" profileId: "nvfp4-multimodal" + contextTokensPerRequest: 32768 - modelId: "gemma-4-12b-it-qat" profileId: "gpu" + contextTokensPerRequest: 32768 From da8142a4520dee46836e43fa77741b5dc1846a65 Mon Sep 17 00:00:00 2001 From: Andre-Philippe Paquet Date: Wed, 11 Nov 2026 14:51:13 -0500 Subject: [PATCH 06/25] feat(backend): add can start now to model recommendations --- backend/core/src/model/compatibility.rs | 38 +- backend/core/src/model/controller/mod.rs | 10 +- .../core/src/model/controller/operations.rs | 7 +- .../core/src/model/recommendation/machine.rs | 3 +- backend/core/src/model/recommendation/mod.rs | 115 +++-- .../core/src/model/recommendation/tests.rs | 421 +++++++++++++----- backend/core/src/model/repository.rs | 18 +- backend/core/src/model/service.rs | 63 ++- .../core/tests/backend/backend_integration.rs | 52 ++- 9 files changed, 526 insertions(+), 201 deletions(-) diff --git a/backend/core/src/model/compatibility.rs b/backend/core/src/model/compatibility.rs index 8dd0122b..90d92d25 100644 --- a/backend/core/src/model/compatibility.rs +++ b/backend/core/src/model/compatibility.rs @@ -311,25 +311,13 @@ pub(crate) fn node_workloads( ) -> NodeWorkloads { let mut workloads = NodeWorkloads::new(); for deployment in deployments { - let Some(status) = deployment.state.as_ref().map(|state| state.status()) else { + let Some(phase) = workload_phase(deployment) else { continue; }; let memory = deployment .accelerator_memory .as_ref() .and_then(|memory| memory.result.as_ref()); - let phase = match status { - ModelDeploymentStatus::Queued - | ModelDeploymentStatus::Preparing - | ModelDeploymentStatus::Downloading - | ModelDeploymentStatus::Loading - | ModelDeploymentStatus::WarmingUp => WorkloadPhase::Starting, - // Its estimate counts until a reading measures it, rather than offering memory the - // next reading will show in use. - ModelDeploymentStatus::Ready if memory_pending(memory) => WorkloadPhase::Starting, - ModelDeploymentStatus::Ready => WorkloadPhase::Ready, - _ => WorkloadPhase::Other, - }; let local = match deployment .snapshot .as_ref() @@ -391,6 +379,30 @@ pub(crate) fn node_workloads( workloads } +/// The phase a model deployment's workload is in, or none without a lifecycle state. Deployments +/// queued, preparing, downloading, loading or warming up are starting, and so are ready ones whose +/// memory no reading has measured yet; other ready ones are ready, and the rest are in another +/// phase. +pub(crate) fn workload_phase(deployment: &ModelDeployment) -> Option { + let status = deployment.state.as_ref()?.status(); + let memory = deployment + .accelerator_memory + .as_ref() + .and_then(|memory| memory.result.as_ref()); + Some(match status { + ModelDeploymentStatus::Queued + | ModelDeploymentStatus::Preparing + | ModelDeploymentStatus::Downloading + | ModelDeploymentStatus::Loading + | ModelDeploymentStatus::WarmingUp => WorkloadPhase::Starting, + // Its estimate counts until a reading measures it, rather than offering memory the next + // reading will show in use. + ModelDeploymentStatus::Ready if memory_pending(memory) => WorkloadPhase::Starting, + ModelDeploymentStatus::Ready => WorkloadPhase::Ready, + _ => WorkloadPhase::Other, + }) +} + /// Whether a deployment's accelerator memory has no reading yet only because none was taken since /// its process started. fn memory_pending(memory: Option<&accelerator_memory_observation::Result>) -> bool { diff --git a/backend/core/src/model/controller/mod.rs b/backend/core/src/model/controller/mod.rs index 523200b4..a86d5884 100644 --- a/backend/core/src/model/controller/mod.rs +++ b/backend/core/src/model/controller/mod.rs @@ -212,6 +212,7 @@ pub(crate) trait DesiredDeploymentRepository: ModelRealizationInput { fn model_recommendations( &self, _use_cases: &[dotcore_proto::dotcore::catalog::v1::UseCase], + _can_start_now: bool, _inventory: Option<&TargetCapabilityInventory>, _deployments: &[ModelDeployment], ) -> Result { @@ -328,11 +329,13 @@ pub trait ModelLifecycleController: Send + Sync { } /// Ranks the catalog's recommended model profiles for each of `use_cases` on the site's - /// configured model execution targets, marking the profiles already deployed. Advisory: it - /// never changes deployments, admission or placement. + /// configured model execution targets, naming the deployments already running them, and + /// keeping only what can start now when `can_start_now` is set. Advisory: it never changes + /// deployments, admission or placement. fn list_model_recommendations( &self, _use_cases: Vec, + _can_start_now: bool, ) -> impl Future> + Send { std::future::ready(Err(ModelLifecycleError::configuration( @@ -484,10 +487,11 @@ impl DesiredDeploymentRepository for crate::model::repository::ModelDeclarationR fn model_recommendations( &self, use_cases: &[dotcore_proto::dotcore::catalog::v1::UseCase], + can_start_now: bool, inventory: Option<&TargetCapabilityInventory>, deployments: &[ModelDeployment], ) -> Result { - self.model_recommendations(use_cases, inventory, deployments) + self.model_recommendations(use_cases, can_start_now, inventory, deployments) } fn load_upstream_model( diff --git a/backend/core/src/model/controller/operations.rs b/backend/core/src/model/controller/operations.rs index 510fd5b7..1ca599c8 100644 --- a/backend/core/src/model/controller/operations.rs +++ b/backend/core/src/model/controller/operations.rs @@ -168,6 +168,7 @@ where fn list_model_recommendations( &self, use_cases: Vec, + can_start_now: bool, ) -> impl std::future::Future< Output = Result< dotcore_proto::dotcore::model::v1::ListModelRecommendationsResponse, @@ -179,12 +180,13 @@ where let span = debug_span!( "model_recommendations", operation = "model_recommendations", + can_start_now, outcome = field::Empty, error_kind = field::Empty, ); let operation_span = span.clone(); async move { - let result = execute_model_recommendations(&inner, &use_cases).await; + let result = execute_model_recommendations(&inner, &use_cases, can_start_now).await; record_operation_result(&operation_span, &result); result } @@ -561,6 +563,7 @@ where async fn execute_model_recommendations( inner: &ManagerInferenceControllerInner, use_cases: &[dotcore_proto::dotcore::catalog::v1::UseCase], + can_start_now: bool, ) -> Result where D: super::DesiredDeploymentRepository, @@ -582,7 +585,7 @@ where let deployments = inner.public_projection()?.deployments; inner .desired - .model_recommendations(use_cases, inventory.as_ref(), &deployments) + .model_recommendations(use_cases, can_start_now, inventory.as_ref(), &deployments) .map_err(ModelLifecycleError::from_model_repository) } diff --git a/backend/core/src/model/recommendation/machine.rs b/backend/core/src/model/recommendation/machine.rs index 3f8bcf17..06c7898b 100644 --- a/backend/core/src/model/recommendation/machine.rs +++ b/backend/core/src/model/recommendation/machine.rs @@ -139,6 +139,7 @@ impl IdleMachine { Some(&self.inventory), &[], use_cases, + false, ) } @@ -264,7 +265,7 @@ mod tests { .iter() .flat_map(|row| { row.targets.iter().map(move |target| { - assert!(!target.deployed); + assert_eq!(target.model_deployment_id, None); let name = machine .targets() .iter() diff --git a/backend/core/src/model/recommendation/mod.rs b/backend/core/src/model/recommendation/mod.rs index b8a1fe98..5cd71b11 100644 --- a/backend/core/src/model/recommendation/mod.rs +++ b/backend/core/src/model/recommendation/mod.rs @@ -6,8 +6,11 @@ //! read, the entry's own minimums added to its configuration's. An entry is sized for one request //! using its whole total context, at the entry's context per request or else the profile's //! default. Entries that can't run on a target or exceed its total memory are left out there, and -//! the rest merge across targets by their best declared position and context per request. The -//! answer is advisory: it never admits, reserves, or places a deployment. +//! the rest merge across targets by their best declared position and context per request. Each +//! target names the model's running or starting deployment of the same profile there, whatever its +//! context. Asked for what can start now, it keeps only reachable targets that are deployed or fit +//! free memory, deployed rows first. The answer is advisory: it never admits, reserves, or places a +//! deployment. use std::{cmp::Ordering, collections::BTreeMap}; @@ -32,13 +35,16 @@ mod machine; pub use machine::{IdleMachine, IdleMachineError}; use crate::{ - hardware::selection::{ - HardwareClassSelectionError, SelectedTargetCapabilities, select_hardware_class, + hardware::{ + capacity::WorkloadPhase, + selection::{ + HardwareClassSelectionError, SelectedTargetCapabilities, select_hardware_class, + }, }, model::{ compatibility::{ CheckedTarget, ProfileEvaluation, ProfileRequest, StaleTargets, check_targets, - evaluate_profile, node_workloads, + evaluate_profile, node_workloads, workload_phase, }, engine::EngineIntegrationRegistry, sizing_scenario::{ @@ -66,7 +72,8 @@ pub struct Recommendation { /// profile's default. pub context_tokens_per_request: u64, - /// Targets whose list has the profile and that can run it, by node then target. + /// Targets whose list has the profile and that can run it, by node then target; deployed + /// targets come first when only what can start now is asked for. pub targets: Vec, } @@ -77,8 +84,9 @@ pub struct TargetRecommendation { pub fit: RecommendationFit, - /// Whether a model deployment of the same model and profile is placed on the target. - pub deployed: bool, + /// The running or starting model deployment of the same model and profile on the target, + /// whatever its context per request. + pub model_deployment_id: Option, /// Total context in tokens a deployment of the profile starts from there; absent without a /// largest fit or a profile default. @@ -106,14 +114,16 @@ pub enum RecommendationFit { /// `targets`, which must be configured model execution targets. `inventory` is the current target /// observation, or none when it couldn't be read, which leaves nothing to recommend; a target that /// stopped reporting is judged on its last observations, its rows marked unreachable, until it -/// leaves the site. `deployments` are the site's model deployments, which hold memory and mark the -/// profiles already deployed. +/// leaves the site. `deployments` are the site's model deployments, which hold memory and name the +/// profiles already deployed. With `can_start_now`, each list keeps only what can start now, as +/// `startable` describes. pub(crate) fn recommend_models( catalog: &Catalog, targets: &[ExecutionTarget], inventory: Option<&TargetCapabilityInventory>, deployments: &[ModelDeployment], use_cases: &[UseCase], + can_start_now: bool, ) -> Vec { let workloads = node_workloads(deployments, inventory); let checked = check_targets( @@ -131,9 +141,12 @@ pub(crate) fn recommend_models( }; use_cases .iter() - .map(|&use_case| UseCaseRecommendations { - use_case, - rows: recommender.rank(use_case, &checked, deployments), + .map(|&use_case| { + let rows = recommender.rank(use_case, &checked, deployments); + UseCaseRecommendations { + use_case, + rows: if can_start_now { startable(rows) } else { rows }, + } }) .collect() } @@ -214,7 +227,7 @@ impl Recommender<'_> { }); row.position = row.position.min(position); row.targets.push(TargetRecommendation { - deployed: deployed(deployments, &placement, entry), + model_deployment_id: deployment_id(deployments, &placement, entry), placement, fit, starting_total_context_tokens, @@ -373,25 +386,34 @@ fn target_fit(evaluation: &ProfileEvaluation) -> Option<(RecommendationFit, Opti )) } -/// Whether a model deployment of the entry's model and profile is placed at `placement`. -fn deployed( +/// The first running or starting model deployment of the entry's model and profile placed at +/// `placement`, whatever its context per request. It shares the memory accounting's lifecycle +/// phases, so failed, paused, unavailable and unloading deployments don't count. +fn deployment_id( deployments: &[ModelDeployment], placement: &ExactPlacement, entry: &ModelRecommendation, -) -> bool { - deployments.iter().any(|deployment| { - let placed = deployment - .intent - .as_ref() - .and_then(|intent| intent.placement.as_ref()) - == Some(placement); - let same_profile = matches!( - deployment.snapshot.as_ref().and_then(|snapshot| snapshot.source.as_ref()), - Some(model_deployment_snapshot::Source::Local(local)) - if local.model_id == entry.model_id && local.profile_id == entry.profile_id - ); - placed && same_profile - }) +) -> Option { + deployments + .iter() + .find(|deployment| { + let placed = deployment + .intent + .as_ref() + .and_then(|intent| intent.placement.as_ref()) + == Some(placement); + let same_profile = matches!( + deployment.snapshot.as_ref().and_then(|snapshot| snapshot.source.as_ref()), + Some(model_deployment_snapshot::Source::Local(local)) + if local.model_id == entry.model_id && local.profile_id == entry.profile_id + ); + let active = matches!( + workload_phase(deployment), + Some(WorkloadPhase::Starting | WorkloadPhase::Ready) + ); + placed && same_profile && active + }) + .map(|deployment| deployment.model_deployment_id.clone()) } /// Orders merged rows by best declared position, then higher intelligence score, then model id, @@ -421,6 +443,39 @@ fn ranked(rows: BTreeMap) -> Vec { .collect() } +/// Keeps what can start now from ranked rows: unreachable targets go first, deployed or not; a row +/// keeps its deployed targets and those that fit free memory now, and goes when none remain. Rows +/// with a deployed target come first, each group in rank order; within a row deployed targets +/// come first, then by node and target. +fn startable(rows: Vec) -> Vec { + let mut rows = rows + .into_iter() + .filter_map(|mut row| { + row.targets.retain(|target| { + target.fit != RecommendationFit::Unreachable + && (target.model_deployment_id.is_some() + || target.fit == RecommendationFit::FitsNow) + }); + row.targets.sort_by_key(|target| { + ( + target.model_deployment_id.is_none(), + target.fit != RecommendationFit::FitsNow, + target.placement.site_node_id.clone(), + target.placement.execution_target_id.clone(), + ) + }); + (!row.targets.is_empty()).then_some(row) + }) + .collect::>(); + // A stable sort keeps the rank order within each group. + rows.sort_by_key(|row| { + !row.targets + .iter() + .any(|target| target.model_deployment_id.is_some()) + }); + rows +} + /// Logs why a target has no list for a use case: no class with one matches it, or, against /// catalog validation, several match with none more specific. fn log_no_list(use_case: UseCase, target: &ExecutionTarget, error: &HardwareClassSelectionError) { diff --git a/backend/core/src/model/recommendation/tests.rs b/backend/core/src/model/recommendation/tests.rs index 4efbc13f..6de2287b 100644 --- a/backend/core/src/model/recommendation/tests.rs +++ b/backend/core/src/model/recommendation/tests.rs @@ -43,6 +43,12 @@ enum Occupancy { /// estimate. Running(&'static str, &'static str), + /// One ready deployment of the model and profile on the site's first machine, using 30 GB. + Filling(&'static str, &'static str), + + /// One failed deployment of the model and profile on the site's first machine, using nothing. + Failed(&'static str, &'static str), + /// One deployment of unknown size still downloading on the site's first machine. StartingUnsized, @@ -55,11 +61,12 @@ enum Occupancy { } /// One expected row: `model/profile`, its context per request, then each target with its fit, -/// whether the profile is deployed there, and the total context a deployment starts from. +/// the model deployment of the profile running or starting there, and the total context a +/// deployment starts from. type Row = ( &'static str, u64, - Vec<(&'static str, RecommendationFit, bool, u64)>, + Vec<(&'static str, RecommendationFit, Option<&'static str>, u64)>, ); #[test] @@ -71,13 +78,13 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy machines: &["s8"], occupancy: Occupancy::Idle, use_case: Coding, - rows: vec![("small/q4", 32_768, vec![("s8", FitsNow, false, 59_392)])], + rows: vec![("small/q4", 32_768, vec![("s8", FitsNow, None, 59_392)])], }, Case { machines: &["s8"], occupancy: Occupancy::Idle, use_case: ChatAssistant, - rows: vec![("small/q4", 32_768, vec![("s8", FitsNow, false, 59_392)])], + rows: vec![("small/q4", 32_768, vec![("s8", FitsNow, None, 59_392)])], }, // 32 GiB GPU of the model the GPU-model class names: its only configuration comes first. Case { @@ -85,10 +92,10 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy occupancy: Occupancy::Idle, use_case: Coding, rows: vec![ - ("x-only/x", 32_768, vec![("x32", FitsNow, false, 293_888)]), - ("big/q4", 32_768, vec![("x32", FitsNow, false, 174_080)]), - ("mid/q4", 32_768, vec![("x32", FitsNow, false, 274_432)]), - ("small/q4", 32_768, vec![("x32", FitsNow, false, 314_368)]), + ("x-only/x", 32_768, vec![("x32", FitsNow, None, 293_888)]), + ("big/q4", 32_768, vec![("x32", FitsNow, None, 174_080)]), + ("mid/q4", 32_768, vec![("x32", FitsNow, None, 274_432)]), + ("small/q4", 32_768, vec![("x32", FitsNow, None, 314_368)]), ], }, Case { @@ -96,8 +103,8 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy occupancy: Occupancy::Idle, use_case: ChatAssistant, rows: vec![ - ("mid/q4", 32_768, vec![("x32", FitsNow, false, 274_432)]), - ("small/q4", 32_768, vec![("x32", FitsNow, false, 314_368)]), + ("mid/q4", 32_768, vec![("x32", FitsNow, None, 274_432)]), + ("small/q4", 32_768, vec![("x32", FitsNow, None, 314_368)]), ], }, // The running model is deployed and a second one needs its memory freed; it starts from @@ -107,14 +114,14 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy occupancy: Occupancy::Running("big", "q4"), use_case: Coding, rows: vec![ - ("x-only/x", 32_768, vec![("x32", FitsNow, false, 101_376)]), + ("x-only/x", 32_768, vec![("x32", FitsNow, None, 101_376)]), ( "big/q4", 32_768, - vec![("x32", NeedsMemoryFreed, true, 32_768)], + vec![("x32", NeedsMemoryFreed, Some("big-q4"), 32_768)], ), - ("mid/q4", 32_768, vec![("x32", FitsNow, false, 81_920)]), - ("small/q4", 32_768, vec![("x32", FitsNow, false, 121_856)]), + ("mid/q4", 32_768, vec![("x32", FitsNow, None, 81_920)]), + ("small/q4", 32_768, vec![("x32", FitsNow, None, 121_856)]), ], }, Case { @@ -122,8 +129,8 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy occupancy: Occupancy::Running("big", "q4"), use_case: ChatAssistant, rows: vec![ - ("mid/q4", 32_768, vec![("x32", FitsNow, false, 81_920)]), - ("small/q4", 32_768, vec![("x32", FitsNow, false, 121_856)]), + ("mid/q4", 32_768, vec![("x32", FitsNow, None, 81_920)]), + ("small/q4", 32_768, vec![("x32", FitsNow, None, 121_856)]), ], }, // Macs: entry minimums on system memory separate 16, 32 and 64 GiB. @@ -131,21 +138,21 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy machines: &["mac16"], occupancy: Occupancy::Idle, use_case: Coding, - rows: vec![("small/q4", 32_768, vec![("mac16", FitsNow, false, 87_040)])], + rows: vec![("small/q4", 32_768, vec![("mac16", FitsNow, None, 87_040)])], }, Case { machines: &["mac16"], occupancy: Occupancy::Idle, use_case: ChatAssistant, - rows: vec![("small/q4", 32_768, vec![("mac16", FitsNow, false, 87_040)])], + rows: vec![("small/q4", 32_768, vec![("mac16", FitsNow, None, 87_040)])], }, Case { machines: &["mac32"], occupancy: Occupancy::Idle, use_case: Coding, rows: vec![ - ("mid/q4", 32_768, vec![("mac32", FitsNow, false, 176_128)]), - ("small/q4", 32_768, vec![("mac32", FitsNow, false, 216_064)]), + ("mid/q4", 32_768, vec![("mac32", FitsNow, None, 176_128)]), + ("small/q4", 32_768, vec![("mac32", FitsNow, None, 216_064)]), ], }, Case { @@ -153,8 +160,8 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy occupancy: Occupancy::Idle, use_case: ChatAssistant, rows: vec![ - ("mid/q4", 32_768, vec![("mac32", FitsNow, false, 176_128)]), - ("small/q4", 32_768, vec![("mac32", FitsNow, false, 216_064)]), + ("mid/q4", 32_768, vec![("mac32", FitsNow, None, 176_128)]), + ("small/q4", 32_768, vec![("mac32", FitsNow, None, 216_064)]), ], }, Case { @@ -162,9 +169,9 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy occupancy: Occupancy::Idle, use_case: Coding, rows: vec![ - ("big/q4", 32_768, vec![("mac64", FitsNow, false, 355_328)]), - ("mid/q4", 32_768, vec![("mac64", FitsNow, false, 454_656)]), - ("small/q4", 32_768, vec![("mac64", FitsNow, false, 494_592)]), + ("big/q4", 32_768, vec![("mac64", FitsNow, None, 355_328)]), + ("mid/q4", 32_768, vec![("mac64", FitsNow, None, 454_656)]), + ("small/q4", 32_768, vec![("mac64", FitsNow, None, 494_592)]), ], }, Case { @@ -172,9 +179,13 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy occupancy: Occupancy::Running("mid", "q4"), use_case: Coding, rows: vec![ - ("big/q4", 32_768, vec![("mac64", FitsNow, false, 262_144)]), - ("mid/q4", 32_768, vec![("mac64", FitsNow, true, 362_496)]), - ("small/q4", 32_768, vec![("mac64", FitsNow, false, 402_432)]), + ("big/q4", 32_768, vec![("mac64", FitsNow, None, 262_144)]), + ( + "mid/q4", + 32_768, + vec![("mac64", FitsNow, Some("mid-q4"), 362_496)], + ), + ("small/q4", 32_768, vec![("mac64", FitsNow, None, 402_432)]), ], }, // Unified 128 GiB: its own coding list, the vendor class's chat assistant list. @@ -183,16 +194,8 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy occupancy: Occupancy::Idle, use_case: Coding, rows: vec![ - ( - "big/q4", - 32_768, - vec![("uni128", FitsNow, false, 1_020_928)], - ), - ( - "mid/q4", - 32_768, - vec![("uni128", FitsNow, false, 1_120_256)], - ), + ("big/q4", 32_768, vec![("uni128", FitsNow, None, 1_020_928)]), + ("mid/q4", 32_768, vec![("uni128", FitsNow, None, 1_120_256)]), ], }, Case { @@ -200,15 +203,11 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy occupancy: Occupancy::Idle, use_case: ChatAssistant, rows: vec![ - ( - "mid/q4", - 32_768, - vec![("uni128", FitsNow, false, 1_120_256)], - ), + ("mid/q4", 32_768, vec![("uni128", FitsNow, None, 1_120_256)]), ( "small/q4", 32_768, - vec![("uni128", FitsNow, false, 1_160_192)], + vec![("uni128", FitsNow, None, 1_160_192)], ), ], }, @@ -219,8 +218,22 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy occupancy: Occupancy::StartingUnsized, use_case: Coding, rows: vec![ - ("big/q4", 32_768, vec![("uni128", Unknown, false, 32_768)]), - ("mid/q4", 32_768, vec![("uni128", Unknown, false, 32_768)]), + ("big/q4", 32_768, vec![("uni128", Unknown, None, 32_768)]), + ("mid/q4", 32_768, vec![("uni128", Unknown, None, 32_768)]), + ], + }, + // A starting deployment names its profile's row. + Case { + machines: &["uni128"], + occupancy: Occupancy::StartingUnsized, + use_case: ChatAssistant, + rows: vec![ + ("mid/q4", 32_768, vec![("uni128", Unknown, None, 32_768)]), + ( + "small/q4", + 32_768, + vec![("uni128", Unknown, Some("small-q4"), 32_768)], + ), ], }, // Two machines: rows merge by their best position in either list, then the higher @@ -230,29 +243,29 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy occupancy: Occupancy::Idle, use_case: Coding, rows: vec![ - ("x-only/x", 32_768, vec![("x32", FitsNow, false, 293_888)]), + ("x-only/x", 32_768, vec![("x32", FitsNow, None, 293_888)]), ( "big/q4", 32_768, vec![ - ("mac64", FitsNow, false, 355_328), - ("x32", FitsNow, false, 174_080), + ("mac64", FitsNow, None, 355_328), + ("x32", FitsNow, None, 174_080), ], ), ( "mid/q4", 32_768, vec![ - ("mac64", FitsNow, false, 454_656), - ("x32", FitsNow, false, 274_432), + ("mac64", FitsNow, None, 454_656), + ("x32", FitsNow, None, 274_432), ], ), ( "small/q4", 32_768, vec![ - ("mac64", FitsNow, false, 494_592), - ("x32", FitsNow, false, 314_368), + ("mac64", FitsNow, None, 494_592), + ("x32", FitsNow, None, 314_368), ], ), ], @@ -266,16 +279,16 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy "mid/q4", 32_768, vec![ - ("mac64", FitsNow, false, 454_656), - ("x32", FitsNow, false, 274_432), + ("mac64", FitsNow, None, 454_656), + ("x32", FitsNow, None, 274_432), ], ), ( "small/q4", 32_768, vec![ - ("mac64", FitsNow, false, 494_592), - ("x32", FitsNow, false, 314_368), + ("mac64", FitsNow, None, 494_592), + ("x32", FitsNow, None, 314_368), ], ), ], @@ -294,18 +307,10 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy occupancy: Occupancy::Stale, use_case: Coding, rows: vec![ - ( - "x-only/x", - 32_768, - vec![("x32", Unreachable, false, 32_768)], - ), - ("big/q4", 32_768, vec![("x32", Unreachable, false, 32_768)]), - ("mid/q4", 32_768, vec![("x32", Unreachable, false, 32_768)]), - ( - "small/q4", - 32_768, - vec![("x32", Unreachable, false, 32_768)], - ), + ("x-only/x", 32_768, vec![("x32", Unreachable, None, 32_768)]), + ("big/q4", 32_768, vec![("x32", Unreachable, None, 32_768)]), + ("mid/q4", 32_768, vec![("x32", Unreachable, None, 32_768)]), + ("small/q4", 32_768, vec![("x32", Unreachable, None, 32_768)]), ], }, // What runs on an unreachable machine is deployed there, and never drops an entry its @@ -315,18 +320,14 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy occupancy: Occupancy::StaleRunning("big", "q4"), use_case: Coding, rows: vec![ + ("x-only/x", 32_768, vec![("x32", Unreachable, None, 32_768)]), ( - "x-only/x", - 32_768, - vec![("x32", Unreachable, false, 32_768)], - ), - ("big/q4", 32_768, vec![("x32", Unreachable, true, 32_768)]), - ("mid/q4", 32_768, vec![("x32", Unreachable, false, 32_768)]), - ( - "small/q4", + "big/q4", 32_768, - vec![("x32", Unreachable, false, 32_768)], + vec![("x32", Unreachable, Some("big-q4"), 32_768)], ), + ("mid/q4", 32_768, vec![("x32", Unreachable, None, 32_768)]), + ("small/q4", 32_768, vec![("x32", Unreachable, None, 32_768)]), ], }, // An unreachable machine beside a reachable one: each target keeps its own fit in a row. @@ -335,29 +336,29 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy occupancy: Occupancy::Stale, use_case: Coding, rows: vec![ - ("x-only/x", 32_768, vec![("x32", FitsNow, false, 293_888)]), + ("x-only/x", 32_768, vec![("x32", FitsNow, None, 293_888)]), ( "big/q4", 32_768, vec![ - ("mac64", Unreachable, false, 32_768), - ("x32", FitsNow, false, 174_080), + ("mac64", Unreachable, None, 32_768), + ("x32", FitsNow, None, 174_080), ], ), ( "mid/q4", 32_768, vec![ - ("mac64", Unreachable, false, 32_768), - ("x32", FitsNow, false, 274_432), + ("mac64", Unreachable, None, 32_768), + ("x32", FitsNow, None, 274_432), ], ), ( "small/q4", 32_768, vec![ - ("mac64", Unreachable, false, 32_768), - ("x32", FitsNow, false, 314_368), + ("mac64", Unreachable, None, 32_768), + ("x32", FitsNow, None, 314_368), ], ), ], @@ -380,6 +381,7 @@ fn declared_machines_get_exact_ranked_recommendations_per_use_case_and_occupancy Some(&inventory), &deployments, &[case.use_case], + false, ); assert_eq!( @@ -405,6 +407,7 @@ fn every_use_case_asked_gets_its_own_list_in_the_order_asked() { Some(&inventory), &deployments, &[ImageGeneration, ChatAssistant, Coding], + false, ); assert_eq!( @@ -414,19 +417,19 @@ fn every_use_case_asked_gets_its_own_list_in_the_order_asked() { owned(vec![( "small/q4", 32_768, - vec![("s8", FitsNow, false, 59_392)] + vec![("s8", FitsNow, None, 59_392)] )]), owned(vec![( "small/q4", 32_768, - vec![("s8", FitsNow, false, 59_392)] + vec![("s8", FitsNow, None, 59_392)] )]), ] ); // Without an inventory every target is unknown and nothing is recommended. assert_eq!( - recommend_models(&catalog, &targets, None, &deployments, &[Coding],) + recommend_models(&catalog, &targets, None, &deployments, &[Coding], false) .iter() .map(rows) .collect::>(), @@ -471,6 +474,7 @@ useCases: Some(&inventory), &deployments, &[Coding, ChatAssistant], + false, ) .iter() .map(rows) @@ -485,12 +489,12 @@ useCases: owned(vec![( "mid/q4", 131_072, - vec![("x32", FitsNow, false, 274_432)] + vec![("x32", FitsNow, None, 274_432)] )]), owned(vec![( "mid/q4", 32_768, - vec![("x32", FitsNow, false, 274_432)] + vec![("x32", FitsNow, None, 274_432)] )]), ] ); @@ -501,24 +505,216 @@ useCases: read(&["x32", "uni128"], Occupancy::Running("big", "q4")), [ owned(vec![ + ("mid/q4", 32_768, vec![("uni128", FitsNow, None, 1_120_256)]), ( "mid/q4", - 32_768, - vec![("uni128", FitsNow, false, 1_120_256)] + 131_072, + vec![("x32", NeedsMemoryFreed, None, 32_768)] ), + ]), + owned(vec![( + "mid/q4", + 32_768, + vec![("x32", FitsNow, None, 81_920)] + )]), + ] + ); +} + +#[test] +fn can_start_now_keeps_deployed_and_fitting_targets_with_deployed_rows_first() { + let cases = [ + // Idle 32 GiB GPU: every row fits now. + Case { + machines: &["x32"], + occupancy: Occupancy::Idle, + use_case: Coding, + rows: vec![ + ("x-only/x", 32_768, vec![("x32", FitsNow, None, 293_888)]), + ("big/q4", 32_768, vec![("x32", FitsNow, None, 174_080)]), + ("mid/q4", 32_768, vec![("x32", FitsNow, None, 274_432)]), + ("small/q4", 32_768, vec![("x32", FitsNow, None, 314_368)]), + ], + }, + // The running model's row comes first, its deployed target before the fitting one; other + // rows keep their rank and order their targets by machine. + Case { + machines: &["x32", "mac64"], + occupancy: Occupancy::Running("mid", "q4"), + use_case: Coding, + rows: vec![ ( "mid/q4", - 131_072, - vec![("x32", NeedsMemoryFreed, false, 32_768)] + 32_768, + vec![ + ("x32", FitsNow, Some("mid-q4"), 181_248), + ("mac64", FitsNow, None, 454_656), + ], ), - ]), + ("x-only/x", 32_768, vec![("x32", FitsNow, None, 201_728)]), + ( + "big/q4", + 32_768, + vec![ + ("mac64", FitsNow, None, 355_328), + ("x32", FitsNow, None, 81_920), + ], + ), + ( + "small/q4", + 32_768, + vec![ + ("mac64", FitsNow, None, 494_592), + ("x32", FitsNow, None, 221_184), + ], + ), + ], + }, + // A machine filled by another model starts nothing new, but its own row stays. + Case { + machines: &["x32"], + occupancy: Occupancy::Filling("x-only", "x"), + use_case: ChatAssistant, + rows: vec![], + }, + Case { + machines: &["x32"], + occupancy: Occupancy::Filling("x-only", "x"), + use_case: Coding, + rows: vec![( + "x-only/x", + 32_768, + vec![("x32", NeedsMemoryFreed, Some("x-only-x"), 32_768)], + )], + }, + // An unreachable machine's targets are dropped, even with a deployment there. + Case { + machines: &["mac64", "x32"], + occupancy: Occupancy::StaleRunning("big", "q4"), + use_case: Coding, + rows: vec![ + ("x-only/x", 32_768, vec![("x32", FitsNow, None, 293_888)]), + ("big/q4", 32_768, vec![("x32", FitsNow, None, 174_080)]), + ("mid/q4", 32_768, vec![("x32", FitsNow, None, 274_432)]), + ("small/q4", 32_768, vec![("x32", FitsNow, None, 314_368)]), + ], + }, + // A failed deployment names no row and holds no memory. + Case { + machines: &["x32"], + occupancy: Occupancy::Failed("x-only", "x"), + use_case: Coding, + rows: vec![ + ("x-only/x", 32_768, vec![("x32", FitsNow, None, 293_888)]), + ("big/q4", 32_768, vec![("x32", FitsNow, None, 174_080)]), + ("mid/q4", 32_768, vec![("x32", FitsNow, None, 274_432)]), + ("small/q4", 32_768, vec![("x32", FitsNow, None, 314_368)]), + ], + }, + // Unknown free memory keeps only the starting deployment's row. + Case { + machines: &["uni128"], + occupancy: Occupancy::StartingUnsized, + use_case: ChatAssistant, + rows: vec![( + "small/q4", + 32_768, + vec![("uni128", Unknown, Some("small-q4"), 32_768)], + )], + }, + ]; + + let catalog = catalog(); + for case in cases { + let label = format!( + "{} {:?} {}", + case.machines.join("+"), + case.occupancy, + case.use_case.as_str_name() + ); + let (targets, inventory, deployments) = site(case.machines, case.occupancy); + + let recommendations = recommend_models( + &catalog, + &targets, + Some(&inventory), + &deployments, + &[case.use_case], + true, + ); + + assert_eq!( + recommendations.iter().map(rows).collect::>(), + [owned(case.rows)], + "{label}" + ); + } +} + +#[test] +fn a_running_deployment_serves_every_context_per_request_of_its_profile() { + // Coding asks for 262144 tokens per request and the chat assistant for the default 32768. + let catalog = catalog_with_lists(&[( + "nvidia-dedicated", + r#" +hardwareClassId: "nvidia-dedicated" +useCases: + - useCase: "USE_CASE_CODING" + entries: + - { modelId: "mid", profileId: "q4", contextTokensPerRequest: 262144 } + - useCase: "USE_CASE_CHAT_ASSISTANT" + entries: + - { modelId: "mid", profileId: "q4" } +"#, + )]); + let read = |occupancy, can_start_now| { + let (targets, inventory, deployments) = site(&["x32"], occupancy); + recommend_models( + &catalog, + &targets, + Some(&inventory), + &deployments, + &[Coding, ChatAssistant], + can_start_now, + ) + .iter() + .map(rows) + .collect::>() + }; + + // Idle, 262144 tokens fit now. + assert_eq!( + read(Occupancy::Idle, true), + [ + owned(vec![( + "mid/q4", + 262_144, + vec![("x32", FitsNow, None, 274_432)] + )]), owned(vec![( "mid/q4", 32_768, - vec![("x32", FitsNow, false, 81_920)] + vec![("x32", FitsNow, None, 274_432)] )]), ] ); + + // Beside the same profile running at 32768 tokens, 262144 more would need memory freed, but + // the running deployment is offered for coding too, with or without the option. + let running = [ + owned(vec![( + "mid/q4", + 262_144, + vec![("x32", NeedsMemoryFreed, Some("mid-q4"), 32_768)], + )]), + owned(vec![( + "mid/q4", + 32_768, + vec![("x32", FitsNow, Some("mid-q4"), 181_248)], + )]), + ]; + assert_eq!(read(Occupancy::Running("mid", "q4"), true), running); + assert_eq!(read(Occupancy::Running("mid", "q4"), false), running); } /// The rows of one use case's recommendations, in the form `Row` describes. @@ -534,7 +730,7 @@ fn rows(recommendations: &UseCaseRecommendations) -> Vec { ( target.placement.execution_target_id.clone(), target.fit, - target.deployed, + target.model_deployment_id.clone(), target.starting_total_context_tokens, ) }) @@ -554,8 +750,13 @@ fn owned(rows: Vec) -> Vec { .map(|(name, context_tokens_per_request, targets)| { let targets = targets .into_iter() - .map(|(target, fit, deployed, starting)| { - (target.to_owned(), fit, deployed, Some(starting)) + .map(|(target, fit, deployment, starting)| { + ( + target.to_owned(), + fit, + deployment.map(str::to_owned), + Some(starting), + ) }) .collect(); (name.to_owned(), context_tokens_per_request, targets) @@ -566,7 +767,7 @@ fn owned(rows: Vec) -> Vec { type OwnedRow = ( String, u64, - Vec<(String, RecommendationFit, bool, Option)>, + Vec<(String, RecommendationFit, Option, Option)>, ); /// One target per machine, named after it, with the inventory the machines report under @@ -595,8 +796,14 @@ fn site( let architecture = described[0].accelerator_facts()[0].memory_architecture(); let (deployments, workloads) = match occupancy { Occupancy::Idle | Occupancy::Stale => (Vec::new(), Vec::new()), - Occupancy::Running(model, profile) | Occupancy::StaleRunning(model, profile) => { - let bytes = default_estimate(model); + Occupancy::Running(model, profile) + | Occupancy::Filling(model, profile) + | Occupancy::StaleRunning(model, profile) => { + let bytes = if matches!(occupancy, Occupancy::Filling(..)) { + 30_000_000_000 + } else { + default_estimate(model) + }; ( vec![deployment( first, @@ -611,6 +818,16 @@ fn site( }], ) } + Occupancy::Failed(model, profile) => ( + vec![deployment( + first, + architecture, + (model, profile), + ModelDeploymentStatus::Failed, + Some(default_estimate(model)), + )], + Vec::new(), + ), Occupancy::StartingUnsized => ( vec![deployment( first, diff --git a/backend/core/src/model/repository.rs b/backend/core/src/model/repository.rs index 564abb31..72f5169e 100644 --- a/backend/core/src/model/repository.rs +++ b/backend/core/src/model/repository.rs @@ -290,11 +290,12 @@ impl ModelDeclarationRepository { /// Ranks the catalog's recommended model profiles for each of `use_cases` on the site's /// configured model execution targets against `inventory` and the site's `deployments`, - /// without reading or mutating the retained target capabilities. A missing inventory - /// recommends nothing. + /// without reading or mutating the retained target capabilities, keeping only what can start + /// now when `can_start_now` is set. A missing inventory recommends nothing. pub(crate) fn model_recommendations( &self, use_cases: &[UseCase], + can_start_now: bool, inventory: Option<&TargetCapabilityInventory>, deployments: &[ModelDeployment], ) -> Result { @@ -305,8 +306,14 @@ impl ModelDeclarationRepository { .into_iter() .filter(is_model_target) .collect::>(); - let recommendations = - recommend_models(&self.catalog, &targets, inventory, deployments, use_cases); + let recommendations = recommend_models( + &self.catalog, + &targets, + inventory, + deployments, + use_cases, + can_start_now, + ); Ok(recommendations_response(recommendations)) } @@ -714,8 +721,7 @@ fn recommendations_response( } } .into(), - // TODO: the recommender returns the deployment id. - model_deployment_id: None, + model_deployment_id: target.model_deployment_id, starting_total_context_tokens: target.starting_total_context_tokens, }) .collect(), diff --git a/backend/core/src/model/service.rs b/backend/core/src/model/service.rs index 769d7ad0..07de6e1b 100644 --- a/backend/core/src/model/service.rs +++ b/backend/core/src/model/service.rs @@ -352,9 +352,9 @@ where &self, request: Request, ) -> Result, Status> { - let use_cases = parse_model_recommendations_request(request.into_inner())?; + let (use_cases, can_start_now) = parse_model_recommendations_request(request.into_inner())?; self.controller - .list_model_recommendations(use_cases) + .list_model_recommendations(use_cases, can_start_now) .await .map(Response::new) .map_err(Self::translate_controller_error) @@ -840,13 +840,14 @@ fn parse_metadata_patch( }) } -/// Validates the use cases of a recommendations read: each must be a known, specified use case. -/// Duplicates collapse to their first occurrence, and none asks for every use case. +/// Validates a recommendations read: each use case must be a known, specified one. Duplicates +/// collapse to their first occurrence, and none asks for every use case. Returns the use cases and +/// whether only what can start now is asked for. fn parse_model_recommendations_request( request: ListModelRecommendationsRequest, -) -> Result, Status> { +) -> Result<(Vec, bool), Status> { if request.use_cases.is_empty() { - return Ok(ALL_USE_CASES.to_vec()); + return Ok((ALL_USE_CASES.to_vec(), request.can_start_now)); } let mut use_cases = Vec::new(); for value in request.use_cases { @@ -860,7 +861,7 @@ fn parse_model_recommendations_request( use_cases.push(use_case); } } - Ok(use_cases) + Ok((use_cases, request.can_start_now)) } /// Every use case a recommendations read ranks when the request names none. @@ -1344,32 +1345,50 @@ mod tests { #[test] fn recommendation_requests_default_to_all_use_cases_and_reject_unspecified_ones() { - let parse = |use_cases: &[i32]| { + let parse = |use_cases: &[i32], can_start_now: bool| { super::parse_model_recommendations_request(ListModelRecommendationsRequest { use_cases: use_cases.to_vec(), - can_start_now: false, + can_start_now, }) }; assert_eq!( - parse(&[]).expect("all use cases"), - [ - UseCase::Coding, - UseCase::ChatAssistant, - UseCase::ImageGeneration, - ] + parse(&[], false).expect("all use cases"), + ( + vec![ + UseCase::Coding, + UseCase::ChatAssistant, + UseCase::ImageGeneration, + ], + false + ) ); assert_eq!( - parse(&[ - UseCase::ImageGeneration as i32, - UseCase::Coding as i32, - UseCase::ImageGeneration as i32, - ]) + parse(&[], true).expect("all use cases that can start now"), + ( + vec![ + UseCase::Coding, + UseCase::ChatAssistant, + UseCase::ImageGeneration, + ], + true + ) + ); + assert_eq!( + parse( + &[ + UseCase::ImageGeneration as i32, + UseCase::Coding as i32, + UseCase::ImageGeneration as i32, + ], + true + ) .expect("named use cases"), - [UseCase::ImageGeneration, UseCase::Coding] + (vec![UseCase::ImageGeneration, UseCase::Coding], true) ); for invalid in [UseCase::Unspecified as i32, 99] { - let status = parse(&[UseCase::ImageGeneration as i32, invalid]).expect_err("invalid"); + let status = + parse(&[UseCase::ImageGeneration as i32, invalid], false).expect_err("invalid"); assert_eq!(status.code(), tonic::Code::InvalidArgument); assert_eq!( status.message(), diff --git a/backend/core/tests/backend/backend_integration.rs b/backend/core/tests/backend/backend_integration.rs index 01b3173c..71ec663a 100644 --- a/backend/core/tests/backend/backend_integration.rs +++ b/backend/core/tests/backend/backend_integration.rs @@ -5077,11 +5077,11 @@ async fn model_recommendations_rank_the_simulated_site_through_the_model_service let mut paths = support::configuration(&root); paths.master_catalog_root = Some(recommendation_master_catalog(&root)); let service = model_service_for(&paths).await.expect("model service"); - let read = |use_cases: Vec| { + let read = |use_cases: Vec, can_start_now: bool| { ModelServiceContract::list_model_recommendations( &service, Request::new(ListModelRecommendationsRequest { - can_start_now: false, + can_start_now, use_cases: use_cases .into_iter() .map(|use_case| use_case as i32) @@ -5093,7 +5093,7 @@ async fn model_recommendations_rank_the_simulated_site_through_the_model_service // The manager's own node reports its first observation asynchronously after composition. let deadline = Instant::now() + Duration::from_secs(10); let response = loop { - let response = read(vec![UseCase::Coding]) + let response = read(vec![UseCase::Coding], false) .await .expect("recommendation read") .into_inner(); @@ -5127,27 +5127,35 @@ async fn model_recommendations_rank_the_simulated_site_through_the_model_service .starting_total_context_tokens .map(u64::from) .expect("a starting total context"); - assert_eq!( - response.use_cases, - [UseCaseModelRecommendations { - use_case: UseCase::Coding as i32, - rows: vec![RecommendedModelProfile { - model_id: "fixture-model".to_owned(), - profile_id: "fixture-profile".to_owned(), - // The entry sets none, so the row reads the profile's default. - context_tokens_per_request: 32_768, - targets: vec![RecommendedProfileTarget { - target: Some(support::exact_placement("fixture-node", "fixture-target")), - fit: ModelRecommendationFit::FitsNow as i32, - model_deployment_id: None, - starting_total_context_tokens: Some(starting_total_context_tokens), - }], + let expected = [UseCaseModelRecommendations { + use_case: UseCase::Coding as i32, + rows: vec![RecommendedModelProfile { + model_id: "fixture-model".to_owned(), + profile_id: "fixture-profile".to_owned(), + // The entry sets none, so the row reads the profile's default. + context_tokens_per_request: 32_768, + targets: vec![RecommendedProfileTarget { + target: Some(support::exact_placement("fixture-node", "fixture-target")), + fit: ModelRecommendationFit::FitsNow as i32, + model_deployment_id: None, + starting_total_context_tokens: Some(starting_total_context_tokens), }], - }] - ); + }], + }]; + assert_eq!(response.use_cases, expected); + + // The idle site's target fits now, so asking for what can start now keeps it. + let startable = read(vec![UseCase::Coding], true) + .await + .expect("can start now read") + .into_inner(); + assert_eq!(startable.use_cases, expected); // No use case asks for all three, each ranked even when nothing is recommended. - let all = read(Vec::new()).await.expect("all use cases").into_inner(); + let all = read(Vec::new(), false) + .await + .expect("all use cases") + .into_inner(); assert_eq!( all.use_cases .iter() @@ -5160,7 +5168,7 @@ async fn model_recommendations_rank_the_simulated_site_through_the_model_service ] ); - let unspecified = read(vec![UseCase::Coding, UseCase::Unspecified]) + let unspecified = read(vec![UseCase::Coding, UseCase::Unspecified], false) .await .expect_err("unspecified use case"); assert_eq!(unspecified.code(), tonic::Code::InvalidArgument); From 0ca2294f193a2395891eb7de6f930160af101bad Mon Sep 17 00:00:00 2001 From: Andre-Philippe Paquet Date: Wed, 11 Nov 2026 15:00:03 -0500 Subject: [PATCH 07/25] feat(catalog): apply reviewed recommendation lists --- .../core/tests/backend/catalog_contracts.rs | 6 ++-- catalog/models/qwen3-8-27b.yaml | 14 ++++++++ catalog/recommendations/apple.yaml | 32 +++++++++---------- catalog/recommendations/nvidia-dedicated.yaml | 26 +++++++++------ catalog/recommendations/nvidia-unified.yaml | 14 ++++---- 5 files changed, 55 insertions(+), 37 deletions(-) diff --git a/backend/core/tests/backend/catalog_contracts.rs b/backend/core/tests/backend/catalog_contracts.rs index 6c6115d2..d290d3c0 100644 --- a/backend/core/tests/backend/catalog_contracts.rs +++ b/backend/core/tests/backend/catalog_contracts.rs @@ -472,9 +472,9 @@ fn repository_ninfer_leads_coding_on_gb202_cards_only() { assert_eq!( coding(&large_4090), [ - ("qwen3-8-27b".to_owned(), "q4-xl".to_owned()), - ("qwen3-5-9b".to_owned(), "q4-xl".to_owned()), - ("qwen3-5-4b".to_owned(), "q4-xl".to_owned()), + ("qwen3-8-27b".to_owned(), "q3-xl-q8-kv".to_owned()), + ("gemma-4-12b-it-qat".to_owned(), "gpu".to_owned()), + ("qwen3-5-4b".to_owned(), "q3-xl".to_owned()), ] ); } diff --git a/catalog/models/qwen3-8-27b.yaml b/catalog/models/qwen3-8-27b.yaml index 45bc446f..f599d738 100644 --- a/catalog/models/qwen3-8-27b.yaml +++ b/catalog/models/qwen3-8-27b.yaml @@ -526,6 +526,20 @@ profiles: values: - parameterId: "prompt-cache-memory-mib" unsignedInteger: "2048" + - profileId: "q3-xl-q8-kv" + displayName: "Unsloth Q3_K_XL with 8-bit KV cache" + description: "Uses the smaller Q3 weights and stores the context cache at 8 bits, which fits more context in the same memory." + configurationId: "llama-cpp" + defaultModelArtifactBindingId: "q3-xl" + engineParameterDefaults: + values: + - parameterId: "prompt-cache-memory-mib" + unsignedInteger: "2048" + optionDefaults: + - optionId: "kv-cache" + values: + - parameterId: "precision" + string: "q8_0" - profileId: "q4-xl" displayName: "Unsloth Q4_K_XL" configurationId: "llama-cpp" diff --git a/catalog/recommendations/apple.yaml b/catalog/recommendations/apple.yaml index b52acb91..150f70bd 100644 --- a/catalog/recommendations/apple.yaml +++ b/catalog/recommendations/apple.yaml @@ -1,45 +1,43 @@ # Model recommendations for Apple Silicon Macs, best first. Minimums on system memory separate Mac -# sizes: 16, 24, 32, 64 and 128 GB. Memory bandwidth limits generation, so the chat assistant -# prefers a mixture-of-experts model on Macs large enough for it. +# sizes: 24, 32 and 64 GB, with the last entries for 16 GB Macs and smaller. Coding entries carry +# the largest context per request that starts on an idle Mac of their size. hardwareClassId: "apple" useCases: - useCase: "USE_CASE_CODING" entries: - modelId: "qwen3-8-27b" profileId: "q5-xl" - contextTokensPerRequest: 131072 + contextTokensPerRequest: 245760 hardwareRequirements: minimumSystemMemoryBytes: "64000000000" - - modelId: "qwen3-8-27b" - profileId: "q4-xl" - contextTokensPerRequest: 131072 + - modelId: "gemma-4-26b-a4b-it" + profileId: "q3-xl" + contextTokensPerRequest: 196608 hardwareRequirements: minimumSystemMemoryBytes: "32000000000" - - modelId: "qwen3-5-9b" - profileId: "q4-xl" - contextTokensPerRequest: 131072 + - modelId: "gemma-4-12b-it-qat" + profileId: "gpu" + contextTokensPerRequest: 262144 hardwareRequirements: - minimumSystemMemoryBytes: "16000000000" + minimumSystemMemoryBytes: "24000000000" - modelId: "qwen3-5-4b" - profileId: "q4-xl" - contextTokensPerRequest: 131072 + profileId: "q5-xl" + contextTokensPerRequest: 139264 - useCase: "USE_CASE_CHAT_ASSISTANT" entries: - - modelId: "qwen3-6-35b-a3b" - profileId: "q4-xl" + - modelId: "qwen3-8-27b" + profileId: "q5-xl" contextTokensPerRequest: 32768 hardwareRequirements: minimumSystemMemoryBytes: "64000000000" - modelId: "qwen3-8-27b" - profileId: "q4-xl" + profileId: "q3-xl" contextTokensPerRequest: 32768 hardwareRequirements: minimumSystemMemoryBytes: "32000000000" - modelId: "gemma-4-12b-it-qat" profileId: "gpu" contextTokensPerRequest: 32768 - hardwareRequirements: - minimumSystemMemoryBytes: "16000000000" - modelId: "gemma-4-e4b-it-qat" profileId: "gpu" contextTokensPerRequest: 32768 diff --git a/catalog/recommendations/nvidia-dedicated.yaml b/catalog/recommendations/nvidia-dedicated.yaml index ec4aa28d..66445f14 100644 --- a/catalog/recommendations/nvidia-dedicated.yaml +++ b/catalog/recommendations/nvidia-dedicated.yaml @@ -1,25 +1,26 @@ # Model recommendations for NVIDIA GPUs with their own memory, best first. Minimums per GPU separate -# card sizes: 8, 16, 24 and 32 GB. Entries a GPU can't run, such as ninfer off an RTX 5090 or RTX PRO 6000, are -# skipped for it. +# card sizes: 12, 16 and 24 GB, with the last entry for 8 GB cards. Entries a GPU can't run, such as +# ninfer off an RTX 5090 or RTX PRO 6000, are skipped for it. Coding entries carry the largest +# context per request that starts on an idle card of their size. hardwareClassId: "nvidia-dedicated" useCases: - useCase: "USE_CASE_CODING" entries: - modelId: "qwen3-8-27b" profileId: "ninfer-nvfp4" - contextTokensPerRequest: 131072 + contextTokensPerRequest: 229376 - modelId: "qwen3-8-27b" - profileId: "q4-xl" - contextTokensPerRequest: 131072 + profileId: "q3-xl-q8-kv" + contextTokensPerRequest: 155648 hardwareRequirements: minimumMemoryBytesPerDevice: "24000000000" - - modelId: "qwen3-5-9b" - profileId: "q4-xl" - contextTokensPerRequest: 131072 + - modelId: "gemma-4-12b-it-qat" + profileId: "gpu" + contextTokensPerRequest: 163840 hardwareRequirements: minimumMemoryBytesPerDevice: "12000000000" - modelId: "qwen3-5-4b" - profileId: "q4-xl" + profileId: "q3-xl" contextTokensPerRequest: 131072 - useCase: "USE_CASE_CHAT_ASSISTANT" entries: @@ -31,11 +32,16 @@ useCases: contextTokensPerRequest: 32768 hardwareRequirements: minimumMemoryBytesPerDevice: "24000000000" + - modelId: "muse-glimmer-30b" + profileId: "q3-xl" + contextTokensPerRequest: 32768 + hardwareRequirements: + minimumMemoryBytesPerDevice: "16000000000" - modelId: "gemma-4-12b-it-qat" profileId: "gpu" contextTokensPerRequest: 32768 hardwareRequirements: minimumMemoryBytesPerDevice: "12000000000" - modelId: "qwen3-5-4b" - profileId: "q4-xl" + profileId: "q5-xl" contextTokensPerRequest: 32768 diff --git a/catalog/recommendations/nvidia-unified.yaml b/catalog/recommendations/nvidia-unified.yaml index 51673bc3..adeeabda 100644 --- a/catalog/recommendations/nvidia-unified.yaml +++ b/catalog/recommendations/nvidia-unified.yaml @@ -1,25 +1,25 @@ # Model recommendations for NVIDIA GPUs sharing the host's memory, such as the GB10, best first. -# Memory bandwidth limits generation there, so the chat assistant prefers mixture-of-experts -# models, which read fewer weights per token. +# Later entries are fallbacks for smaller machines. Coding entries carry the largest context per +# request that starts on an idle GB10. hardwareClassId: "nvidia-unified" useCases: - useCase: "USE_CASE_CODING" entries: - modelId: "qwen3-8-27b" profileId: "nvfp4-text" - contextTokensPerRequest: 131072 + contextTokensPerRequest: 262144 - modelId: "qwen3-6-35b-a3b" profileId: "nvfp4" - contextTokensPerRequest: 131072 + contextTokensPerRequest: 262144 - modelId: "qwen3-5-9b" profileId: "q4-xl" - contextTokensPerRequest: 131072 + contextTokensPerRequest: 262144 - useCase: "USE_CASE_CHAT_ASSISTANT" entries: - - modelId: "ornith-1-5-35b-a3b" + - modelId: "qwen3-8-27b" profileId: "nvfp4-multimodal" contextTokensPerRequest: 32768 - - modelId: "qwen3-8-27b" + - modelId: "ornith-1-5-35b-a3b" profileId: "nvfp4-multimodal" contextTokensPerRequest: 32768 - modelId: "gemma-4-12b-it-qat" From f7a1a9d0c7876cdd108e9d1cdd20e84c15a161f9 Mon Sep 17 00:00:00 2001 From: Andre-Philippe Paquet Date: Thu, 12 Nov 2026 11:39:18 -0500 Subject: [PATCH 08/25] feat(backend): add app targets read --- backend/catalog/src/lib.rs | 102 ++++ backend/catalog/src/source.rs | 27 +- backend/catalog/src/validation/application.rs | 116 +++- backend/catalog/src/validation/mod.rs | 1 + .../core/src/application/controller/mod.rs | 20 +- .../src/application/controller/operations.rs | 54 +- .../application/controller/test_support.rs | 8 + backend/core/src/application/mod.rs | 1 + .../core/src/application/recommendation.rs | 565 ++++++++++++++++++ backend/core/src/application/service.rs | 24 +- .../core/tests/backend/backend_integration.rs | 75 ++- 11 files changed, 967 insertions(+), 26 deletions(-) create mode 100644 backend/core/src/application/recommendation.rs diff --git a/backend/catalog/src/lib.rs b/backend/catalog/src/lib.rs index ddd0c068..eb7f8738 100644 --- a/backend/catalog/src/lib.rs +++ b/backend/catalog/src/lib.rs @@ -2247,6 +2247,108 @@ useCases: fs::remove_dir_all(root).expect("Catalog fixture cleanup"); } + #[test] + fn master_catalog_applications_serve_distinct_use_cases() { + let root = temporary_catalog_root(); + create_empty_catalog_source(&root); + let mut chat = super::test_support::application(); + chat.use_cases = vec![UseCase::ChatAssistant as i32]; + let mut images = super::test_support::application(); + images.application_id = "fixture-images".to_owned(); + images.use_cases = vec![UseCase::ImageGeneration as i32]; + images.preferred_accelerators = vec!["nvidia".to_owned()]; + write_application(&root, "chat.yaml", &chat); + write_application(&root, "images.yaml", &images); + + let catalog = Catalog::load_with_policy( + &root, + OciArtifactPolicy::Development, + &ACCEPT_ENGINE_DECLARATIONS, + ) + .expect("applications serving distinct use cases load"); + assert_eq!( + catalog + .list_application_releases() + .iter() + .map(|application| ( + application.application_id.as_str(), + application.use_cases().collect::>(), + application.preferred_accelerators.clone(), + )) + .collect::>(), + [ + ( + "fixture-application", + vec![UseCase::ChatAssistant], + Vec::new() + ), + ( + "fixture-images", + vec![UseCase::ImageGeneration], + vec!["nvidia".to_owned()] + ), + ] + ); + + let mut second_chat = images; + second_chat.use_cases = vec![ + UseCase::ImageGeneration as i32, + UseCase::ChatAssistant as i32, + ]; + write_application(&root, "images.yaml", &second_chat); + let error = Catalog::load_with_policy( + &root, + OciArtifactPolicy::Development, + &ACCEPT_ENGINE_DECLARATIONS, + ) + .expect_err("a use case served by two applications is rejected"); + assert_eq!( + error.to_string(), + "Catalog diagnostics: applications/images.yaml: application \"fixture-images\" serves use case USE_CASE_CHAT_ASSISTANT, already served by application \"fixture-application\" in applications/chat.yaml" + ); + fs::remove_dir_all(root).expect("Catalog fixture cleanup"); + } + + #[test] + fn catalog_layers_cannot_declare_application_use_cases() { + 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(); + application.use_cases = vec![UseCase::Coding as i32]; + write_application(&layer, "coding.yaml", &application); + + let development = + Catalog::load_development_sources(&master, &layer, &ACCEPT_ENGINE_DECLARATIONS) + .expect_err("a development layer must not declare application use cases"); + let site = Catalog::load_sources_with_policy( + &master, + &layer, + OciArtifactPolicy::Development, + &ACCEPT_ENGINE_DECLARATIONS, + ) + .expect_err("a site layer must not declare application use cases"); + + assert_eq!( + development.to_string(), + "Catalog diagnostics: development catalog applications/coding.yaml: application use cases can only be declared by the master catalog" + ); + assert_eq!( + site.to_string(), + "Catalog diagnostics: site catalog applications/coding.yaml: application use cases can only be declared by the master catalog" + ); + + // A layer application that serves no use case still loads. + application.use_cases.clear(); + application.preferred_accelerators = vec!["nvidia".to_owned()]; + write_application(&layer, "coding.yaml", &application); + Catalog::load_development_sources(&master, &layer, &ACCEPT_ENGINE_DECLARATIONS) + .expect("a layer application without use cases loads"); + fs::remove_dir_all(root).expect("Catalog fixture cleanup"); + } + #[test] fn recommendations_on_wider_and_narrower_classes_than_the_supported_one_are_valid() { let root = recommendation_fixture(" - hardwareClassId: \"nvidia\"\n"); diff --git a/backend/catalog/src/source.rs b/backend/catalog/src/source.rs index 75a2ee65..58be4093 100644 --- a/backend/catalog/src/source.rs +++ b/backend/catalog/src/source.rs @@ -3,7 +3,8 @@ //! //! It walks the model, engine, hardware class, recommendation, application, and upstream-template //! directories and parses every file into a typed record, extracting each configuration's private -//! sizing layers along the way. Only the master catalog may declare model recommendations. +//! sizing layers along the way. Only the master catalog may declare model recommendations and the +//! use cases applications serve. //! A file that cannot be read or parsed is reported as a located diagnostic rather than //! aborting the whole load, so one bad file never hides the rest. @@ -57,7 +58,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, @@ -294,14 +301,28 @@ fn load_recommendations( } } +/// Loads the applications of one catalog source. Only the master catalog may say which use cases +/// an application serves, since a layer's application would compete with the master's for its use +/// case, so a layer application that names one is reported instead. 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) { + match yaml::from_file::(&file.path) { + Ok(application) + if role == CatalogSourceRole::Layer && !application.use_cases.is_empty() => + { + diagnostics.push(CatalogDiagnostic::new( + file.location, + anyhow::anyhow!( + "application use cases can only be declared by the master catalog" + ), + )); + } Ok(application) => applications.push((file.location, application)), Err(error) => diagnostics.push(CatalogDiagnostic::new( file.location, diff --git a/backend/catalog/src/validation/application.rs b/backend/catalog/src/validation/application.rs index fdc19c82..7756dde7 100644 --- a/backend/catalog/src/validation/application.rs +++ b/backend/catalog/src/validation/application.rs @@ -1,10 +1,16 @@ //! Application declaration graph, service, and binding validation. +//! +//! Besides its graph, an application may name the use cases it serves and the accelerator vendors +//! it prefers. Both are recommendation and display metadata: each use case is served by at most +//! one master-catalog application, and they never decide admission or placement. + +use std::collections::{BTreeMap, BTreeSet}; use anyhow::anyhow; -use dotcore_domain::valid_id; -use dotcore_proto::dotcore::catalog::v1::Application; +use dotcore_domain::{Accelerator, valid_id}; +use dotcore_proto::dotcore::catalog::v1::{Application, UseCase}; -use super::graph::validate_application_graph; +use super::{CatalogDiagnostic, graph::validate_application_graph}; use crate::OciArtifactPolicy; pub fn validate_application_with_policy( @@ -28,6 +34,8 @@ pub fn validate_application_with_policy( "application notes must be at most {MAX_NOTES_BYTES} bytes and contain no control characters other than line breaks" )); } + validate_use_cases(application, &mut errors); + validate_preferred_accelerators(application, &mut errors); match application.graph.as_ref() { Some(graph) => errors.extend(validate_application_graph(graph, oci_artifact_policy)), None => errors.push(anyhow!("application requires a service graph")), @@ -35,6 +43,64 @@ pub fn validate_application_with_policy( errors } +/// Reports every application that serves a use case an earlier application already serves, so +/// each use case leads to at most one application. +pub(super) fn report_shared_use_cases( + applications: &[(String, Application)], + diagnostics: &mut Vec, +) { + let mut served = BTreeMap::new(); + for (location, application) in applications { + for use_case in application.use_cases().collect::>() { + if use_case == UseCase::Unspecified { + continue; + } + match served.get(&use_case) { + Some((first_id, first_location)) => diagnostics.push(CatalogDiagnostic::new( + location, + anyhow!( + "application {:?} serves use case {}, already served by application {first_id:?} in {first_location}", + application.application_id, + use_case.as_str_name() + ), + )), + None => { + served.insert(use_case, (&application.application_id, location)); + } + } + } + } +} + +fn validate_use_cases(application: &Application, errors: &mut Vec) { + let id = &application.application_id; + let mut use_cases = BTreeSet::new(); + for use_case in &application.use_cases { + match UseCase::try_from(*use_case) { + Ok(UseCase::Unspecified) | Err(_) => { + errors.push(anyhow!("application {id:?} names an unspecified use case")) + } + Ok(use_case) if !use_cases.insert(use_case) => errors.push(anyhow!( + "application {id:?} serves use case {} more than once", + use_case.as_str_name() + )), + Ok(_) => {} + } + } +} + +fn validate_preferred_accelerators(application: &Application, errors: &mut Vec) { + let mut vendors = BTreeSet::new(); + if application.preferred_accelerators.iter().any(|name| { + Accelerator::from_catalog_name(name).is_none() || !vendors.insert(name.as_str()) + }) { + errors.push(anyhow!( + "application {:?} requires distinct preferred accelerators, each nvidia or apple", + application.application_id + )); + } +} + const MAX_NOTES_BYTES: usize = 1000; fn valid_notes(notes: &str) -> bool { @@ -64,7 +130,7 @@ mod tests { ApplicationServicePort, ApplicationServiceReadiness, BooleanParameterSchema, NixArtifact, NixDockerImage, NixSystemStorePath, OciPlatform, ParameterDefinition, StorageBinding, StorageKind, StorageScope, StringEnumParameterSchema, UnsignedIntegerParameterSchema, - application_configuration_binding, + UseCase, application_configuration_binding, application_service_artifact::Kind as ServiceArtifactKind, nix_artifact::Provides, parameter_definition::ParameterType, }; @@ -219,6 +285,48 @@ mod tests { assert!(notes_error(&"é".repeat(501))); } + #[test] + fn application_use_cases_and_preferred_accelerators_are_validated() { + let errors = |use_cases: &[UseCase], accelerators: &[&str]| { + let mut application = application(); + application.use_cases = use_cases.iter().map(|use_case| *use_case as i32).collect(); + application.preferred_accelerators = accelerators + .iter() + .map(|accelerator| (*accelerator).to_owned()) + .collect(); + validate_application(&application) + .iter() + .map(ToString::to_string) + .collect::>() + }; + + assert_eq!( + errors( + &[UseCase::ChatAssistant, UseCase::Coding], + &["nvidia", "apple"] + ), + Vec::::new() + ); + assert_eq!(errors(&[], &[]), Vec::::new()); + assert_eq!( + errors(&[UseCase::Unspecified], &[]), + ["application \"open-webui\" names an unspecified use case"] + ); + assert_eq!( + errors(&[UseCase::Coding, UseCase::Coding], &[]), + ["application \"open-webui\" serves use case USE_CASE_CODING more than once"] + ); + for accelerators in [&["amd"][..], &["nvidia", "nvidia"], &[""]] { + assert_eq!( + errors(&[], accelerators), + [ + "application \"open-webui\" requires distinct preferred accelerators, each nvidia or apple" + ], + "{accelerators:?}" + ); + } + } + #[test] fn application_validation_requires_nonempty_unique_concrete_linux_platforms() { let cases = [ diff --git a/backend/catalog/src/validation/mod.rs b/backend/catalog/src/validation/mod.rs index 3631a63d..fd0a814a 100644 --- a/backend/catalog/src/validation/mod.rs +++ b/backend/catalog/src/validation/mod.rs @@ -183,6 +183,7 @@ pub(super) fn validate_catalog( &duplicate_recommendations, diagnostics, ); + application::report_shared_use_cases(&applications, diagnostics); let application_index = validate_declarations( applications, |application| application.application_id.clone(), diff --git a/backend/core/src/application/controller/mod.rs b/backend/core/src/application/controller/mod.rs index eca41141..d1e84e84 100644 --- a/backend/core/src/application/controller/mod.rs +++ b/backend/core/src/application/controller/mod.rs @@ -50,6 +50,7 @@ use dotcore_proto::{ ApplicationReadinessState, ApplicationServiceLifecycleState, ApplicationServiceObservation, CreateApplicationDeploymentRequest, CurrentApplicationDeployment, ListApplicationDeploymentsResponse, + ListApplicationTargetsResponse, }, site::v1::{ExecutionTarget as SiteExecutionTarget, Site}, }, @@ -80,7 +81,8 @@ pub(crate) use node::{ }; pub(crate) use node_view::ApplicationNodeViewSection; use operations::{ - create_deployment, pause_deployment, remove_deployment, resume_deployment, update_metadata, + create_deployment, list_targets, pause_deployment, remove_deployment, resume_deployment, + update_metadata, }; use owner::ApplicationOwner; use projection::project; @@ -113,6 +115,13 @@ pub trait ApplicationLifecycleController: Send + Sync { deployment_id: String, ) -> impl Future> + Send; + /// Lists the application targets that can take a catalog application now, best first. The + /// read is advisory and never waits for a lifecycle operation in progress. + fn list_targets( + &self, + application_id: String, + ) -> impl Future> + Send; + fn create_deployment( &self, request: CreateApplicationDeploymentRequest, @@ -337,6 +346,15 @@ where } } + fn list_targets( + &self, + application_id: String, + ) -> impl Future> + Send + { + let owner = Arc::clone(&self.owner); + async move { list_targets(&owner, &application_id).await } + } + fn create_deployment( &self, request: CreateApplicationDeploymentRequest, diff --git a/backend/core/src/application/controller/operations.rs b/backend/core/src/application/controller/operations.rs index 2388c48d..2e698aed 100644 --- a/backend/core/src/application/controller/operations.rs +++ b/backend/core/src/application/controller/operations.rs @@ -2,20 +2,20 @@ use std::sync::Arc; -use dotcore_domain::{SiteNodeId, real_time}; +use dotcore_domain::{SiteNodeId, diagnostic::diagnostic_chain, real_time}; use dotcore_proto::dotcore::{ application::v1::{ ApplicationDeploymentDeclaration, ApplicationDeploymentDesiredState, ApplicationLifecycleState, CreateApplicationDeploymentRequest, - CurrentApplicationDeployment, ResolvedApplicationDeploymentSnapshot, - ResolvedApplicationServiceSelection, + CurrentApplicationDeployment, ListApplicationTargetsResponse, + ResolvedApplicationDeploymentSnapshot, ResolvedApplicationServiceSelection, }, catalog::v1::application_service_artifact::Kind as ServiceArtifactKind, deployment::v1::{ArtifactPreparationPolicy, ExactPlacement}, site::v1::{ExecutionTarget, Site}, }; use dotcore_site::node::target_inventory::CapabilityObservation; -use tracing::info; +use tracing::{debug, info, warn}; use super::{ ApplicationLifecycleError, ApplicationOwner, configuration, configuration_with_source, @@ -24,6 +24,7 @@ use super::{ }; use crate::application::{ published_port::{PUBLISHED_PORT_RANGE, allocate_published_port, deployment_start_offset}, + recommendation::recommend_application_targets, repository::{ ApplicationDeclarationRepository, ApplicationLifecycleGenerations, CurrentApplicationRepository, has_inference_route_projection, @@ -257,6 +258,51 @@ where result } +/// Lists the application targets that can take a catalog application now, best first, from the +/// current target observations and deployments. Unavailable observations leave nothing to list. +pub(super) async fn list_targets( + owner: &ApplicationOwner, + application_id: &str, +) -> Result +where + D: ApplicationDeclarationRepository, + S: CurrentApplicationRepository + ApplicationLifecycleGenerations, +{ + validate_id(application_id)?; + let application = resolve_application(owner, application_id)?; + let inventory = match owner.target_capabilities.observe().await { + Ok(inventory) => inventory, + Err(error) => { + warn!( + application_id, + error = %diagnostic_chain(&error), + "target observations are unavailable; application targets list nothing" + ); + return Ok(ListApplicationTargetsResponse::default()); + } + }; + let site = owner.application_site()?; + let deployments = owner.projection()?.deployments; + let manager = site + .nodes + .iter() + .find(|node| node.site_manager.is_some()) + .map(|node| node.node_id.as_str()); + let targets = recommend_application_targets( + &application, + &site.execution_targets, + manager, + &inventory, + &deployments, + ); + debug!( + application_id, + target_count = targets.len(), + "listed application targets" + ); + Ok(ListApplicationTargetsResponse { targets }) +} + fn find_desired( owner: &Arc>, deployment_id: &str, diff --git a/backend/core/src/application/controller/test_support.rs b/backend/core/src/application/controller/test_support.rs index 20616c07..8b25c580 100644 --- a/backend/core/src/application/controller/test_support.rs +++ b/backend/core/src/application/controller/test_support.rs @@ -1371,6 +1371,14 @@ where self.owner.get_deployment(deployment_id) } + fn list_targets( + &self, + application_id: String, + ) -> impl Future> + Send + { + self.owner.list_targets(application_id) + } + fn create_deployment( &self, request: CreateApplicationDeploymentRequest, diff --git a/backend/core/src/application/mod.rs b/backend/core/src/application/mod.rs index 6b5be8b2..62b49838 100644 --- a/backend/core/src/application/mod.rs +++ b/backend/core/src/application/mod.rs @@ -2,6 +2,7 @@ mod controller; mod published_port; +mod recommendation; pub mod repository; pub mod service; pub(crate) mod workloads; diff --git a/backend/core/src/application/recommendation.rs b/backend/core/src/application/recommendation.rs new file mode 100644 index 00000000..cc661b61 --- /dev/null +++ b/backend/core/src/application/recommendation.rs @@ -0,0 +1,565 @@ +//! Recommends where one catalog application can run now: the application targets that can take +//! it, best first. +//! +//! A target qualifies when its node reports it fresh, its runtime runs the platform the site +//! declares for it, and every service of the application approves that platform, as creating a +//! deployment there requires. Targets the application already runs or starts on come first, then +//! those whose accelerators the application prefers, then the site manager's. The answer is +//! advisory: it never admits, reserves, or places a deployment. + +use std::time::SystemTime; + +use dotcore_domain::ExecutionPlatform; +use dotcore_proto::dotcore::{ + application::v1::{ApplicationDeployment, ApplicationLifecycleState, ApplicationTargetOption}, + catalog::v1::Application, + deployment::v1::ExactPlacement, + site::v1::{ExecutionTarget, execution_target::RuntimeBinding}, +}; +use dotcore_site::node::target_inventory::{CapabilityObservation, TargetCapabilityInventory}; + +/// Returns the application targets among `targets` that can take `application` now, best first. +/// `site_manager_node_id` names the site manager's node, when it has one, and `deployments` are +/// the site's application deployments. A target matches the application's preference when it +/// holds accelerator devices of a vendor the application prefers; every target matches an +/// application with no preference. +pub(crate) fn recommend_application_targets( + application: &Application, + targets: &[ExecutionTarget], + site_manager_node_id: Option<&str>, + inventory: &TargetCapabilityInventory, + deployments: &[ApplicationDeployment], +) -> Vec { + let mut options = targets + .iter() + .filter(|target| can_take(application, target, inventory)) + .map(|target| { + let placement = ExactPlacement { + site_node_id: target.site_node_id.clone(), + execution_target_id: target.execution_target_id.clone(), + }; + ApplicationTargetOption { + matches_preference: matches_preference(application, target, inventory), + application_deployment_id: deployment_id(application, &placement, deployments), + target: Some(placement), + } + }) + .collect::>(); + options.sort_by_cached_key(|option| { + let placement = option.target.clone().unwrap_or_default(); + ( + option.application_deployment_id.is_none(), + !option.matches_preference, + Some(placement.site_node_id.as_str()) != site_manager_node_id, + placement.site_node_id, + placement.execution_target_id, + ) + }); + options +} + +/// Whether `target` is an application target that can take the application now: freshly +/// observed, on its declared platform, which every service of the application approves. +fn can_take( + application: &Application, + target: &ExecutionTarget, + inventory: &TargetCapabilityInventory, +) -> bool { + if !matches!( + target.runtime_binding, + Some(RuntimeBinding::ApplicationRuntime(_)) + ) { + return false; + } + let Some(capabilities) = inventory.for_target(&target.execution_target_id) else { + return false; + }; + let Some(observed) = fresh( + inventory, + &capabilities.platform, + capabilities.platform_observed_at, + ) else { + return false; + }; + let declared = target + .platform + .as_ref() + .and_then(|platform| ExecutionPlatform::try_from(platform).ok()); + if declared.as_ref() != Some(observed) { + return false; + } + let platform = observed.to_oci_platform(); + application.graph.as_ref().is_some_and(|graph| { + graph.services.iter().all(|service| { + service + .artifact + .as_ref() + .is_some_and(|artifact| artifact.supported_platforms.contains(&platform)) + }) + }) +} + +/// Whether `target` holds accelerator devices of a vendor the application prefers, or the +/// application prefers none. +fn matches_preference( + application: &Application, + target: &ExecutionTarget, + inventory: &TargetCapabilityInventory, +) -> bool { + if application.preferred_accelerators.is_empty() { + return true; + } + if target.accelerator_device_ids.is_empty() { + return false; + } + inventory + .for_target(&target.execution_target_id) + .and_then(|capabilities| { + fresh( + inventory, + &capabilities.accelerator, + capabilities.accelerator_observed_at, + ) + }) + .is_some_and(|vendor| { + application + .preferred_accelerators + .iter() + .any(|preferred| preferred == vendor.catalog_name()) + }) +} + +/// The first running or starting deployment of the application placed at `placement`. Paused, +/// stopped, failed, unavailable and stopping deployments don't count. +fn deployment_id( + application: &Application, + placement: &ExactPlacement, + deployments: &[ApplicationDeployment], +) -> Option { + deployments + .iter() + .find(|deployment| { + let same_application = deployment + .snapshot + .as_ref() + .is_some_and(|snapshot| snapshot.application_id == application.application_id); + let active = matches!( + deployment + .state + .as_ref() + .map(|state| state.lifecycle_state()), + Some( + ApplicationLifecycleState::Queued + | ApplicationLifecycleState::Preparing + | ApplicationLifecycleState::Starting + | ApplicationLifecycleState::Running + | ApplicationLifecycleState::Ready + ) + ); + deployment.placement.as_ref() == Some(placement) && same_application && active + }) + .map(|deployment| deployment.application_deployment_id.clone()) +} + +/// The observed value when it is available and was fresh when the inventory was read. +fn fresh<'a, T>( + inventory: &TargetCapabilityInventory, + observation: &'a CapabilityObservation, + observed_at: Option, +) -> Option<&'a T> { + match observation { + CapabilityObservation::Available(value) if inventory.is_fresh(observed_at) => Some(value), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use std::time::SystemTime; + + use dotcore_domain::timing::Timing; + use dotcore_node_hardware::MachineDescription; + use dotcore_proto::dotcore::{ + application::v1::{ + ApplicationDeployment, ApplicationDeploymentState, ApplicationLifecycleState, + ResolvedApplicationDeploymentSnapshot, + }, + catalog::v1::{ + Application, ApplicationGraph, ApplicationService, ApplicationServiceArtifact, + OciPlatform, + }, + deployment::v1::ExactPlacement, + site::v1::{ExecutionTarget, execution_target::RuntimeBinding}, + }; + use dotcore_site::{ + initial::inputs::described_execution_targets, + node::target_inventory::{ + NodeAcceleratorObservation, RuntimePlatformObservation, TargetCapabilityInventory, + TargetCapabilityReconciler, + }, + }; + + use super::recommend_application_targets; + + use ApplicationLifecycleState::{Failed, Paused, Ready, Starting}; + + /// Expected recommendations for one site of described machines. + struct Case { + name: &'static str, + site: Site, + application: Application, + deployments: Vec<(&'static str, &'static str, ApplicationLifecycleState)>, + /// Each expected target, best first: its node, whether it matches the application's + /// preference, and the application's deployment there. + expected: Vec<(&'static str, bool, Option<&'static str>)>, + } + + #[test] + fn application_targets_are_the_ones_that_can_take_the_application_best_first() { + let cases = [ + Case { + name: "the NVIDIA machine's Docker target holds its GPU, the Mac's holds none", + site: Site::of(&[("a-mac", MAC), ("z-gpu", NVIDIA)]), + application: application(&["nvidia"], &[LINUX_AMD64, LINUX_ARM64]), + deployments: Vec::new(), + expected: vec![("z-gpu", true, None), ("a-mac", false, None)], + }, + Case { + name: "a Mac alone never matches", + site: Site::of(&[("a-mac", MAC)]), + application: application(&["nvidia"], &[LINUX_AMD64, LINUX_ARM64]), + deployments: Vec::new(), + expected: vec![("a-mac", false, None)], + }, + Case { + name: "an NVIDIA machine's Docker target that holds no GPU doesn't match", + site: Site::of(&[("z-gpu", NVIDIA)]).without_application_devices(), + application: application(&["nvidia"], &[LINUX_AMD64, LINUX_ARM64]), + deployments: Vec::new(), + expected: vec![("z-gpu", false, None)], + }, + Case { + name: "a site without application targets offers none", + site: Site::of(&[("a-mac", MAC), ("z-gpu", NVIDIA)]).without_application_targets(), + application: application(&["nvidia"], &[LINUX_AMD64, LINUX_ARM64]), + deployments: Vec::new(), + expected: Vec::new(), + }, + Case { + name: "a target running or starting the application comes first, with its deployment", + site: Site::of(&[("a-mac", MAC), ("z-gpu", NVIDIA)]), + application: application(&["nvidia"], &[LINUX_AMD64, LINUX_ARM64]), + deployments: vec![ + ("a-mac", "app-on-mac", Starting), + ("a-mac", "app-on-mac-ready", Ready), + ], + expected: vec![("a-mac", false, Some("app-on-mac")), ("z-gpu", true, None)], + }, + Case { + name: "failed and paused deployments and other applications' don't count", + site: Site::of(&[("a-mac", MAC), ("z-gpu", NVIDIA)]), + application: application(&["nvidia"], &[LINUX_AMD64, LINUX_ARM64]), + deployments: vec![ + ("a-mac", "app-failed", Failed), + ("a-mac", "app-paused", Paused), + ("a-mac", OTHER_APPLICATION_DEPLOYMENT, Ready), + ], + expected: vec![("z-gpu", true, None), ("a-mac", false, None)], + }, + Case { + name: "a machine that stopped reporting is dropped, even running the application", + site: Site::of(&[("a-mac", MAC), ("z-gpu", NVIDIA)]).stale("z-gpu"), + application: application(&["nvidia"], &[LINUX_AMD64, LINUX_ARM64]), + deployments: vec![("z-gpu", "app-on-gpu", Ready)], + expected: vec![("a-mac", false, None)], + }, + Case { + name: "without a preference every target matches, the site manager's first", + site: Site::of(&[("a-mac", MAC), ("z-gpu", NVIDIA)]).managed_by("z-gpu"), + application: application(&[], &[LINUX_AMD64, LINUX_ARM64]), + deployments: Vec::new(), + expected: vec![("z-gpu", true, None), ("a-mac", true, None)], + }, + Case { + name: "without a preference or a manager, by node", + site: Site::of(&[("z-gpu", NVIDIA), ("a-mac", MAC)]), + application: application(&[], &[LINUX_AMD64, LINUX_ARM64]), + deployments: Vec::new(), + expected: vec![("a-mac", true, None), ("z-gpu", true, None)], + }, + Case { + name: "a platform one service doesn't approve is left out", + site: Site::of(&[("a-mac", MAC), ("z-gpu", NVIDIA)]), + application: application(&["nvidia"], &[LINUX_AMD64]), + deployments: Vec::new(), + expected: vec![("z-gpu", true, None)], + }, + ]; + + for case in cases { + let deployments = case + .deployments + .iter() + .map(|(node, id, state)| case.site.deployment(node, id, *state)) + .collect::>(); + let options = recommend_application_targets( + &case.application, + &case.site.targets, + case.site.manager.as_deref(), + &case.site.inventory, + &deployments, + ); + let actual = options + .into_iter() + .map(|option| { + let target = option.target.expect("a target"); + ( + target, + option.matches_preference, + option.application_deployment_id, + ) + }) + .collect::>(); + let expected = case + .expected + .iter() + .map(|(node, matches, deployment)| { + ( + case.site.application_placement(node), + *matches, + deployment.map(str::to_owned), + ) + }) + .collect::>(); + assert_eq!(actual, expected, "{}", case.name); + } + } + + /// A site of described machines, each its own node, observed now unless made stale. + struct Site { + machines: Vec<(&'static str, MachineDescription)>, + targets: Vec, + manager: Option, + stale: Vec<&'static str>, + inventory: TargetCapabilityInventory, + } + + impl Site { + /// Declares the targets site setup gives each named machine. + fn of(machines: &[(&'static str, &str)]) -> Self { + let machines = machines + .iter() + .map(|(node, yaml)| { + let description = + MachineDescription::from_yaml(node, yaml).expect("described machine"); + (*node, description) + }) + .collect::>(); + let targets = machines + .iter() + .flat_map(|(node, description)| { + described_execution_targets(node, description).expect("described targets") + }) + .collect(); + Self { + machines, + targets, + manager: None, + stale: Vec::new(), + inventory: TargetCapabilityInventory::empty_now(), + } + .observed() + } + + fn without_application_devices(mut self) -> Self { + for target in self + .targets + .iter_mut() + .filter(|target| is_application(target)) + { + target.accelerator_device_ids.clear(); + } + self.observed() + } + + fn without_application_targets(mut self) -> Self { + self.targets.retain(|target| !is_application(target)); + self.observed() + } + + fn stale(mut self, node: &'static str) -> Self { + self.stale.push(node); + self.observed() + } + + fn managed_by(mut self, node: &str) -> Self { + self.manager = Some(node.to_owned()); + self + } + + /// Observes every target's platform and every node's accelerators, long ago on stale + /// nodes. + fn observed(mut self) -> Self { + let now = SystemTime::now(); + let max_age = Timing::test().node_control.capability_max_age; + let reported_at = |node: &str| { + if self.stale.contains(&node) { + now - 2 * max_age + } else { + now + } + }; + let platforms = self + .targets + .iter() + .map(|target| { + RuntimePlatformObservation::available_at( + target.execution_target_id.clone(), + target + .platform + .as_ref() + .expect("target platform") + .try_into() + .expect("supported platform"), + reported_at(&target.site_node_id), + ) + }) + .collect::>(); + let accelerators = self + .machines + .iter() + .map(|(node, description)| { + let snapshot = description + .render(&[], reported_at(node)) + .expect("rendered snapshot"); + NodeAcceleratorObservation::from_hardware_snapshot( + *node, + &snapshot, + self.stale.contains(node), + ) + }) + .collect::>(); + self.inventory = TargetCapabilityReconciler::reconcile( + &self.targets, + platforms, + accelerators, + max_age, + now, + ); + self + } + + /// The placement of the node's one application target. + fn application_placement(&self, node: &str) -> ExactPlacement { + let mut targets = self + .targets + .iter() + .filter(|target| target.site_node_id == node && is_application(target)); + let target = targets.next().expect("an application target"); + assert!(targets.next().is_none(), "one application target on {node}"); + ExactPlacement { + site_node_id: node.to_owned(), + execution_target_id: target.execution_target_id.clone(), + } + } + + /// A deployment on the node's application target, of the fixture application unless + /// its id says otherwise. + fn deployment( + &self, + node: &str, + id: &str, + lifecycle_state: ApplicationLifecycleState, + ) -> ApplicationDeployment { + let application_id = if id == OTHER_APPLICATION_DEPLOYMENT { + "other-application" + } else { + APPLICATION_ID + }; + ApplicationDeployment { + application_deployment_id: id.to_owned(), + placement: Some(self.application_placement(node)), + snapshot: Some(ResolvedApplicationDeploymentSnapshot { + application_id: application_id.to_owned(), + ..Default::default() + }), + state: Some(ApplicationDeploymentState { + lifecycle_state: lifecycle_state as i32, + ..Default::default() + }), + ..Default::default() + } + } + } + + fn is_application(target: &ExecutionTarget) -> bool { + matches!( + target.runtime_binding, + Some(RuntimeBinding::ApplicationRuntime(_)) + ) + } + + /// The fixture application with two services approving `platforms`, preferring + /// `accelerators`. + fn application(accelerators: &[&str], platforms: &[(&str, &str)]) -> Application { + let platforms = platforms + .iter() + .map(|(operating_system, architecture)| OciPlatform { + operating_system: (*operating_system).to_owned(), + architecture: (*architecture).to_owned(), + variant: None, + }) + .collect::>(); + let service = |name: &str| ApplicationService { + service_name: name.to_owned(), + artifact: Some(ApplicationServiceArtifact { + kind: None, + supported_platforms: platforms.clone(), + }), + ..Default::default() + }; + Application { + application_id: APPLICATION_ID.to_owned(), + preferred_accelerators: accelerators + .iter() + .map(|accelerator| (*accelerator).to_owned()) + .collect(), + graph: Some(ApplicationGraph { + services: vec![service("web"), service("worker")], + ..Default::default() + }), + ..Default::default() + } + } + + const APPLICATION_ID: &str = "fixture-application"; + + /// The id of a deployment of another application. + const OTHER_APPLICATION_DEPLOYMENT: &str = "other-application-deployment"; + + const LINUX_AMD64: (&str, &str) = ("linux", "amd64"); + + const LINUX_ARM64: (&str, &str) = ("linux", "arm64"); + + /// A dedicated 32 GiB NVIDIA GPU beside 64 GiB of host memory. + const NVIDIA: &str = r#" +platform: linux/amd64 +cpuCount: 16 +hostMemory: { totalBytes: 68719476736, idleUsedBytes: 4294967296 } +gpus: + - id: GPU-fixture + vendor: nvidia + model: Fixture GPU + driverVersion: "580.173.02" + memory: { architecture: dedicated, totalBytes: 34359738368, reservedBytes: 536870912 } +"#; + + /// A 32 GiB Mac, 6 GiB in use at idle. + const MAC: &str = r#" +platform: darwin/arm64 +cpuCount: 10 +hostMemory: { totalBytes: 34359738368, idleUsedBytes: 6442450944 } +gpus: + - { id: apple-gpu-0, vendor: apple, model: Fixture Apple GPU, memory: { architecture: unified } } +"#; +} diff --git a/backend/core/src/application/service.rs b/backend/core/src/application/service.rs index 24d31aae..dd6aa17f 100644 --- a/backend/core/src/application/service.rs +++ b/backend/core/src/application/service.rs @@ -89,12 +89,13 @@ where async fn list_application_targets( &self, - _request: Request, + request: Request, ) -> Result, Status> { - // TODO: answer from the apps controller's application target read. - Ok(Response::new(ListApplicationTargetsResponse { - targets: Vec::new(), - })) + self.controller + .list_targets(request.into_inner().application_id) + .await + .map(Response::new) + .map_err(|error| error.to_tonic_status()) } async fn get_application_deployment( @@ -547,8 +548,8 @@ mod tests { use dotcore_proto::{ dotcore::application::v1::{ ApplicationDeployment, CreateApplicationDeploymentRequest, DeploymentLogResumePosition, - ListApplicationDeploymentsResponse, ReadDeploymentLogsRequest, - UpdateApplicationDeploymentMetadataRequest, + ListApplicationDeploymentsResponse, ListApplicationTargetsResponse, + ReadDeploymentLogsRequest, UpdateApplicationDeploymentMetadataRequest, }, google::protobuf::Timestamp, }; @@ -1073,6 +1074,15 @@ mod tests { ready(Ok(ApplicationDeployment::default())) } + fn list_targets( + &self, + _application_id: String, + ) -> impl std::future::Future< + Output = Result, + > + Send { + ready(Ok(ListApplicationTargetsResponse::default())) + } + fn create_deployment( &self, _request: CreateApplicationDeploymentRequest, diff --git a/backend/core/tests/backend/backend_integration.rs b/backend/core/tests/backend/backend_integration.rs index 71ec663a..e7cba202 100644 --- a/backend/core/tests/backend/backend_integration.rs +++ b/backend/core/tests/backend/backend_integration.rs @@ -23,10 +23,10 @@ use dotcore_proto::dotcore::{ application::v1::{ ApplicationDeployment, ApplicationDeploymentDeclaration, ApplicationDeploymentDesiredState, ApplicationDeploymentState, ApplicationLifecycleState, ApplicationReadinessState, - CreateApplicationDeploymentRequest, CurrentApplicationDeployment, - GetApplicationDeploymentRequest, ListApplicationDeploymentsRequest, PauseDeploymentRequest, - RemoveApplicationDeploymentRequest, ResumeDeploymentRequest, - UpdateApplicationDeploymentMetadataRequest, + ApplicationTargetOption, CreateApplicationDeploymentRequest, CurrentApplicationDeployment, + GetApplicationDeploymentRequest, ListApplicationDeploymentsRequest, + ListApplicationTargetsRequest, PauseDeploymentRequest, RemoveApplicationDeploymentRequest, + ResumeDeploymentRequest, UpdateApplicationDeploymentMetadataRequest, application_deployment_service_server::ApplicationDeploymentService as ApplicationServiceContract, }, catalog::v1::{ @@ -1097,12 +1097,12 @@ async fn application_paused_snapshot_survives_catalog_removal_and_remove_never_s fs::create_dir_all(root.join("catalog/applications")).expect("Application catalog directory"); fs::write( root.join("catalog/applications/open-webui.yaml"), - support::with_oci_service_images( + site_catalog_application(&support::with_oci_service_images( &fs::read_to_string( support::workspace_root().join("catalog/applications/open-webui.yaml"), ) .expect("local application catalog release"), - ), + )), ) .expect("local application catalog release"); let empty_master = root.join("empty-master-catalog"); @@ -2071,7 +2071,7 @@ fn configure_route_name_application(root: &Path) { let source = fs::read_to_string(support::workspace_root().join("catalog/applications/open-webui.yaml")) .expect("route-name application source"); - let source = support::with_oci_service_images(&source); + let source = site_catalog_application(&support::with_oci_service_images(&source)); let source = source .replace("applicationId: \"open-webui\"", "applicationId: \"route-test\"") .replace("displayName: \"Open WebUI\"", "displayName: \"Route Test\"") @@ -2085,6 +2085,16 @@ fn configure_route_name_application(root: &Path) { .expect("route-name application fixture"); } +/// A master-catalog application declaration as a site catalog may declare it: without the use +/// cases it serves, which only the master catalog names. +fn site_catalog_application(source: &str) -> String { + source + .lines() + .filter(|line| !line.starts_with("useCases:")) + .map(|line| format!("{line}\n")) + .collect() +} + struct ApplicationHealthServer { port: u16, task: tokio::task::JoinHandle<()>, @@ -5178,6 +5188,57 @@ async fn model_recommendations_rank_the_simulated_site_through_the_model_service ); } +#[tokio::test] +async fn application_targets_list_the_simulated_site_through_the_application_service() { + let root = support::temporary_fast_fixture(); + support::declare_fixture_machine(&root, &["rtx-5090"]); + let service = application_service_for(&support::configuration(&root)) + .await + .expect("application service"); + let read = |application_id: &str| { + ApplicationServiceContract::list_application_targets( + &service, + Request::new(ListApplicationTargetsRequest { + application_id: application_id.to_owned(), + }), + ) + }; + + // The manager's own node reports its first observation asynchronously after composition. + let deadline = Instant::now() + Duration::from_secs(10); + let targets = loop { + let targets = read("comfyui") + .await + .expect("application target read") + .into_inner() + .targets; + if !targets.is_empty() || Instant::now() >= deadline { + break targets; + } + tokio::time::sleep(Duration::from_millis(10)).await; + }; + + // ComfyUI prefers NVIDIA, but the simulated application target holds no GPU of the machine. + assert_eq!( + targets, + [ApplicationTargetOption { + target: Some(support::exact_placement( + "fixture-node", + "fixture-application-target" + )), + matches_preference: false, + application_deployment_id: None, + }] + ); + + let unknown = read("missing-application") + .await + .expect_err("unknown application"); + assert_eq!(unknown.code(), tonic::Code::NotFound); + let invalid = read("Invalid").await.expect_err("invalid application id"); + assert_eq!(invalid.code(), tonic::Code::InvalidArgument); +} + #[tokio::test] async fn rejected_local_configurations_return_their_reason_through_the_model_service() { let root = support::temporary_fast_fixture(); From ead22aa784cbee16f510013795b6e340cf663847 Mon Sep 17 00:00:00 2001 From: Andre-Philippe Paquet Date: Thu, 12 Nov 2026 11:52:51 -0500 Subject: [PATCH 09/25] feat(catalog): declare app use cases and preferred accelerators --- catalog/applications/comfyui.yaml | 2 ++ catalog/applications/open-webui.yaml | 1 + 2 files changed, 3 insertions(+) diff --git a/catalog/applications/comfyui.yaml b/catalog/applications/comfyui.yaml index be1cf7ec..4ae19778 100644 --- a/catalog/applications/comfyui.yaml +++ b/catalog/applications/comfyui.yaml @@ -10,6 +10,8 @@ notes: |- The app has no login. Anyone who can reach its published port can run workflows that load any custom node of the deployment, which run as root with the container's network and GPU access. The only access control is the address the node publishes applications on, plus the network in front of it, so publish ComfyUI on a trusted network only. version: "0.42.5" +useCases: ["USE_CASE_IMAGE_GENERATION"] +preferredAccelerators: ["nvidia"] graph: parameters: - advanced: true diff --git a/catalog/applications/open-webui.yaml b/catalog/applications/open-webui.yaml index fc316abc..ce1ad575 100644 --- a/catalog/applications/open-webui.yaml +++ b/catalog/applications/open-webui.yaml @@ -4,6 +4,7 @@ applicationId: "open-webui" displayName: "Open WebUI" description: "Self-hosted chat interface for the models on this site." version: "0.11.4" +useCases: ["USE_CASE_CHAT_ASSISTANT"] graph: services: - serviceName: "web" From c5b8abd4218b8941eaf908a389dc49851c6ec530 Mon Sep 17 00:00:00 2001 From: Andre-Philippe Paquet Date: Thu, 12 Nov 2026 11:52:59 -0500 Subject: [PATCH 10/25] feat(backend): show context and apps in hardware recommend --- backend/cli/src/hardware/recommend.rs | 68 +++++++++++++++---- backend/cli/tests/command_contract.rs | 9 +-- backend/core/src/application/mod.rs | 1 + .../core/src/model/recommendation/machine.rs | 21 +++++- .../applications/fixture-art.yaml | 26 +++++++ .../applications/fixture-chat.yaml | 26 +++++++ 6 files changed, 133 insertions(+), 18 deletions(-) create mode 100644 backend/fixtures/recommendation-catalog/applications/fixture-art.yaml create mode 100644 backend/fixtures/recommendation-catalog/applications/fixture-chat.yaml diff --git a/backend/cli/src/hardware/recommend.rs b/backend/cli/src/hardware/recommend.rs index cd7e9c4f..51c89ed6 100644 --- a/backend/cli/src/hardware/recommend.rs +++ b/backend/cli/src/hardware/recommend.rs @@ -2,7 +2,9 @@ //! //! The machine is this one or a machine description file, treated as idle. For each use case it //! prints a table of recommended models, best first, with the execution target that runs each, -//! whether it fits there now, and the total context it starts with. +//! whether it fits there now, its context per request and the total context it starts with. Each +//! use case also names the catalog application that serves it, and says when no target runs it +//! or none holds the accelerators it prefers. use std::path::PathBuf; @@ -12,7 +14,7 @@ use dotcore::model::{IdleMachine, RecommendationFit, UseCaseRecommendations}; use dotcore_catalog::{Catalog, CatalogModelId}; use dotcore_domain::timing::Timing; use dotcore_node_hardware::MachineDescription; -use dotcore_proto::dotcore::catalog::v1::UseCase; +use dotcore_proto::dotcore::catalog::v1::{Application, UseCase}; use super::load_catalog; use crate::{RootPathArgs, terminal::Message}; @@ -83,8 +85,8 @@ fn read_machine(file: &PathBuf) -> Result { .with_context(|| format!("invalid machine description {}", file.display())) } -/// Lays the recommendations out with the shared terminal layout: a heading and a table per use -/// case, or a line saying nothing is recommended. +/// Lays the recommendations out with the shared terminal layout: a heading per use case, then its +/// table of models and the application that serves it, or a line saying nothing is recommended. fn render( catalog: &Catalog, machine: &IdleMachine, @@ -96,12 +98,23 @@ fn render( text.push('\n'); } let heading = Message::Heading(use_case_label(list.use_case).to_owned()); - let body = if list.rows.is_empty() { - Message::Info("No recommendation for this machine".to_owned()) - } else { - table(catalog, machine, list) - }; - for message in [heading, body] { + let application = catalog + .list_application_releases() + .into_iter() + .find(|application| application.use_cases().any(|case| case == list.use_case)); + let mut messages = vec![heading]; + if !list.rows.is_empty() { + messages.push(table(catalog, machine, list)); + } + if let Some(application) = application { + messages.push(application_line(machine, &application)); + } + if messages.len() == 1 { + messages.push(Message::Info( + "No recommendation for this machine".to_owned(), + )); + } + for message in messages { for line in message.plain_lines() { text.push_str(&line); text.push('\n'); @@ -125,6 +138,7 @@ fn table(catalog: &Catalog, machine: &IdleMachine, list: &UseCaseRecommendations profile.clone(), target_name(machine, &target.placement.execution_target_id), fit_label(target.fit).to_owned(), + row.context_tokens_per_request.to_string(), target .starting_total_context_tokens .map_or("-".to_owned(), |tokens| tokens.to_string()), @@ -133,13 +147,41 @@ fn table(catalog: &Catalog, machine: &IdleMachine, list: &UseCaseRecommendations }) .collect(); Message::Table { - header: ["MODEL", "PROFILE", "TARGET", "STATUS", "CONTEXT TOKENS"] - .map(str::to_owned) - .to_vec(), + header: [ + "MODEL", + "PROFILE", + "TARGET", + "STATUS", + "CONTEXT PER REQUEST", + "TOTAL CONTEXT", + ] + .map(str::to_owned) + .to_vec(), rows, } } +/// Names the application that serves a use case, with a note when the machine can't run it or +/// holds none of the accelerators it prefers. +fn application_line(machine: &IdleMachine, application: &Application) -> Message { + let options = machine.recommend_application(application); + let note = if options.is_empty() { + Some("no target can run it".to_owned()) + } else if !options.iter().any(|option| option.matches_preference) { + Some(format!( + "no target matches its preferred accelerators: {}", + application.preferred_accelerators.join(", ") + )) + } else { + None + }; + let name = match note { + Some(note) => format!("{} ({note})", application.display_name), + None => application.display_name.clone(), + }; + Message::Info(format!("App: {name}")) +} + fn use_case_label(use_case: UseCase) -> &'static str { match use_case { UseCase::Coding => "Coding", diff --git a/backend/cli/tests/command_contract.rs b/backend/cli/tests/command_contract.rs index 3750031b..dd1e94b9 100644 --- a/backend/cli/tests/command_contract.rs +++ b/backend/cli/tests/command_contract.rs @@ -6154,21 +6154,22 @@ fn hardware_recommend_prints_exact_tables_for_a_described_machine() { }; // The model that needs 24 GB per device is listed first on the roomy GPU and dropped on the tight one. + // Chat's app prefers Apple, so no target on this NVIDIA machine matches it; image generation's matches. assert_eq!( recommend(&roomy, &[]), - "Coding\n MODEL PROFILE TARGET STATUS CONTEXT TOKENS\n Fixture Big Fixture Q4 Fixture GPU X fits now 174080\n Fixture Small Fixture Q4 Fixture GPU X fits now 314368\n\nChat assistant\n MODEL PROFILE TARGET STATUS CONTEXT TOKENS\n Fixture Small Fixture Q4 Fixture GPU X fits now 314368\n\nImage generation\nNo recommendation for this machine\n" + "Coding\n MODEL PROFILE TARGET STATUS CONTEXT PER REQUEST TOTAL CONTEXT\n Fixture Big Fixture Q4 Fixture GPU X fits now 32768 174080\n Fixture Small Fixture Q4 Fixture GPU X fits now 32768 314368\n\nChat assistant\n MODEL PROFILE TARGET STATUS CONTEXT PER REQUEST TOTAL CONTEXT\n Fixture Small Fixture Q4 Fixture GPU X fits now 32768 314368\nApp: Fixture Chat (no target matches its preferred accelerators: apple)\n\nImage generation\nApp: Fixture Art\n" ); assert_eq!( recommend(&tight, &["--use-case", "coding"]), - "Coding\n MODEL PROFILE TARGET STATUS CONTEXT TOKENS\n Fixture Small Fixture Q4 Fixture GPU X fits now 59392\n" + "Coding\n MODEL PROFILE TARGET STATUS CONTEXT PER REQUEST TOTAL CONTEXT\n Fixture Small Fixture Q4 Fixture GPU X fits now 32768 59392\n" ); assert_eq!( recommend(&tight, &["--use-case", "chat"]), - "Chat assistant\n MODEL PROFILE TARGET STATUS CONTEXT TOKENS\n Fixture Small Fixture Q4 Fixture GPU X fits now 59392\n" + "Chat assistant\n MODEL PROFILE TARGET STATUS CONTEXT PER REQUEST TOTAL CONTEXT\n Fixture Small Fixture Q4 Fixture GPU X fits now 32768 59392\nApp: Fixture Chat (no target matches its preferred accelerators: apple)\n" ); assert_eq!( recommend(&tight, &["--use-case", "image"]), - "Image generation\nNo recommendation for this machine\n" + "Image generation\nApp: Fixture Art\n" ); } diff --git a/backend/core/src/application/mod.rs b/backend/core/src/application/mod.rs index 62b49838..d0a6bfd3 100644 --- a/backend/core/src/application/mod.rs +++ b/backend/core/src/application/mod.rs @@ -18,3 +18,4 @@ pub use controller::{ InferenceRouteResolutionError, InferenceRouteResolver, ParameterRejection, ResolvedInferenceRoute, RetainedTargetMismatch, TargetIneligibility, }; +pub(crate) use recommendation::recommend_application_targets; diff --git a/backend/core/src/model/recommendation/machine.rs b/backend/core/src/model/recommendation/machine.rs index 06c7898b..61423309 100644 --- a/backend/core/src/model/recommendation/machine.rs +++ b/backend/core/src/model/recommendation/machine.rs @@ -9,7 +9,8 @@ use dotcore_catalog::{Catalog, SupportedHardwareClass}; use dotcore_domain::{real_time, timing::NodeControlTiming}; use dotcore_node_hardware::{MachineDescription, MachineError}; use dotcore_proto::dotcore::{ - catalog::v1::{HardwareClass, UseCase}, + application::v1::ApplicationTargetOption, + catalog::v1::{Application, HardwareClass, UseCase}, site::v1::{ExecutionTarget, execution_target::RuntimeBinding}, }; use dotcore_site::{ @@ -23,6 +24,7 @@ use tracing::debug; use super::{UseCaseRecommendations, recommend_models}; use crate::{ + application::recommend_application_targets, hardware::selection::select_hardware_class, model::compatibility::{StaleTargets, check_targets, node_workloads}, }; @@ -143,6 +145,23 @@ impl IdleMachine { ) } + /// Returns the machine's application targets that can run `application`, best first, with + /// nothing deployed on them. + pub fn recommend_application(&self, application: &Application) -> Vec { + let targets = self + .targets + .iter() + .filter(|target| { + matches!( + target.runtime_binding, + Some(RuntimeBinding::ApplicationRuntime(_)) + ) + }) + .cloned() + .collect::>(); + recommend_application_targets(application, &targets, None, &self.inventory, &[]) + } + /// The targets that run models, which recommendations and hardware classes apply to. fn model_targets(&self) -> Vec { self.targets diff --git a/backend/fixtures/recommendation-catalog/applications/fixture-art.yaml b/backend/fixtures/recommendation-catalog/applications/fixture-art.yaml new file mode 100644 index 00000000..89314987 --- /dev/null +++ b/backend/fixtures/recommendation-catalog/applications/fixture-art.yaml @@ -0,0 +1,26 @@ +applicationId: "fixture-art" +displayName: "Fixture Art" +description: "Image generation fixture that prefers NVIDIA." +version: "1.0.0" +useCases: ["USE_CASE_IMAGE_GENERATION"] +preferredAccelerators: ["nvidia"] +graph: + services: + - serviceName: "web" + artifact: + nix: + flake: "self" + package: "apps.fixture-art.docker" + dockerImage: {} + supportedPlatforms: + - operatingSystem: "linux" + architecture: "amd64" + - operatingSystem: "linux" + architecture: "arm64" + ports: + - portName: "http" + portNumber: 8080 + primaryEndpoint: + serviceName: "web" + portName: "http" + protocol: "APPLICATION_ENDPOINT_PROTOCOL_HTTP" diff --git a/backend/fixtures/recommendation-catalog/applications/fixture-chat.yaml b/backend/fixtures/recommendation-catalog/applications/fixture-chat.yaml new file mode 100644 index 00000000..c911de24 --- /dev/null +++ b/backend/fixtures/recommendation-catalog/applications/fixture-chat.yaml @@ -0,0 +1,26 @@ +applicationId: "fixture-chat" +displayName: "Fixture Chat" +description: "Chat fixture that prefers Apple." +version: "1.0.0" +useCases: ["USE_CASE_CHAT_ASSISTANT"] +preferredAccelerators: ["apple"] +graph: + services: + - serviceName: "web" + artifact: + nix: + flake: "self" + package: "apps.fixture-chat.docker" + dockerImage: {} + supportedPlatforms: + - operatingSystem: "linux" + architecture: "amd64" + - operatingSystem: "linux" + architecture: "arm64" + ports: + - portName: "http" + portNumber: 8080 + primaryEndpoint: + serviceName: "web" + portName: "http" + protocol: "APPLICATION_ENDPOINT_PROTOCOL_HTTP" From 205d1dcfe85c45c740333ef23ecc5544159c4053 Mon Sep 17 00:00:00 2001 From: Andre-Philippe Paquet Date: Thu, 12 Nov 2026 12:01:03 -0500 Subject: [PATCH 11/25] docs(*): describe get started recommendations --- .agents/skills/add-app/SKILL.md | 1 + .agents/skills/add-model/SKILL.md | 2 +- AGENTS.md | 9 +++++---- docs/specs/apps.md | 3 +++ docs/specs/catalog.md | 11 ++++++++--- docs/specs/console.md | 2 +- docs/specs/models.md | 7 +++++-- 7 files changed, 24 insertions(+), 11 deletions(-) diff --git a/.agents/skills/add-app/SKILL.md b/.agents/skills/add-app/SKILL.md index cadfe2c7..009b03cc 100644 --- a/.agents/skills/add-app/SKILL.md +++ b/.agents/skills/add-app/SKILL.md @@ -29,6 +29,7 @@ Each image has a folder under `apps/` (or `engines/` for an engine image) with a 4. Write `catalog/applications/.yaml` from the closest neighbour. * Reference the package `apps..docker` with `dockerImage: {}`, and list the platforms the image was pinned for. * Bind the inference endpoint and published URLs with resolved values rather than literals, and keep any fixed login visible in `notes`. + * Declare `useCases` when the app is the master catalog's app for a use case (one app per use case), and `preferredAccelerators` (vendor names) when it runs best on one vendor's GPUs. Both are advisory metadata. * Leave out optional fields you cannot support. 5. Write the folder's `README.md` and `AGENTS.md` from the templates in `apps/AGENTS.md`. diff --git a/.agents/skills/add-model/SKILL.md b/.agents/skills/add-model/SKILL.md index 8d70e41a..020c74e9 100644 --- a/.agents/skills/add-model/SKILL.md +++ b/.agents/skills/add-model/SKILL.md @@ -25,7 +25,7 @@ Sizing is owned by the `model-sizing` skill. Catalog loading and validation live * Size each configuration with the `model-sizing` skill. * Leave out optional fields you cannot support. -4. Decide whether the model belongs in any hardware class's lists in `catalog/recommendations/`, for each use case, and place it by priority. Lists stay short and best first; add a memory minimum only where the model fits but leaves too little room for context. +4. Decide whether the model belongs in any hardware class's lists in `catalog/recommendations/`, for each use case, and place it by priority. An entry may set `contextTokensPerRequest`: coding entries carry the largest context per request that starts on an idle machine at the entry's minimum; the catalog test checks every entry fits. Lists stay short and best first; add a memory minimum only where the model fits but leaves too little room for context. 5. Validate the declaration and any list you changed with the catalog tests and the area gates. diff --git a/AGENTS.md b/AGENTS.md index 301d223f..25e7680c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -206,11 +206,12 @@ in the same change that adds, removes or renames a folder it lists. An **engine artifact** is one runnable build of it. - **hardware class**: a kind of execution hardware, nothing model-specific. **Hardware support** is a configuration's memory minimums and sizing constants for one class. -- **use case**: what an operator runs models for (coding and agents, general assistant, - translation, decision making); a fixed list, distinct from inference request kinds. +- **use case**: what an operator runs Dotcore for (chat assistant, coding, image generation); a + fixed list, distinct from inference request kinds. Each use case has at most one app; models + serve the use cases that have model lists. - **model recommendation**: a master-catalog entry naming a model profile to run on a hardware - class for a use case, in priority order, with optional memory minimums; advisory, never used for - admission or placement. + class for a use case, in priority order, with optional memory minimums and an optional context + per request (the profile still holds none); advisory, never used for admission or placement. - **maximum context**: the largest context one request can use with the model's weights; a configuration may lower it. No "effective" variant. - **total context** (`total-context`): the KV pool an engine holds, shared by active requests. diff --git a/docs/specs/apps.md b/docs/specs/apps.md index 3441174a..5b9e00a8 100644 --- a/docs/specs/apps.md +++ b/docs/specs/apps.md @@ -137,6 +137,9 @@ What an application deployment keeps from creation: - Own app configuration resolution and endpoint projection, including the site inference base URL and optional route links. - Own typed app parameter resolution. +- Answer an advisory read of the application targets that can run an app now, best first: targets + with the app already running or starting, then targets matching its preferred accelerators. It + never changes admission or placement; a deployment still names its exact target. - 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 e9aea477..9647e4fa 100644 --- a/docs/specs/catalog.md +++ b/docs/specs/catalog.md @@ -161,8 +161,10 @@ minimums. - Only the master catalog declares recommendations. Site and development catalogs can't add or replace them. -- Validation keeps each entry runnable on its class and serving its use case's request kind, and - keeps the classes that declare lists disjoint or nested. +- An entry may set its own context per request, within the model's minimum and maximum context. + Recommendations size every entry with it. +- Validation keeps each entry runnable on its class and accepting chat requests, and keeps the + classes that declare lists disjoint or nested. - A site turns them into advisory recommendations per use case ([models](models.md#advisory-reads)). @@ -170,6 +172,9 @@ minimums. - Carries the app's portable artifact, configuration, storage, secret, dependency, readiness and endpoint requirements, and its typed deployment parameters, shown as basic or advanced. +- May name the use cases it serves (at most one master-catalog app per use case) and the + accelerator vendors it prefers. Both are recommendation and display metadata, with no admission, + placement or fit authority. Site and development catalogs can't claim a use case. - Engine releases and apps declare the storage they need. Deployments keep that declaration ([runtime](runtime.md)). @@ -193,7 +198,7 @@ The artifact manager also owns artifact bytes ([storage](storage.md)). - Catalog declarations are immutable. Deployments keep what they resolved ([models](models.md#deployment-snapshot)). -- Scores, licences and recommendations never affect admission, placement or fit. +- Scores, licences, app use cases, preferred accelerators and recommendations never affect admission, placement or fit. ## Future direction diff --git a/docs/specs/console.md b/docs/specs/console.md index 6a5de957..bc91649a 100644 --- a/docs/specs/console.md +++ b/docs/specs/console.md @@ -9,7 +9,7 @@ Code: `frontend/` (its `AGENTS.md` holds the code map). - Manage and observe the site, its machines, deployments and routes, through the management API. - See live updates as the site changes. -- Start with a guided quick start that recommends models for your use cases and hardware. +- Start with a guided Get started page that proposes, for each use case, a model, an app or both that can start now on your hardware. - Check fit and preview memory before deploying, from the server's capacity answers. - Explore metric history across machines, devices, deployments and routes. - Try inference routes: conversations with chat routes, and typed questions to decision routes diff --git a/docs/specs/models.md b/docs/specs/models.md index 4919f96e..6dfd06ed 100644 --- a/docs/specs/models.md +++ b/docs/specs/models.md @@ -13,8 +13,8 @@ recommendations), `backend/core/src/telemetry/` (workload resource records). execution target. - Use profiles as presets, and override typed options, engine parameters and expert launch arguments when you create a deployment. -- Get model recommendations for your use cases across the site's targets, through a guided quick - start. +- Get model recommendations for your use cases across the site's targets, through the Get started + page. - See which configured targets can run each profile, and narrow the catalog to profiles whose fit allows them there. - See advisory memory estimates for a configuration when measured evidence exists, and its fit on @@ -167,6 +167,9 @@ flowchart LR - Recommendations: each target takes its most specific hardware class's list per use case. Entries are kept or dropped by the capacity component's verdicts, entry minimums included, and the result is one ranking per use case across the site. +- A caller may ask for "can start now": rows keep only targets that already run the model or fit + free memory now, and rows with a running or starting deployment come first. Each row's target + names that deployment, and each row carries its context per request. - A target that stopped reporting is judged on its last observations for recommendations, marked unreachable with free memory unknown, until its node leaves the site. This is the only read that accepts stale observations. From ecded3d46b520116172c44d2b0fe21ddc6e7f41f Mon Sep 17 00:00:00 2001 From: Andre-Philippe Paquet Date: Thu, 12 Nov 2026 13:07:41 -0500 Subject: [PATCH 12/25] feat(proto): add get started dismissed preference --- frontend/src/gen/dotcore/console/v1/preferences_pb.ts | 9 ++++++++- proto/dotcore/console/v1/preferences.proto | 3 +++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/frontend/src/gen/dotcore/console/v1/preferences_pb.ts b/frontend/src/gen/dotcore/console/v1/preferences_pb.ts index 1a1ababf..846ad530 100644 --- a/frontend/src/gen/dotcore/console/v1/preferences_pb.ts +++ b/frontend/src/gen/dotcore/console/v1/preferences_pb.ts @@ -10,7 +10,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file dotcore/console/v1/preferences.proto. */ export const file_dotcore_console_v1_preferences: GenFile = /*@__PURE__*/ - fileDesc("CiRkb3Rjb3JlL2NvbnNvbGUvdjEvcHJlZmVyZW5jZXMucHJvdG8SEmRvdGNvcmUuY29uc29sZS52MSLeAQoSQ29uc29sZVByZWZlcmVuY2VzEjUKDGNvbG9yX3NjaGVtZRgBIAEoDjIfLmRvdGNvcmUuY29uc29sZS52MS5Db2xvclNjaGVtZRIZChFzaWRlYmFyX2NvbGxhcHNlZBgCIAEoCBIzCgttZW1vcnlfdmlldxgDIAEoDjIeLmRvdGNvcmUuY29uc29sZS52MS5NZW1vcnlWaWV3EkEKEmRldmVsb3Blcl9zZXR0aW5ncxgEIAEoCzIlLmRvdGNvcmUuY29uc29sZS52MS5EZXZlbG9wZXJTZXR0aW5ncyJcChFEZXZlbG9wZXJTZXR0aW5ncxIXCg9sYXRlbmN5X2VuYWJsZWQYASABKAgSFgoObGF0ZW5jeV9taW5fbXMYAiABKA0SFgoObGF0ZW5jeV9tYXhfbXMYAyABKA0qWgoLQ29sb3JTY2hlbWUSHAoYQ09MT1JfU0NIRU1FX1VOU1BFQ0lGSUVEEAASFgoSQ09MT1JfU0NIRU1FX0xJR0hUEAESFQoRQ09MT1JfU0NIRU1FX0RBUksQAipjCgpNZW1vcnlWaWV3EhsKF01FTU9SWV9WSUVXX1VOU1BFQ0lGSUVEEAASHAoYTUVNT1JZX1ZJRVdfQUxMX01BQ0hJTkVTEAESGgoWTUVNT1JZX1ZJRVdfQllfTUFDSElORRACYgZwcm90bzM"); + fileDesc("CiRkb3Rjb3JlL2NvbnNvbGUvdjEvcHJlZmVyZW5jZXMucHJvdG8SEmRvdGNvcmUuY29uc29sZS52MSL9AQoSQ29uc29sZVByZWZlcmVuY2VzEjUKDGNvbG9yX3NjaGVtZRgBIAEoDjIfLmRvdGNvcmUuY29uc29sZS52MS5Db2xvclNjaGVtZRIZChFzaWRlYmFyX2NvbGxhcHNlZBgCIAEoCBIzCgttZW1vcnlfdmlldxgDIAEoDjIeLmRvdGNvcmUuY29uc29sZS52MS5NZW1vcnlWaWV3EkEKEmRldmVsb3Blcl9zZXR0aW5ncxgEIAEoCzIlLmRvdGNvcmUuY29uc29sZS52MS5EZXZlbG9wZXJTZXR0aW5ncxIdChVnZXRfc3RhcnRlZF9kaXNtaXNzZWQYBSABKAgiXAoRRGV2ZWxvcGVyU2V0dGluZ3MSFwoPbGF0ZW5jeV9lbmFibGVkGAEgASgIEhYKDmxhdGVuY3lfbWluX21zGAIgASgNEhYKDmxhdGVuY3lfbWF4X21zGAMgASgNKloKC0NvbG9yU2NoZW1lEhwKGENPTE9SX1NDSEVNRV9VTlNQRUNJRklFRBAAEhYKEkNPTE9SX1NDSEVNRV9MSUdIVBABEhUKEUNPTE9SX1NDSEVNRV9EQVJLEAIqYwoKTWVtb3J5VmlldxIbChdNRU1PUllfVklFV19VTlNQRUNJRklFRBAAEhwKGE1FTU9SWV9WSUVXX0FMTF9NQUNISU5FUxABEhoKFk1FTU9SWV9WSUVXX0JZX01BQ0hJTkUQAmIGcHJvdG8z"); /** * Stores browser-local console preferences. @@ -47,6 +47,13 @@ export type ConsolePreferences = Message<"dotcore.console.v1.ConsolePreferences" * @generated from field: dotcore.console.v1.DeveloperSettings developer_settings = 4; */ developerSettings?: DeveloperSettings; + + /** + * Hides the Get started navigation item once dismissed; absent reads as shown. + * + * @generated from field: bool get_started_dismissed = 5; + */ + getStartedDismissed: boolean; }; /** diff --git a/proto/dotcore/console/v1/preferences.proto b/proto/dotcore/console/v1/preferences.proto index 3a2eaa55..c26051c4 100644 --- a/proto/dotcore/console/v1/preferences.proto +++ b/proto/dotcore/console/v1/preferences.proto @@ -17,6 +17,9 @@ message ConsolePreferences { // Holds the tools that help develop and debug the Console itself. DeveloperSettings developer_settings = 4; + + // Hides the Get started navigation item once dismissed; absent reads as shown. + bool get_started_dismissed = 5; } // Configures the synthetic latency the Console adds to its own API calls. From bac9dea3c811c33bf236d1b31705b6e85548d87d Mon Sep 17 00:00:00 2001 From: Andre-Philippe Paquet Date: Thu, 12 Nov 2026 13:13:54 -0500 Subject: [PATCH 13/25] feat(frontend): add get started page --- frontend/src/api/application-client.ts | 2 + frontend/src/api/model-client.ts | 2 + frontend/src/app/console-layout.test.tsx | 2 +- frontend/src/app/console-sidebar.test.tsx | 2 +- frontend/src/app/console-sidebar.tsx | 19 +- .../src/app/get-started-entry-points.test.tsx | 81 + frontend/src/app/profile-menu.test.tsx | 5 +- frontend/src/app/profile-menu.tsx | 13 +- frontend/src/app/router.test.tsx | 2 + frontend/src/app/routes.ts | 5 + frontend/src/components/ui/section-icons.ts | 1 + frontend/src/domain/model-projection.ts | 18 + .../applications/application-inspector.tsx | 26 +- .../applications-section-test-support.tsx | 1 + .../get-started/customize-model-dialog.tsx | 110 ++ .../features/get-started/deploying-step.tsx | 202 +++ .../features/get-started/followed-step.tsx | 102 ++ .../get-started/get-started-launch.ts | 87 ++ .../get-started/get-started-page.test.tsx | 1352 +++++++++++++++++ .../features/get-started/get-started-page.tsx | 217 +++ .../get-started/get-started-proposals.ts | 227 +++ .../features/get-started/machines-card.tsx | 105 ++ .../features/get-started/proposal-card.tsx | 437 ++++++ .../src/features/get-started/ready-step.tsx | 187 +++ .../features/get-started/use-case-cards.tsx | 51 + .../src/features/get-started/use-case-copy.ts | 27 + .../get-started/use-get-started-data.test.tsx | 444 ++++++ .../get-started/use-get-started-data.ts | 235 +++ .../get-started/use-get-started-deploy.ts | 182 +++ .../get-started/use-proposal-memory.ts | 160 ++ .../inference-routes/routes-page.test.tsx | 1 + .../deploy/model-deploy-dialog.test.tsx | 50 + .../models/deploy/model-deploy-dialog.tsx | 24 +- .../models/deploy/use-model-deploy-form.ts | 18 +- .../deployed/model-deployment-table.tsx | 20 +- .../deployed/models-section-test-support.tsx | 1 + .../models/inspector/deployment-overview.tsx | 19 +- .../polling/use-models-polling.test.tsx | 1 + .../models/runtime/engine-parameter-values.ts | 12 + .../src/hooks/use-get-started-dismissed.ts | 30 + frontend/src/lib/console-preferences.test.ts | 42 + frontend/src/lib/console-preferences.ts | 69 + frontend/src/lib/timing.ts | 6 + frontend/src/test/setup.ts | 1 + 44 files changed, 4544 insertions(+), 54 deletions(-) create mode 100644 frontend/src/app/get-started-entry-points.test.tsx create mode 100644 frontend/src/features/get-started/customize-model-dialog.tsx create mode 100644 frontend/src/features/get-started/deploying-step.tsx create mode 100644 frontend/src/features/get-started/followed-step.tsx create mode 100644 frontend/src/features/get-started/get-started-launch.ts create mode 100644 frontend/src/features/get-started/get-started-page.test.tsx create mode 100644 frontend/src/features/get-started/get-started-page.tsx create mode 100644 frontend/src/features/get-started/get-started-proposals.ts create mode 100644 frontend/src/features/get-started/machines-card.tsx create mode 100644 frontend/src/features/get-started/proposal-card.tsx create mode 100644 frontend/src/features/get-started/ready-step.tsx create mode 100644 frontend/src/features/get-started/use-case-cards.tsx create mode 100644 frontend/src/features/get-started/use-case-copy.ts create mode 100644 frontend/src/features/get-started/use-get-started-data.test.tsx create mode 100644 frontend/src/features/get-started/use-get-started-data.ts create mode 100644 frontend/src/features/get-started/use-get-started-deploy.ts create mode 100644 frontend/src/features/get-started/use-proposal-memory.ts create mode 100644 frontend/src/hooks/use-get-started-dismissed.ts diff --git a/frontend/src/api/application-client.ts b/frontend/src/api/application-client.ts index 8c90fc09..aa191a50 100644 --- a/frontend/src/api/application-client.ts +++ b/frontend/src/api/application-client.ts @@ -34,6 +34,7 @@ export type ApplicationCatalogClient = Pick, "list type GeneratedApplicationDeploymentServiceClient = Pick< Client, | "listApplicationDeployments" + | "listApplicationTargets" | "getApplicationDeployment" | "createApplicationDeployment" | "removeApplicationDeployment" @@ -71,6 +72,7 @@ export function createApplicationClient(baseUrl = applicationServerUrl): Applica catalog: createClient(CatalogService, transport), application: { listApplicationDeployments: application.listApplicationDeployments, + listApplicationTargets: application.listApplicationTargets, getApplicationDeployment: application.getApplicationDeployment, createApplicationDeployment: async (request) => validateCreateApplicationResponse( diff --git a/frontend/src/api/model-client.ts b/frontend/src/api/model-client.ts index 70efd2a4..17ef73b4 100644 --- a/frontend/src/api/model-client.ts +++ b/frontend/src/api/model-client.ts @@ -40,6 +40,7 @@ export type CatalogClient = Pick< type GeneratedModelDeploymentServiceClient = Pick< Client, | "listModelDeployments" + | "listModelRecommendations" | "createModelDeployment" | "updateModelDeployment" | "removeModelDeployment" @@ -92,6 +93,7 @@ export function createModelClient(baseUrl = modelServerUrl): ModelClient { catalog: createClient(CatalogService, transport), model: { listModelDeployments: model.listModelDeployments, + listModelRecommendations: model.listModelRecommendations, previewLocalModelDeployment: (local) => model.previewLocalModelDeployment( create(PreviewLocalModelDeploymentRequestSchema, { local }), diff --git a/frontend/src/app/console-layout.test.tsx b/frontend/src/app/console-layout.test.tsx index 69bd5fd5..cab94481 100644 --- a/frontend/src/app/console-layout.test.tsx +++ b/frontend/src/app/console-layout.test.tsx @@ -268,7 +268,7 @@ describe("ConsoleLayout", () => { await user.tab(); expect(document.activeElement).toHaveAccessibleName("Open profile menu"); - for (const path of ["/overview", "/machines"]) { + for (const path of ["/get-started", "/overview", "/machines"]) { await user.tab(); expect(document.activeElement).toHaveAttribute("href", path); } diff --git a/frontend/src/app/console-sidebar.test.tsx b/frontend/src/app/console-sidebar.test.tsx index 9286a830..44e7d36b 100644 --- a/frontend/src/app/console-sidebar.test.tsx +++ b/frontend/src/app/console-sidebar.test.tsx @@ -123,7 +123,7 @@ describe("ConsoleSidebar primary destinations", () => { within(groupRow.parentElement as HTMLElement) .getAllByRole("link") .map((link) => link.getAttribute("href")), - ).toEqual(["/overview", "/machines", "/models", "/models/catalog", "/routes"]); + ).toEqual(["/get-started", "/overview", "/machines", "/models", "/models/catalog", "/routes"]); }); it("groups the Apps and Catalog links under Apps, open on the Apps pages", () => { diff --git a/frontend/src/app/console-sidebar.tsx b/frontend/src/app/console-sidebar.tsx index be77b110..62bc1fe8 100644 --- a/frontend/src/app/console-sidebar.tsx +++ b/frontend/src/app/console-sidebar.tsx @@ -9,6 +9,7 @@ import { AppsSectionIcon, CatalogSectionIcon, ChatSectionIcon, + GetStartedSectionIcon, MachinesSectionIcon, ModelsSectionIcon, OverviewSectionIcon, @@ -30,6 +31,7 @@ import { SidebarSeparator, useSidebar, } from "@/components/ui/sidebar"; +import { useGetStartedDismissed } from "@/hooks/use-get-started-dismissed"; import { timing } from "@/lib/timing"; import { usePanelNavigationPending } from "@/panels/panel-navigation-pending"; @@ -49,7 +51,14 @@ interface NavItem { children?: NavChildItem[]; } +const getStartedNavItem: NavItem = { + icon: GetStartedSectionIcon, + label: "Get started", + path: "/get-started", +}; + const primaryNavItems: NavItem[] = [ + getStartedNavItem, { icon: OverviewSectionIcon, label: "Overview", path: "/overview" }, { icon: MachinesSectionIcon, label: "Machines", path: "/machines" }, { @@ -76,12 +85,13 @@ const footerNavItem: NavItem = { icon: SettingsSectionIcon, label: "Settings" }; /** * Icon-collapsible left navigation with direct destinations and optional disclosure groups. * Settings has no route yet and renders disabled. Primary items default to the console - * destinations; supplied items use the same linked, disabled and collapsible row treatments. + * destinations, with Get started until dismissed; supplied items use the same linked, disabled and + * collapsible row treatments. */ export function ConsoleSidebar({ chatCurrent = false, chatOpen = false, - items = primaryNavItems, + items, onChatToggle, }: { /** Whether the Chat page is the current page, which marks the Chat item current. */ @@ -92,6 +102,9 @@ export function ConsoleSidebar({ onChatToggle?: () => void; } = {}) { const { pathname } = useLocation(); + const [getStartedDismissed] = useGetStartedDismissed(); + const navItems = + items ?? primaryNavItems.filter((item) => !(getStartedDismissed && item === getStartedNavItem)); const { state } = useSidebar(); const { hovering, onPointerEnter, onPointerLeave, suppressHover } = useHoverPeek(); const { focusWithin, onBlur, onFocus, onKeyDown, onPointerDown, suppressFocus } = @@ -126,7 +139,7 @@ export function ConsoleSidebar({ - {items.map((item) => ( + {navItems.map((item) => ( ))} diff --git a/frontend/src/app/get-started-entry-points.test.tsx b/frontend/src/app/get-started-entry-points.test.tsx new file mode 100644 index 00000000..9d82419d --- /dev/null +++ b/frontend/src/app/get-started-entry-points.test.tsx @@ -0,0 +1,81 @@ +import { screen, within } from "@testing-library/react"; +import { createMemoryRouter, RouterProvider } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { routes } from "@/app/routes"; +import { renderWithClients } from "@/test/render-with-clients"; +import { setupUser } from "@/test/user"; +import { ThemeProvider } from "@/theme/theme-provider"; + +function renderConsole(initialEntry: string) { + const router = createMemoryRouter(routes, { initialEntries: [initialEntry] }); + const view = renderWithClients( + + + , + ); + + return { router, ...view }; +} + +function sidebarLink(name: string) { + return screen.queryByRole("link", { name }); +} + +beforeEach(() => window.localStorage.clear()); +afterEach(() => window.localStorage.clear()); + +describe("Get started entry points", () => { + it("shows the sidebar item first by default and opens the page", async () => { + const user = setupUser(); + const { router } = renderConsole("/overview"); + + const links = screen.getAllByRole("link"); + expect(links[0]).toHaveTextContent("Get started"); + expect(links[0]).toHaveAttribute("href", "/get-started"); + + await user.click(links[0]); + + expect(router.state.location.pathname).toBe("/get-started"); + expect(await screen.findByRole("heading", { name: "Get started" })).toBeVisible(); + }); + + it("hides the item on Dismiss, goes to the overview and stays hidden after a reload", async () => { + const user = setupUser(); + const first = renderConsole("/get-started"); + + await user.click(await screen.findByRole("button", { name: "Dismiss" })); + + expect(first.router.state.location.pathname).toBe("/overview"); + expect(sidebarLink("Get started")).not.toBeInTheDocument(); + + first.unmount(); + renderConsole("/overview"); + + expect(await screen.findByRole("link", { name: "Overview" })).toBeVisible(); + expect(sidebarLink("Get started")).not.toBeInTheDocument(); + }); + + it("shows the item again and opens the page from the profile menu", async () => { + const user = setupUser(); + const { router } = renderConsole("/get-started"); + + await user.click(await screen.findByRole("button", { name: "Dismiss" })); + expect(sidebarLink("Get started")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Open profile menu" })); + await user.click( + within(screen.getByRole("menu")).getByRole("menuitem", { name: "Get started" }), + ); + + expect(router.state.location.pathname).toBe("/get-started"); + expect(await screen.findByRole("link", { name: "Get started" })).toHaveAttribute( + "href", + "/get-started", + ); + }); +}); diff --git a/frontend/src/app/profile-menu.test.tsx b/frontend/src/app/profile-menu.test.tsx index 328be719..56e931da 100644 --- a/frontend/src/app/profile-menu.test.tsx +++ b/frontend/src/app/profile-menu.test.tsx @@ -79,6 +79,7 @@ describe("ProfileMenu", () => { ...within(menu).getAllByRole("menuitemcheckbox"), ]; expect(items.map((item) => item.textContent)).toEqual([ + "Get started", "Preferences", "Site logs", "Dark mode", @@ -106,9 +107,9 @@ describe("ProfileMenu", () => { trigger.focus(); await user.keyboard("{Enter}"); await waitFor(() => - expect(screen.getByRole("menuitem", { name: "Preferences" })).toHaveFocus(), + expect(screen.getByRole("menuitem", { name: "Get started" })).toHaveFocus(), ); - await user.keyboard("{ArrowDown}"); + await user.keyboard("{ArrowDown}{ArrowDown}"); await waitFor(() => expect(screen.getByRole("menuitem", { name: "Site logs" })).toHaveFocus(), ); diff --git a/frontend/src/app/profile-menu.tsx b/frontend/src/app/profile-menu.tsx index 572d536b..54e328f1 100644 --- a/frontend/src/app/profile-menu.tsx +++ b/frontend/src/app/profile-menu.tsx @@ -10,15 +10,17 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { PreferencesSectionIcon } from "@/components/ui/section-icons"; +import { GetStartedSectionIcon, PreferencesSectionIcon } from "@/components/ui/section-icons"; import { DiagnosticLogViewer } from "@/features/diagnostic-logs/diagnostic-log-viewer"; import { ColorScheme } from "@/gen/dotcore/console/v1/preferences_pb"; +import { useGetStartedDismissed } from "@/hooks/use-get-started-dismissed"; import { useTheme } from "@/theme/theme-context"; export function ProfileMenu() { const { diagnosticLogOperations: diagnostics } = useClients(); const { colorScheme, setColorScheme } = useTheme(); const navigate = useNavigate(); + const [, setGetStartedDismissed] = useGetStartedDismissed(); const [menuOpen, setMenuOpen] = useState(false); const [logsOpen, setLogsOpen] = useState(false); const openLogsAfterMenuCloseRef = useRef(false); @@ -63,6 +65,15 @@ export function ProfileMenu() { className="w-[min(12rem,calc(100vw-16px))] border border-border" sideOffset={8} > + { + setGetStartedDismissed(false); + void navigate("/get-started"); + }} + > + void navigate("/preferences")}>