From 7fd017432f7b07675afe4b3342dbf485ffe40884 Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Mon, 6 Apr 2026 18:29:56 +0000 Subject: [PATCH 1/3] Add version-level lifecycle status to AI model types and filter out deprecated versions by default --- .../docs/extensions/extension-framework.md | 6 +- .../microsoft.azd.demo/internal/cmd/ai.go | 4 +- cli/azd/grpc/proto/ai_model.proto | 8 +- cli/azd/pkg/ai/mapper_registry.go | 14 +- cli/azd/pkg/ai/model_service.go | 145 +++++++-- cli/azd/pkg/ai/model_service_test.go | 287 ++++++++++++++++++ cli/azd/pkg/ai/types.go | 7 +- cli/azd/pkg/azdext/ai_model.pb.go | 52 ++-- cli/azd/pkg/azdext/models.pb.go | 4 +- 9 files changed, 473 insertions(+), 54 deletions(-) diff --git a/cli/azd/docs/extensions/extension-framework.md b/cli/azd/docs/extensions/extension-framework.md index 7b4ce2d7a1b..24da8cc7928 100644 --- a/cli/azd/docs/extensions/extension-framework.md +++ b/cli/azd/docs/extensions/extension-framework.md @@ -1979,11 +1979,15 @@ Returns available AI models for a subscription. - `locations` (repeated string) - `capabilities` (repeated string) - `formats` (repeated string) - - `statuses` (repeated string) + - `statuses` (repeated string, applied to version lifecycle status before aggregation) - `exclude_model_names` (repeated string) - **Response:** _ListModelsResponse_ - `models` (repeated _AiModel_) +`filter.statuses` matches version-level lifecycle status before aggregation. Returned models +only contain versions (and locations) that matched. `AiModel.lifecycle_status` is derived from +the default surviving version. + If `filter.locations` is empty, models are listed across all subscription locations. When `filter.locations` is provided, it limits which models are returned, but each returned model still contains canonical `locations`. diff --git a/cli/azd/extensions/microsoft.azd.demo/internal/cmd/ai.go b/cli/azd/extensions/microsoft.azd.demo/internal/cmd/ai.go index e0efae03a05..4405c4de5b0 100644 --- a/cli/azd/extensions/microsoft.azd.demo/internal/cmd/ai.go +++ b/cli/azd/extensions/microsoft.azd.demo/internal/cmd/ai.go @@ -104,7 +104,6 @@ func printAiModelDetails(model *azdext.AiModel) { color.HiWhite("Model Details") fmt.Printf(" Name: %s\n", color.CyanString(model.Name)) fmt.Printf(" Format: %s\n", model.Format) - fmt.Printf(" Status: %s\n", model.LifecycleStatus) if len(model.Capabilities) > 0 { capabilities := slices.Clone(model.Capabilities) @@ -138,6 +137,9 @@ func printAiModelDetails(model *azdext.AiModel) { defaultLabel = color.YellowString(" (default)") } fmt.Printf(" - Version: %s%s\n", version.Version, defaultLabel) + if version.LifecycleStatus != "" { + fmt.Printf(" Status: %s\n", version.LifecycleStatus) + } skus := slices.Clone(version.Skus) slices.SortFunc(skus, func(a, b *azdext.AiModelSku) int { diff --git a/cli/azd/grpc/proto/ai_model.proto b/cli/azd/grpc/proto/ai_model.proto index f992583ae63..9d39996314a 100644 --- a/cli/azd/grpc/proto/ai_model.proto +++ b/cli/azd/grpc/proto/ai_model.proto @@ -40,7 +40,7 @@ service AiModelService { message AiModel { string name = 1; // e.g. "gpt-4o" string format = 2; // e.g. "OpenAI" - string lifecycle_status = 3; // e.g. "preview", "stable" + string lifecycle_status = 3 [deprecated = true]; // derived from the default surviving version; prefer AiModelVersion.lifecycle_status repeated string capabilities = 4; // e.g. ["chat", "embeddings"] repeated AiModelVersion versions = 5; repeated string locations = 6; // canonical locations where available @@ -50,6 +50,7 @@ message AiModelVersion { string version = 1; bool is_default = 2; repeated AiModelSku skus = 3; + string lifecycle_status = 4; // e.g. "GenerallyAvailable", "Preview" } // AiModelSku represents a deployment SKU with capacity constraints. @@ -110,8 +111,9 @@ message AiModelFilterOptions { // Matches AiModel.format exactly (for example: "OpenAI", "Microsoft"). repeated string formats = 3; - // Include models whose lifecycle status matches one of these values. - // Matches AiModel.lifecycle_status exactly (for example: "Stable", "Preview"). + // Include model versions whose lifecycle status matches one of these values. + // Filtering is applied before aggregation, so returned versions, derived + // AiModel.lifecycle_status, and locations reflect only matching versions. repeated string statuses = 4; // Exclude models by exact model name (for example: "gpt-4o-mini"). diff --git a/cli/azd/pkg/ai/mapper_registry.go b/cli/azd/pkg/ai/mapper_registry.go index 570a0d68a9d..b9ebba94fd8 100644 --- a/cli/azd/pkg/ai/mapper_registry.go +++ b/cli/azd/pkg/ai/mapper_registry.go @@ -120,9 +120,10 @@ func aiModelVersionToProto(src *AiModelVersion) (*azdext.AiModelVersion, error) } return &azdext.AiModelVersion{ - Version: src.Version, - IsDefault: src.IsDefault, - Skus: skus, + Version: src.Version, + IsDefault: src.IsDefault, + Skus: skus, + LifecycleStatus: src.LifecycleStatus, }, nil } @@ -133,9 +134,10 @@ func protoToAiModelVersion(src *azdext.AiModelVersion) AiModelVersion { } return AiModelVersion{ - Version: src.Version, - IsDefault: src.IsDefault, - Skus: skus, + Version: src.Version, + IsDefault: src.IsDefault, + Skus: skus, + LifecycleStatus: src.LifecycleStatus, } } diff --git a/cli/azd/pkg/ai/model_service.go b/cli/azd/pkg/ai/model_service.go index 013aaec11aa..8f4d944e068 100644 --- a/cli/azd/pkg/ai/model_service.go +++ b/cli/azd/pkg/ai/model_service.go @@ -10,6 +10,7 @@ import ( "slices" "strings" "sync" + "time" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices" "github.com/azure/azure-dev/cli/azd/pkg/account" @@ -82,14 +83,30 @@ func (s *AiModelService) ListFilteredModels( subscriptionId string, options *FilterOptions, ) ([]AiModel, error) { + if options == nil { + return s.ListModels(ctx, subscriptionId, nil) + } + + filteredOptions := *options + // Fetch canonical models and apply filters in-memory so model metadata - // (especially Locations) remains complete. - models, err := s.ListModels(ctx, subscriptionId, nil) + // remains complete for non-status filters. Status filtering is applied during + // aggregation so Versions, derived LifecycleStatus, and Locations reflect only + // versions matching the requested statuses. + locations, err := s.ListLocations(ctx, subscriptionId) + if err != nil { + return nil, err + } + + rawModels, err := s.fetchModelsForLocations(ctx, subscriptionId, locations) if err != nil { return nil, err } - return FilterModels(models, options), nil + models := s.convertToAiModelsAt(rawModels, time.Now().UTC(), filteredOptions.Statuses) + filteredOptions.Statuses = nil + + return FilterModels(models, &filteredOptions), nil } // ListModelVersions returns available versions for a specific model at a location. @@ -569,13 +586,27 @@ func (s *AiModelService) fetchModelsForLocations( // convertToAiModels converts raw ARM models grouped by location into domain AiModel types. func (s *AiModelService) convertToAiModels( rawByLocation map[string][]*armcognitiveservices.Model, +) []AiModel { + return s.convertToAiModelsAt(rawByLocation, time.Now().UTC(), nil) +} + +// convertToAiModelsAt converts raw ARM models grouped by location into domain AiModel types, +// optionally filtering by version lifecycle status before aggregation. The now parameter +// makes deprecation filtering deterministic in tests. +func (s *AiModelService) convertToAiModelsAt( + rawByLocation map[string][]*armcognitiveservices.Model, + now time.Time, + statuses []string, ) []AiModel { // Aggregate: model name → location → version → SKUs modelMap := make(map[string]*AiModel) for loc, models := range rawByLocation { for _, m := range models { - if m.Model == nil || m.Model.Name == nil { + if m.Model == nil || m.Model.Name == nil || modelVersionDeprecated(m.Model, now) { + continue + } + if len(statuses) > 0 && !slices.Contains(statuses, modelLifecycleStatusValue(m.Model.LifecycleStatus)) { continue } name := *m.Model.Name @@ -586,9 +617,6 @@ func (s *AiModelService) convertToAiModels( Name: name, Format: safeString(m.Model.Format), } - if m.Model.LifecycleStatus != nil { - aiModel.LifecycleStatus = string(*m.Model.LifecycleStatus) - } if m.Model.Capabilities != nil { for key := range m.Model.Capabilities { aiModel.Capabilities = append(aiModel.Capabilities, key) @@ -598,21 +626,29 @@ func (s *AiModelService) convertToAiModels( modelMap[name] = aiModel } - // Track locations - if !slices.Contains(aiModel.Locations, loc) { - aiModel.Locations = append(aiModel.Locations, loc) - } - // Build version entry ver := safeString(m.Model.Version) isDefault := m.Model.IsDefaultVersion != nil && *m.Model.IsDefaultVersion + lifecycleStatus := modelLifecycleStatusValue(m.Model.LifecycleStatus) + hadSkus := len(m.Model.SKUs) > 0 var skus []AiModelSku if m.Model.SKUs != nil { for _, sku := range m.Model.SKUs { + if modelSkuDeprecated(sku, now) { + continue + } skus = append(skus, convertSku(sku)) } } + if hadSkus && len(skus) == 0 { + continue + } + + // Track locations only when this location contributes a surviving version/SKU. + if !slices.Contains(aiModel.Locations, loc) { + aiModel.Locations = append(aiModel.Locations, loc) + } // Find or create version in model versionFound := false @@ -622,6 +658,9 @@ func (s *AiModelService) convertToAiModels( if isDefault { aiModel.Versions[i].IsDefault = true } + if aiModel.Versions[i].LifecycleStatus == "" { + aiModel.Versions[i].LifecycleStatus = lifecycleStatus + } // Merge SKUs (deduplicate by name + usage_name, since the same SKU name // can appear with different usage names representing different quota pools) for _, newSku := range skus { @@ -636,9 +675,10 @@ func (s *AiModelService) convertToAiModels( } if !versionFound { aiModel.Versions = append(aiModel.Versions, AiModelVersion{ - Version: ver, - IsDefault: isDefault, - Skus: skus, + Version: ver, + IsDefault: isDefault, + LifecycleStatus: lifecycleStatus, + Skus: skus, }) } } @@ -647,6 +687,9 @@ func (s *AiModelService) convertToAiModels( // Convert map to sorted slice result := make([]AiModel, 0, len(modelMap)) for _, model := range modelMap { + if len(model.Versions) == 0 { + continue + } slices.Sort(model.Locations) result = append(result, *model) } @@ -657,7 +700,66 @@ func (s *AiModelService) convertToAiModels( return result } -// FilterModels applies FilterOptions to a list of models. +func modelVersionDeprecated(model *armcognitiveservices.AccountModel, now time.Time) bool { + if model == nil { + return false + } + + if modelLifecycleDeprecated(model.LifecycleStatus) { + return true + } + + return modelDeprecationReached(model.Deprecation, now) +} + +func modelLifecycleDeprecated(status *armcognitiveservices.ModelLifecycleStatus) bool { + if status == nil { + return false + } + + return strings.EqualFold(string(*status), "Deprecated") +} + +func modelLifecycleStatusValue(status *armcognitiveservices.ModelLifecycleStatus) string { + if status == nil { + return "" + } + + return string(*status) +} + +func modelDeprecationReached(info *armcognitiveservices.ModelDeprecationInfo, now time.Time) bool { + if info == nil || info.Inference == nil { + return false + } + + return deprecationReached(*info.Inference, now) +} + +func modelSkuDeprecated(sku *armcognitiveservices.ModelSKU, now time.Time) bool { + if sku == nil || sku.DeprecationDate == nil { + return false + } + + return !sku.DeprecationDate.After(now) +} + +func deprecationReached(value string, now time.Time) bool { + if strings.TrimSpace(value) == "" { + return false + } + + deprecatedAt, err := time.Parse(time.RFC3339, value) + if err != nil { + return false + } + + return !deprecatedAt.After(now) +} + +// FilterModels applies FilterOptions to already-aggregated models. When Statuses is set, +// versions are pruned, but Locations cannot be recomputed (version-to-location provenance +// is lost). Use ListFilteredModels for full fidelity. func FilterModels(models []AiModel, options *FilterOptions) []AiModel { if options == nil { return models @@ -665,15 +767,20 @@ func FilterModels(models []AiModel, options *FilterOptions) []AiModel { var filtered []AiModel for _, model := range models { + if len(options.Statuses) > 0 { + model.Versions = slices.DeleteFunc(slices.Clone(model.Versions), func(version AiModelVersion) bool { + return !slices.Contains(options.Statuses, version.LifecycleStatus) + }) + if len(model.Versions) == 0 { + continue + } + } if len(options.ExcludeModelNames) > 0 && slices.Contains(options.ExcludeModelNames, model.Name) { continue } if len(options.Formats) > 0 && !slices.Contains(options.Formats, model.Format) { continue } - if len(options.Statuses) > 0 && !slices.Contains(options.Statuses, model.LifecycleStatus) { - continue - } if len(options.Capabilities) > 0 { hasCapability := false for _, cap := range options.Capabilities { diff --git a/cli/azd/pkg/ai/model_service_test.go b/cli/azd/pkg/ai/model_service_test.go index e56697fbcfa..0fe285af99f 100644 --- a/cli/azd/pkg/ai/model_service_test.go +++ b/cli/azd/pkg/ai/model_service_test.go @@ -5,7 +5,9 @@ package ai import ( "testing" + "time" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices" "github.com/stretchr/testify/require" ) @@ -17,6 +19,10 @@ func TestFilterModels(t *testing.T) { LifecycleStatus: "stable", Capabilities: []string{"chat", "completion"}, Locations: []string{"eastus", "westus"}, + Versions: []AiModelVersion{ + {Version: "2024-05-13", LifecycleStatus: "stable"}, + {Version: "2024-11-20", IsDefault: true, LifecycleStatus: "stable"}, + }, }, { Name: "gpt-4o-mini", @@ -24,6 +30,9 @@ func TestFilterModels(t *testing.T) { LifecycleStatus: "preview", Capabilities: []string{"chat"}, Locations: []string{"eastus"}, + Versions: []AiModelVersion{ + {Version: "2024-07-18", IsDefault: true, LifecycleStatus: "preview"}, + }, }, { Name: "text-embedding-ada-002", @@ -31,6 +40,9 @@ func TestFilterModels(t *testing.T) { LifecycleStatus: "stable", Capabilities: []string{"embeddings"}, Locations: []string{"westus"}, + Versions: []AiModelVersion{ + {Version: "2", IsDefault: true, LifecycleStatus: "stable"}, + }, }, } @@ -103,6 +115,281 @@ func TestFilterModels(t *testing.T) { } } +func TestFilterModels_FiltersVersionsByStatus(t *testing.T) { + t.Parallel() + + models := []AiModel{ + { + Name: "gpt-4o", + Format: "OpenAI", + LifecycleStatus: "GenerallyAvailable", + Locations: []string{"eastus", "westus"}, + Versions: []AiModelVersion{ + {Version: "2024-08-06", LifecycleStatus: "Deprecating"}, + {Version: "2024-11-20", IsDefault: true, LifecycleStatus: "GenerallyAvailable"}, + }, + }, + } + + filtered := FilterModels(models, &FilterOptions{Statuses: []string{"Deprecating"}}) + require.Len(t, filtered, 1) + require.Len(t, filtered[0].Versions, 1) + require.Equal(t, "2024-08-06", filtered[0].Versions[0].Version) + require.Equal(t, "Deprecating", filtered[0].Versions[0].LifecycleStatus) +} + +func TestConvertToAiModels_FiltersDeprecatedVersionsAndSkus(t *testing.T) { + t.Parallel() + + svc := NewAiModelService(nil, nil) + now := time.Date(2026, 4, 6, 0, 0, 0, 0, time.UTC) + + rawModels := map[string][]*armcognitiveservices.Model{ + "northcentralus": { + { + Model: &armcognitiveservices.AccountModel{ + Name: new("gpt-35-turbo"), + Version: new("0613"), + LifecycleStatus: new(armcognitiveservices.ModelLifecycleStatus("Deprecated")), + Deprecation: &armcognitiveservices.ModelDeprecationInfo{ + Inference: new("2025-04-30T00:00:00Z"), + }, + SKUs: []*armcognitiveservices.ModelSKU{ + { + Name: new("Standard"), + UsageName: new("OpenAI.Standard.gpt-35-turbo"), + DeprecationDate: new(time.Date(2025, 2, 13, 0, 0, 0, 0, time.UTC)), + }, + }, + }, + }, + { + Model: &armcognitiveservices.AccountModel{ + Name: new("gpt-4o"), + Version: new("2024-08-06"), + LifecycleStatus: new(armcognitiveservices.ModelLifecycleStatus("Deprecating")), + Deprecation: &armcognitiveservices.ModelDeprecationInfo{ + Inference: new("2026-10-01T00:00:00Z"), + }, + SKUs: []*armcognitiveservices.ModelSKU{ + { + Name: new("Standard"), + UsageName: new("OpenAI.Standard.gpt-4o"), + DeprecationDate: new(time.Date(2026, 3, 31, 0, 0, 0, 0, time.UTC)), + }, + { + Name: new("GlobalStandard"), + UsageName: new("OpenAI.GlobalStandard.gpt-4o"), + DeprecationDate: new(time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)), + }, + }, + }, + }, + { + Model: &armcognitiveservices.AccountModel{ + Name: new("all-expired"), + Version: new("1"), + LifecycleStatus: new(armcognitiveservices.ModelLifecycleStatus("GenerallyAvailable")), + SKUs: []*armcognitiveservices.ModelSKU{ + { + Name: new("Standard"), + UsageName: new("Custom.Standard.all-expired"), + DeprecationDate: new(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)), + }, + }, + }, + }, + { + Model: &armcognitiveservices.AccountModel{ + Name: new("gpt-4.1-mini"), + Version: new("2025-04-14"), + LifecycleStatus: new(armcognitiveservices.ModelLifecycleStatus("GenerallyAvailable")), + SKUs: []*armcognitiveservices.ModelSKU{ + { + Name: new("GlobalStandard"), + UsageName: new("OpenAI.GlobalStandard.gpt-4.1-mini"), + DeprecationDate: new(time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC)), + }, + }, + }, + }, + }, + } + + models := svc.convertToAiModelsAt(rawModels, now, nil) + require.Len(t, models, 2) + + require.Equal(t, "gpt-4.1-mini", models[0].Name) + require.Equal(t, "gpt-4o", models[1].Name) + + require.Len(t, models[1].Versions, 1) + require.Equal(t, "2024-08-06", models[1].Versions[0].Version) + require.Equal(t, "Deprecating", models[1].Versions[0].LifecycleStatus) + require.Len(t, models[1].Versions[0].Skus, 1) + require.Equal(t, "GlobalStandard", models[1].Versions[0].Skus[0].Name) + require.Equal(t, "OpenAI.GlobalStandard.gpt-4o", models[1].Versions[0].Skus[0].UsageName) +} + +func TestConvertToAiModels_PreservesVersionLifecycleStatus(t *testing.T) { + t.Parallel() + + svc := NewAiModelService(nil, nil) + now := time.Date(2026, 4, 6, 0, 0, 0, 0, time.UTC) + + rawModels := map[string][]*armcognitiveservices.Model{ + "northcentralus": { + { + Model: &armcognitiveservices.AccountModel{ + Name: new("gpt-4o"), + Version: new("2024-08-06"), + LifecycleStatus: new(armcognitiveservices.ModelLifecycleStatus("Deprecating")), + SKUs: []*armcognitiveservices.ModelSKU{ + { + Name: new("GlobalStandard"), + UsageName: new("OpenAI.GlobalStandard.gpt-4o"), + DeprecationDate: new(time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)), + }, + }, + }, + }, + { + Model: &armcognitiveservices.AccountModel{ + Name: new("gpt-4o"), + Version: new("2024-11-20"), + IsDefaultVersion: new(true), + LifecycleStatus: new(armcognitiveservices.ModelLifecycleStatus("GenerallyAvailable")), + SKUs: []*armcognitiveservices.ModelSKU{ + { + Name: new("GlobalStandard"), + UsageName: new("OpenAI.GlobalStandard.gpt-4o"), + DeprecationDate: new(time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)), + }, + }, + }, + }, + }, + } + + models := svc.convertToAiModelsAt(rawModels, now, nil) + require.Len(t, models, 1) + require.Equal(t, "gpt-4o", models[0].Name) + require.Empty(t, models[0].LifecycleStatus) + require.Len(t, models[0].Versions, 2) + + versionStatuses := map[string]string{} + for _, version := range models[0].Versions { + versionStatuses[version.Version] = version.LifecycleStatus + } + + require.Equal(t, map[string]string{ + "2024-08-06": "Deprecating", + "2024-11-20": "GenerallyAvailable", + }, versionStatuses) +} + +func TestConvertToAiModels_FiltersStatusesBeforeAggregation(t *testing.T) { + t.Parallel() + + svc := NewAiModelService(nil, nil) + now := time.Date(2026, 4, 6, 0, 0, 0, 0, time.UTC) + + rawModels := map[string][]*armcognitiveservices.Model{ + "eastus": { + { + Model: &armcognitiveservices.AccountModel{ + Name: new("gpt-4o"), + Version: new("2024-08-06"), + LifecycleStatus: new(armcognitiveservices.ModelLifecycleStatus("Deprecating")), + SKUs: []*armcognitiveservices.ModelSKU{ + { + Name: new("GlobalStandard"), + UsageName: new("OpenAI.GlobalStandard.gpt-4o"), + DeprecationDate: new(time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)), + }, + }, + }, + }, + }, + "westus": { + { + Model: &armcognitiveservices.AccountModel{ + Name: new("gpt-4o"), + Version: new("2024-11-20"), + IsDefaultVersion: new(true), + LifecycleStatus: new(armcognitiveservices.ModelLifecycleStatus("GenerallyAvailable")), + SKUs: []*armcognitiveservices.ModelSKU{ + { + Name: new("GlobalStandard"), + UsageName: new("OpenAI.GlobalStandard.gpt-4o"), + DeprecationDate: new(time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)), + }, + }, + }, + }, + }, + } + + models := svc.convertToAiModelsAt(rawModels, now, []string{"GenerallyAvailable"}) + require.Len(t, models, 1) + require.Equal(t, "gpt-4o", models[0].Name) + require.Empty(t, models[0].LifecycleStatus) + require.Equal(t, []string{"westus"}, models[0].Locations) + require.Len(t, models[0].Versions, 1) + require.Equal(t, "2024-11-20", models[0].Versions[0].Version) + require.Equal(t, "GenerallyAvailable", models[0].Versions[0].LifecycleStatus) +} + +func TestConvertToAiModels_ExcludesLocationsWithOnlyDeprecatedEntries(t *testing.T) { + t.Parallel() + + svc := NewAiModelService(nil, nil) + now := time.Date(2026, 4, 6, 0, 0, 0, 0, time.UTC) + + rawModels := map[string][]*armcognitiveservices.Model{ + "eastus": { + { + Model: &armcognitiveservices.AccountModel{ + Name: new("gpt-4o"), + Version: new("2024-08-06"), + LifecycleStatus: new(armcognitiveservices.ModelLifecycleStatus("GenerallyAvailable")), + SKUs: []*armcognitiveservices.ModelSKU{ + { + Name: new("GlobalStandard"), + UsageName: new("OpenAI.GlobalStandard.gpt-4o"), + DeprecationDate: new(time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)), + }, + }, + }, + }, + }, + "westus": { + { + Model: &armcognitiveservices.AccountModel{ + Name: new("gpt-4o"), + Version: new("2024-08-06"), + LifecycleStatus: new(armcognitiveservices.ModelLifecycleStatus("GenerallyAvailable")), + SKUs: []*armcognitiveservices.ModelSKU{ + { + Name: new("GlobalStandard"), + UsageName: new("OpenAI.GlobalStandard.gpt-4o"), + DeprecationDate: new(time.Date(2026, 3, 31, 0, 0, 0, 0, time.UTC)), + }, + }, + }, + }, + }, + } + + models := svc.convertToAiModelsAt(rawModels, now, nil) + require.Len(t, models, 1) + require.Equal(t, "gpt-4o", models[0].Name) + require.Empty(t, models[0].LifecycleStatus) + require.Equal(t, []string{"eastus"}, models[0].Locations) + require.Len(t, models[0].Versions, 1) + require.Equal(t, "GenerallyAvailable", models[0].Versions[0].LifecycleStatus) + require.Len(t, models[0].Versions[0].Skus, 1) +} + func TestFilterModelsByQuota(t *testing.T) { models := []AiModel{ { diff --git a/cli/azd/pkg/ai/types.go b/cli/azd/pkg/ai/types.go index ae17918134d..0d575a1b8c7 100644 --- a/cli/azd/pkg/ai/types.go +++ b/cli/azd/pkg/ai/types.go @@ -22,7 +22,7 @@ type AiModel struct { Name string // Format is the model format, e.g. "OpenAI". Format string - // LifecycleStatus is the model lifecycle status, e.g. "preview", "stable". + // Deprecated: Use AiModelVersion.LifecycleStatus instead. Always empty (""). LifecycleStatus string // Capabilities lists the model's capabilities, e.g. ["chat", "embeddings"]. Capabilities []string @@ -38,6 +38,8 @@ type AiModelVersion struct { Version string // IsDefault indicates whether this is the default version. IsDefault bool + // LifecycleStatus is the lifecycle status for this specific version. + LifecycleStatus string // Skus lists the available SKUs for this version. Skus []AiModelSku } @@ -132,7 +134,8 @@ type FilterOptions struct { Capabilities []string // Formats filters by model format, e.g. ["OpenAI"]. Formats []string - // Statuses filters by lifecycle status, e.g. ["preview", "stable"]. + // Statuses filters by version lifecycle status. Models are included only if + // at least one version matches. Model-level status is recomputed from survivors. Statuses []string // ExcludeModelNames excludes models by name (for multi-model selection flows). ExcludeModelNames []string diff --git a/cli/azd/pkg/azdext/ai_model.pb.go b/cli/azd/pkg/azdext/ai_model.pb.go index be7acbfa869..ec5098c1556 100644 --- a/cli/azd/pkg/azdext/ai_model.pb.go +++ b/cli/azd/pkg/azdext/ai_model.pb.go @@ -25,13 +25,14 @@ const ( ) type AiModel struct { - state protoimpl.MessageState `protogen:"open.v1"` - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // e.g. "gpt-4o" - Format string `protobuf:"bytes,2,opt,name=format,proto3" json:"format,omitempty"` // e.g. "OpenAI" - LifecycleStatus string `protobuf:"bytes,3,opt,name=lifecycle_status,json=lifecycleStatus,proto3" json:"lifecycle_status,omitempty"` // e.g. "preview", "stable" - Capabilities []string `protobuf:"bytes,4,rep,name=capabilities,proto3" json:"capabilities,omitempty"` // e.g. ["chat", "embeddings"] - Versions []*AiModelVersion `protobuf:"bytes,5,rep,name=versions,proto3" json:"versions,omitempty"` - Locations []string `protobuf:"bytes,6,rep,name=locations,proto3" json:"locations,omitempty"` // canonical locations where available + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // e.g. "gpt-4o" + Format string `protobuf:"bytes,2,opt,name=format,proto3" json:"format,omitempty"` // e.g. "OpenAI" + // Deprecated: Marked as deprecated in ai_model.proto. + LifecycleStatus string `protobuf:"bytes,3,opt,name=lifecycle_status,json=lifecycleStatus,proto3" json:"lifecycle_status,omitempty"` // derived from the default surviving version; prefer AiModelVersion.lifecycle_status + Capabilities []string `protobuf:"bytes,4,rep,name=capabilities,proto3" json:"capabilities,omitempty"` // e.g. ["chat", "embeddings"] + Versions []*AiModelVersion `protobuf:"bytes,5,rep,name=versions,proto3" json:"versions,omitempty"` + Locations []string `protobuf:"bytes,6,rep,name=locations,proto3" json:"locations,omitempty"` // canonical locations where available unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -80,6 +81,7 @@ func (x *AiModel) GetFormat() string { return "" } +// Deprecated: Marked as deprecated in ai_model.proto. func (x *AiModel) GetLifecycleStatus() string { if x != nil { return x.LifecycleStatus @@ -109,12 +111,13 @@ func (x *AiModel) GetLocations() []string { } type AiModelVersion struct { - state protoimpl.MessageState `protogen:"open.v1"` - Version string `protobuf:"bytes,1,opt,name=version,proto3" json:"version,omitempty"` - IsDefault bool `protobuf:"varint,2,opt,name=is_default,json=isDefault,proto3" json:"is_default,omitempty"` - Skus []*AiModelSku `protobuf:"bytes,3,rep,name=skus,proto3" json:"skus,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Version string `protobuf:"bytes,1,opt,name=version,proto3" json:"version,omitempty"` + IsDefault bool `protobuf:"varint,2,opt,name=is_default,json=isDefault,proto3" json:"is_default,omitempty"` + Skus []*AiModelSku `protobuf:"bytes,3,rep,name=skus,proto3" json:"skus,omitempty"` + LifecycleStatus string `protobuf:"bytes,4,opt,name=lifecycle_status,json=lifecycleStatus,proto3" json:"lifecycle_status,omitempty"` // e.g. "GenerallyAvailable", "Preview" + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *AiModelVersion) Reset() { @@ -168,6 +171,13 @@ func (x *AiModelVersion) GetSkus() []*AiModelSku { return nil } +func (x *AiModelVersion) GetLifecycleStatus() string { + if x != nil { + return x.LifecycleStatus + } + return "" +} + // AiModelSku represents a deployment SKU with capacity constraints. type AiModelSku struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -518,8 +528,9 @@ type AiModelFilterOptions struct { // Include models whose format matches one of these values. // Matches AiModel.format exactly (for example: "OpenAI", "Microsoft"). Formats []string `protobuf:"bytes,3,rep,name=formats,proto3" json:"formats,omitempty"` - // Include models whose lifecycle status matches one of these values. - // Matches AiModel.lifecycle_status exactly (for example: "Stable", "Preview"). + // Include model versions whose lifecycle status matches one of these values. + // Filtering is applied before aggregation, so returned versions, derived + // AiModel.lifecycle_status, and locations reflect only matching versions. Statuses []string `protobuf:"bytes,4,rep,name=statuses,proto3" json:"statuses,omitempty"` // Exclude models by exact model name (for example: "gpt-4o-mini"). ExcludeModelNames []string `protobuf:"bytes,5,rep,name=exclude_model_names,json=excludeModelNames,proto3" json:"exclude_model_names,omitempty"` @@ -1272,19 +1283,20 @@ var File_ai_model_proto protoreflect.FileDescriptor const file_ai_model_proto_rawDesc = "" + "\n" + - "\x0eai_model.proto\x12\x06azdext\x1a\fmodels.proto\"\xd6\x01\n" + + "\x0eai_model.proto\x12\x06azdext\x1a\fmodels.proto\"\xda\x01\n" + "\aAiModel\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x16\n" + - "\x06format\x18\x02 \x01(\tR\x06format\x12)\n" + - "\x10lifecycle_status\x18\x03 \x01(\tR\x0flifecycleStatus\x12\"\n" + + "\x06format\x18\x02 \x01(\tR\x06format\x12-\n" + + "\x10lifecycle_status\x18\x03 \x01(\tB\x02\x18\x01R\x0flifecycleStatus\x12\"\n" + "\fcapabilities\x18\x04 \x03(\tR\fcapabilities\x122\n" + "\bversions\x18\x05 \x03(\v2\x16.azdext.AiModelVersionR\bversions\x12\x1c\n" + - "\tlocations\x18\x06 \x03(\tR\tlocations\"q\n" + + "\tlocations\x18\x06 \x03(\tR\tlocations\"\x9c\x01\n" + "\x0eAiModelVersion\x12\x18\n" + "\aversion\x18\x01 \x01(\tR\aversion\x12\x1d\n" + "\n" + "is_default\x18\x02 \x01(\bR\tisDefault\x12&\n" + - "\x04skus\x18\x03 \x03(\v2\x12.azdext.AiModelSkuR\x04skus\"\xd5\x01\n" + + "\x04skus\x18\x03 \x03(\v2\x12.azdext.AiModelSkuR\x04skus\x12)\n" + + "\x10lifecycle_status\x18\x04 \x01(\tR\x0flifecycleStatus\"\xd5\x01\n" + "\n" + "AiModelSku\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x1d\n" + diff --git a/cli/azd/pkg/azdext/models.pb.go b/cli/azd/pkg/azdext/models.pb.go index 33b0c307f7c..92e1a338420 100644 --- a/cli/azd/pkg/azdext/models.pb.go +++ b/cli/azd/pkg/azdext/models.pb.go @@ -3,8 +3,8 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.11 -// protoc v7.34.1 +// protoc-gen-go v1.36.10 +// protoc v6.32.1 // source: models.proto package azdext From 24ef174a66bbcbdbe3872b4b007652d7dd65ae12 Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Mon, 6 Apr 2026 18:47:36 +0000 Subject: [PATCH 2/3] Fix lint warning --- cli/azd/pkg/ai/mapper_registry.go | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/cli/azd/pkg/ai/mapper_registry.go b/cli/azd/pkg/ai/mapper_registry.go index b9ebba94fd8..34f31c9b1f1 100644 --- a/cli/azd/pkg/ai/mapper_registry.go +++ b/cli/azd/pkg/ai/mapper_registry.go @@ -27,13 +27,13 @@ func registerAiModelMappings() { versions[i] = proto } + // LifecycleStatus intentionally omitted — deprecated, always empty. return &azdext.AiModel{ - Name: src.Name, - Format: src.Format, - LifecycleStatus: src.LifecycleStatus, - Capabilities: src.Capabilities, - Versions: versions, - Locations: src.Locations, + Name: src.Name, + Format: src.Format, + Capabilities: src.Capabilities, + Versions: versions, + Locations: src.Locations, }, nil }) @@ -44,13 +44,13 @@ func registerAiModelMappings() { versions[i] = protoToAiModelVersion(v) } + // LifecycleStatus intentionally omitted — deprecated, always empty. return &AiModel{ - Name: src.Name, - Format: src.Format, - LifecycleStatus: src.LifecycleStatus, - Capabilities: src.Capabilities, - Versions: versions, - Locations: src.Locations, + Name: src.Name, + Format: src.Format, + Capabilities: src.Capabilities, + Versions: versions, + Locations: src.Locations, }, nil }) From 2128a7a1340d91b488d55f9d3c817ad94156384c Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Mon, 6 Apr 2026 18:50:42 +0000 Subject: [PATCH 3/3] Address comments --- .../docs/extensions/extension-framework.md | 4 +-- cli/azd/grpc/proto/ai_model.proto | 2 +- cli/azd/pkg/ai/model_service_test.go | 34 ++++++++----------- cli/azd/pkg/ai/types.go | 2 +- cli/azd/pkg/azdext/ai_model.pb.go | 2 +- 5 files changed, 20 insertions(+), 24 deletions(-) diff --git a/cli/azd/docs/extensions/extension-framework.md b/cli/azd/docs/extensions/extension-framework.md index 24da8cc7928..35c2d51f4d9 100644 --- a/cli/azd/docs/extensions/extension-framework.md +++ b/cli/azd/docs/extensions/extension-framework.md @@ -1985,8 +1985,8 @@ Returns available AI models for a subscription. - `models` (repeated _AiModel_) `filter.statuses` matches version-level lifecycle status before aggregation. Returned models -only contain versions (and locations) that matched. `AiModel.lifecycle_status` is derived from -the default surviving version. +only contain versions (and locations) that matched. `AiModel.lifecycle_status` is deprecated +and always empty; use `AiModelVersion.lifecycle_status` for lifecycle state. If `filter.locations` is empty, models are listed across all subscription locations. When `filter.locations` is provided, it limits which models are returned, but each returned model still contains canonical diff --git a/cli/azd/grpc/proto/ai_model.proto b/cli/azd/grpc/proto/ai_model.proto index 9d39996314a..263e88a1799 100644 --- a/cli/azd/grpc/proto/ai_model.proto +++ b/cli/azd/grpc/proto/ai_model.proto @@ -40,7 +40,7 @@ service AiModelService { message AiModel { string name = 1; // e.g. "gpt-4o" string format = 2; // e.g. "OpenAI" - string lifecycle_status = 3 [deprecated = true]; // derived from the default surviving version; prefer AiModelVersion.lifecycle_status + string lifecycle_status = 3 [deprecated = true]; // deprecated; always empty; use AiModelVersion.lifecycle_status repeated string capabilities = 4; // e.g. ["chat", "embeddings"] repeated AiModelVersion versions = 5; repeated string locations = 6; // canonical locations where available diff --git a/cli/azd/pkg/ai/model_service_test.go b/cli/azd/pkg/ai/model_service_test.go index 0fe285af99f..06f64fa9653 100644 --- a/cli/azd/pkg/ai/model_service_test.go +++ b/cli/azd/pkg/ai/model_service_test.go @@ -14,32 +14,29 @@ import ( func TestFilterModels(t *testing.T) { models := []AiModel{ { - Name: "gpt-4o", - Format: "OpenAI", - LifecycleStatus: "stable", - Capabilities: []string{"chat", "completion"}, - Locations: []string{"eastus", "westus"}, + Name: "gpt-4o", + Format: "OpenAI", + Capabilities: []string{"chat", "completion"}, + Locations: []string{"eastus", "westus"}, Versions: []AiModelVersion{ {Version: "2024-05-13", LifecycleStatus: "stable"}, {Version: "2024-11-20", IsDefault: true, LifecycleStatus: "stable"}, }, }, { - Name: "gpt-4o-mini", - Format: "OpenAI", - LifecycleStatus: "preview", - Capabilities: []string{"chat"}, - Locations: []string{"eastus"}, + Name: "gpt-4o-mini", + Format: "OpenAI", + Capabilities: []string{"chat"}, + Locations: []string{"eastus"}, Versions: []AiModelVersion{ {Version: "2024-07-18", IsDefault: true, LifecycleStatus: "preview"}, }, }, { - Name: "text-embedding-ada-002", - Format: "OpenAI", - LifecycleStatus: "stable", - Capabilities: []string{"embeddings"}, - Locations: []string{"westus"}, + Name: "text-embedding-ada-002", + Format: "OpenAI", + Capabilities: []string{"embeddings"}, + Locations: []string{"westus"}, Versions: []AiModelVersion{ {Version: "2", IsDefault: true, LifecycleStatus: "stable"}, }, @@ -120,10 +117,9 @@ func TestFilterModels_FiltersVersionsByStatus(t *testing.T) { models := []AiModel{ { - Name: "gpt-4o", - Format: "OpenAI", - LifecycleStatus: "GenerallyAvailable", - Locations: []string{"eastus", "westus"}, + Name: "gpt-4o", + Format: "OpenAI", + Locations: []string{"eastus", "westus"}, Versions: []AiModelVersion{ {Version: "2024-08-06", LifecycleStatus: "Deprecating"}, {Version: "2024-11-20", IsDefault: true, LifecycleStatus: "GenerallyAvailable"}, diff --git a/cli/azd/pkg/ai/types.go b/cli/azd/pkg/ai/types.go index 0d575a1b8c7..dbfb32301e0 100644 --- a/cli/azd/pkg/ai/types.go +++ b/cli/azd/pkg/ai/types.go @@ -135,7 +135,7 @@ type FilterOptions struct { // Formats filters by model format, e.g. ["OpenAI"]. Formats []string // Statuses filters by version lifecycle status. Models are included only if - // at least one version matches. Model-level status is recomputed from survivors. + // at least one version matches. Statuses []string // ExcludeModelNames excludes models by name (for multi-model selection flows). ExcludeModelNames []string diff --git a/cli/azd/pkg/azdext/ai_model.pb.go b/cli/azd/pkg/azdext/ai_model.pb.go index ec5098c1556..a70f427d806 100644 --- a/cli/azd/pkg/azdext/ai_model.pb.go +++ b/cli/azd/pkg/azdext/ai_model.pb.go @@ -29,7 +29,7 @@ type AiModel struct { Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // e.g. "gpt-4o" Format string `protobuf:"bytes,2,opt,name=format,proto3" json:"format,omitempty"` // e.g. "OpenAI" // Deprecated: Marked as deprecated in ai_model.proto. - LifecycleStatus string `protobuf:"bytes,3,opt,name=lifecycle_status,json=lifecycleStatus,proto3" json:"lifecycle_status,omitempty"` // derived from the default surviving version; prefer AiModelVersion.lifecycle_status + LifecycleStatus string `protobuf:"bytes,3,opt,name=lifecycle_status,json=lifecycleStatus,proto3" json:"lifecycle_status,omitempty"` // deprecated; always empty; use AiModelVersion.lifecycle_status Capabilities []string `protobuf:"bytes,4,rep,name=capabilities,proto3" json:"capabilities,omitempty"` // e.g. ["chat", "embeddings"] Versions []*AiModelVersion `protobuf:"bytes,5,rep,name=versions,proto3" json:"versions,omitempty"` Locations []string `protobuf:"bytes,6,rep,name=locations,proto3" json:"locations,omitempty"` // canonical locations where available