From 11f7878db9bdfeb9b1b5a9e1ffe68bcf3508652c Mon Sep 17 00:00:00 2001 From: trangevi Date: Wed, 1 Jul 2026 15:38:25 -0700 Subject: [PATCH 1/9] fix: run interactive Azure context setup in unified azure.yaml adoption path When 'azd ai agent init -m ' points at a unified Foundry azure.yaml, the adoption path now runs subscription selection and Foundry project configuration (existing vs new) after scaffolding, matching the agent-manifest flow. Extracts the shared subscription + Foundry project selection logic from configureModelChoice's !hasModelResources branch into a standalone configureFoundryProject helper. Both the adoption path and the agent-manifest path now call this helper, eliminating duplication. Fixes #8922 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azure.ai.agents/internal/cmd/init.go | 158 +------------- .../internal/cmd/init_adopt.go | 30 +++ .../cmd/init_foundry_project_setup.go | 200 ++++++++++++++++++ 3 files changed, 239 insertions(+), 149 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_project_setup.go diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go index 38254b668ac..4cfbfa9d06c 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -2026,157 +2026,17 @@ func (a *InitAction) configureModelChoice( // When --project-id is provided, use the existing project to derive location // and configure Foundry env vars (ACR, AppInsights, etc.) instead of prompting. if !hasModelResources { - if a.flags.projectResourceId != "" { - newCred, err := ensureSubscription( - ctx, a.azdClient, a.azureContext, a.environment.Name, - "Select an Azure subscription to provision your agent and Foundry project resources.", - ) - if err != nil { - return nil, err - } - a.credential = newCred - - selectedProject, err := selectFoundryProject( - ctx, a.azdClient, a.credential, a.azureContext, a.environment.Name, - a.azureContext.Scope.SubscriptionId, a.flags.projectResourceId, - a.skipACR(), - true, // bicepless - ) - if err != nil { - return nil, err - } - a.selectedFoundryProject = selectedProject - - if selectedProject == nil { - return nil, fmt.Errorf("specified foundry project was not found or is not eligible for the current configuration: %s", a.flags.projectResourceId) - } - - // Signal Bicep to skip project/role/connection provisioning for this existing project - if err := setEnvValue( - ctx, a.azdClient, a.environment.Name, "USE_EXISTING_AI_PROJECT", "true", - ); err != nil { - return nil, fmt.Errorf("failed to set USE_EXISTING_AI_PROJECT: %w", err) - } - if err := updatePendingProjectSignal( - ctx, a.azdClient, a.environment.Name, true, - ); err != nil { - log.Printf("warning: failed to update project provision signal: %v", err) - } - } else if a.flags.noPrompt { - newCred, err := configureNewProjectForNoPrompt( - ctx, a.azdClient, a.environment.Name, a.azureContext, - "Select an Azure subscription to provision your agent and Foundry project resources.", - ) - if err != nil { - return nil, err - } - a.credential = newCred - } else { - // Prompt user to pick an existing Foundry project or create new resources - projectChoices := []*azdext.SelectChoice{ - {Label: "Use an existing Foundry project", Value: "existing"}, - {Label: "Create a new Foundry project", Value: "new"}, - } - - defaultIdx := int32(0) - projectResp, err := a.azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ - Options: &azdext.SelectOptions{ - Message: "Select a Foundry project to host your agent and any models or tools it uses.", - Choices: projectChoices, - SelectedIndex: &defaultIdx, - }, - }) - if err != nil { - if exterrors.IsCancellation(err) { - return nil, exterrors.Cancelled("project selection was cancelled") - } - return nil, exterrors.FromPrompt(err, "failed to prompt for Foundry project configuration choice") - } - - switch projectChoices[*projectResp.Value].Value { - case "existing": - newCred, err := ensureSubscription( - ctx, a.azdClient, a.azureContext, a.environment.Name, - "Select an Azure subscription to find existing Foundry projects.", - ) - if err != nil { - return nil, err - } - a.credential = newCred - - selectedProject, err := selectFoundryProject( - ctx, a.azdClient, a.credential, a.azureContext, a.environment.Name, - a.azureContext.Scope.SubscriptionId, "", - a.skipACR(), - true, // bicepless - ) - if err != nil { - return nil, err - } - a.selectedFoundryProject = selectedProject - - if selectedProject == nil { - // No existing project selected → fall back to "create new" path - _, _ = color.New(color.Faint).Println( - "No existing Foundry project was selected. Falling back to creating new resources.", - ) - if err := setEnvValue( - ctx, a.azdClient, a.environment.Name, "USE_EXISTING_AI_PROJECT", "false", - ); err != nil { - return nil, fmt.Errorf("failed to set USE_EXISTING_AI_PROJECT: %w", err) - } - if err := updatePendingProjectSignal( - ctx, a.azdClient, a.environment.Name, false, - ); err != nil { - log.Printf("warning: failed to update project provision signal: %v", err) - } - if err := ensureLocation(ctx, a.azdClient, a.azureContext, a.environment.Name); err != nil { - return nil, err - } - } else { - // Signal Bicep to skip project/role/connection provisioning for this existing project - if err := setEnvValue( - ctx, a.azdClient, a.environment.Name, "USE_EXISTING_AI_PROJECT", "true", - ); err != nil { - return nil, fmt.Errorf("failed to set USE_EXISTING_AI_PROJECT: %w", err) - } - if err := updatePendingProjectSignal( - ctx, a.azdClient, a.environment.Name, true, - ); err != nil { - log.Printf("warning: failed to update project provision signal: %v", err) - } - } - default: - newCred, err := ensureSubscriptionAndLocation( - ctx, a.azdClient, a.azureContext, a.environment.Name, - "Select an Azure subscription to provision your agent and Foundry project resources.", - ) - if err != nil { - return nil, err - } - a.credential = newCred - - // Creating new resources — clear any stale existing-project flag - if err := setEnvValue( - ctx, a.azdClient, a.environment.Name, "USE_EXISTING_AI_PROJECT", "false", - ); err != nil { - return nil, fmt.Errorf("failed to set USE_EXISTING_AI_PROJECT: %w", err) - } - if err := updatePendingProjectSignal( - ctx, a.azdClient, a.environment.Name, false, - ); err != nil { - log.Printf("warning: failed to update project provision signal: %v", err) - } - } - } - - // Persist the ACR-skip signal for the no-model-resources path too. - // The deferred-headless and main model-config paths set this, but a - // completing no-model flow (e.g. a pre-built --image agent) otherwise - // would not, leaving Bicep to provision an ACR the user doesn't need. - if err := setACREnvVar(ctx, a.azdClient, a.environment.Name, a.skipACR()); err != nil { + result, err := configureFoundryProject( + ctx, a.azdClient, a.azureContext, a.environment.Name, + a.flags.projectResourceId, a.flags.noPrompt, a.skipACR(), + ) + if err != nil { return nil, err } + if result.Credential != nil { + a.credential = result.Credential + } + a.selectedFoundryProject = result.FoundryProject return agentManifest, nil } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go index b3805be515d..c9fc5929095 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go @@ -206,6 +206,36 @@ func runInitFromAzureYaml( return err } + // --- Interactive Azure context setup (subscription, Foundry project) --- + // The scaffolding created an environment; load it and run the same Foundry + // project selection flow as the agent-manifest path so the user ends up + // with a provision-ready environment. + env := getExistingEnvironment(ctx, envName, azdClient) + if env == nil { + // Environment should exist after scaffoldProject; if not, create one. + env, err = createNewEnvironment(ctx, azdClient, envName) + if err != nil { + return err + } + } + + azureContext, err := loadAzureContext(ctx, azdClient, env.Name) + if err != nil { + return err + } + + _, err = configureFoundryProject( + ctx, azdClient, azureContext, env.Name, + flags.projectResourceId, flags.noPrompt, + true, // skipACR — unified azure.yaml does not use container builds + ) + if err != nil { + if exterrors.IsCancellation(err) { + return exterrors.Cancelled("initialization was cancelled") + } + return err + } + fmt.Printf( "\nAdopted the sample's azure.yaml as the project manifest at %s.\n", output.WithHighLightFormat("azure.yaml"), diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_project_setup.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_project_setup.go new file mode 100644 index 00000000000..f236cf14f6d --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_project_setup.go @@ -0,0 +1,200 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + "log" + + "azureaiagent/internal/exterrors" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/fatih/color" +) + +// foundryProjectSetupResult holds the results of configureFoundryProject. +type foundryProjectSetupResult struct { + Credential azcore.TokenCredential + FoundryProject *FoundryProjectInfo +} + +// configureFoundryProject runs the interactive (or headless) subscription and +// Foundry project selection flow. It handles three modes: +// +// 1. --project-id provided: validate + select the specified project. +// 2. --no-prompt with missing Azure context: defer setup (print what's needed). +// 3. --no-prompt with Azure context present: configure new project headlessly. +// 4. Interactive: prompt "Use an existing Foundry project" vs "Create new". +// +// This is the shared core extracted from configureModelChoice's +// !hasModelResources branch so both the agent-manifest and unified azure.yaml +// adoption paths can reuse it. +func configureFoundryProject( + ctx context.Context, + azdClient *azdext.AzdClient, + azureContext *azdext.AzureContext, + envName string, + projectResourceId string, + noPrompt bool, + skipACR bool, +) (*foundryProjectSetupResult, error) { + result := &foundryProjectSetupResult{} + + // When --project-id is provided, validate the ARM format and extract the + // subscription ID so ensureSubscription can skip the prompt. + if projectResourceId != "" { + projectDetails, err := extractProjectDetails(projectResourceId) + if err != nil { + return nil, exterrors.Validation( + exterrors.CodeInvalidProjectResourceId, + fmt.Sprintf("invalid --project-id value: %s", err), + "Provide a valid Foundry project resource ID in the format:\n"+ + "/subscriptions//resourceGroups//providers/"+ + "Microsoft.CognitiveServices/accounts//projects/", + ) + } + azureContext.Scope.SubscriptionId = projectDetails.SubscriptionId + + newCred, err := ensureSubscription( + ctx, azdClient, azureContext, envName, + "Select an Azure subscription to provision your agent and Foundry project resources.", + ) + if err != nil { + return nil, err + } + result.Credential = newCred + + selectedProject, err := selectFoundryProject( + ctx, azdClient, newCred, azureContext, envName, + azureContext.Scope.SubscriptionId, projectResourceId, + skipACR, + true, // bicepless + ) + if err != nil { + return nil, err + } + result.FoundryProject = selectedProject + + if selectedProject == nil { + return nil, fmt.Errorf( + "specified foundry project was not found or is not eligible for the current configuration: %s", + projectResourceId, + ) + } + + if err := setEnvValue(ctx, azdClient, envName, "USE_EXISTING_AI_PROJECT", "true"); err != nil { + return nil, fmt.Errorf("failed to set USE_EXISTING_AI_PROJECT: %w", err) + } + if err := updatePendingProjectSignal(ctx, azdClient, envName, true); err != nil { + log.Printf("warning: failed to update project provision signal: %v", err) + } + } else if shouldDeferInitAzureContext(noPrompt, azureContext) { + // Headless init with missing Azure values: defer without blocking. + if err := configureDeferredInitAzureContext( + ctx, azdClient, envName, azureContext, false, + ); err != nil { + return nil, err + } + } else if noPrompt { + newCred, err := configureNewProjectForNoPrompt( + ctx, azdClient, envName, azureContext, + "Select an Azure subscription to provision your agent and Foundry project resources.", + ) + if err != nil { + return nil, err + } + result.Credential = newCred + } else { + // Interactive: prompt user to pick an existing Foundry project or create new resources + projectChoices := []*azdext.SelectChoice{ + {Label: "Use an existing Foundry project", Value: "existing"}, + {Label: "Create a new Foundry project", Value: "new"}, + } + + defaultIdx := int32(0) + projectResp, err := azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: "Select a Foundry project to host your agent and any models or tools it uses.", + Choices: projectChoices, + SelectedIndex: &defaultIdx, + }, + }) + if err != nil { + if exterrors.IsCancellation(err) { + return nil, exterrors.Cancelled("project selection was cancelled") + } + return nil, exterrors.FromPrompt(err, "failed to prompt for Foundry project configuration choice") + } + + switch projectChoices[*projectResp.Value].Value { + case "existing": + newCred, err := ensureSubscription( + ctx, azdClient, azureContext, envName, + "Select an Azure subscription to find existing Foundry projects.", + ) + if err != nil { + return nil, err + } + result.Credential = newCred + + selectedProject, err := selectFoundryProject( + ctx, azdClient, newCred, azureContext, envName, + azureContext.Scope.SubscriptionId, "", + skipACR, + true, // bicepless + ) + if err != nil { + return nil, err + } + result.FoundryProject = selectedProject + + if selectedProject == nil { + _, _ = color.New(color.Faint).Println( + "No existing Foundry project was selected. Falling back to creating new resources.", + ) + if err := setEnvValue(ctx, azdClient, envName, "USE_EXISTING_AI_PROJECT", "false"); err != nil { + return nil, fmt.Errorf("failed to set USE_EXISTING_AI_PROJECT: %w", err) + } + if err := updatePendingProjectSignal(ctx, azdClient, envName, false); err != nil { + log.Printf("warning: failed to update project provision signal: %v", err) + } + if err := ensureLocation(ctx, azdClient, azureContext, envName); err != nil { + return nil, err + } + } else { + if err := setEnvValue(ctx, azdClient, envName, "USE_EXISTING_AI_PROJECT", "true"); err != nil { + return nil, fmt.Errorf("failed to set USE_EXISTING_AI_PROJECT: %w", err) + } + if err := updatePendingProjectSignal(ctx, azdClient, envName, true); err != nil { + log.Printf("warning: failed to update project provision signal: %v", err) + } + } + default: + newCred, err := ensureSubscriptionAndLocation( + ctx, azdClient, azureContext, envName, + "Select an Azure subscription to provision your agent and Foundry project resources.", + ) + if err != nil { + return nil, err + } + result.Credential = newCred + + if err := setEnvValue(ctx, azdClient, envName, "USE_EXISTING_AI_PROJECT", "false"); err != nil { + return nil, fmt.Errorf("failed to set USE_EXISTING_AI_PROJECT: %w", err) + } + if err := updatePendingProjectSignal(ctx, azdClient, envName, false); err != nil { + log.Printf("warning: failed to update project provision signal: %v", err) + } + } + } + + // Persist the ACR-skip signal so Bicep knows whether to create a container registry. + if err := setACREnvVar(ctx, azdClient, envName, skipACR); err != nil { + return nil, err + } + + return result, nil +} From 4f964962f745366538d07d77a5463b6ca4d3505e Mon Sep 17 00:00:00 2001 From: trangevi Date: Wed, 1 Jul 2026 16:08:07 -0700 Subject: [PATCH 2/9] feat: verify model deployments in unified azure.yaml against Foundry project After selecting a Foundry project, the adoption path now checks each model deployment declared in the azure.yaml against existing deployments in the project. For each deployment, the user can: - Use an existing matching deployment (removes it from azure.yaml) - Deploy as specified (keeps it for provisioning) - Choose a different model (full catalog + deployment prompt) - Skip (removes from azure.yaml) The first selected deployment's name is persisted as AZURE_AI_MODEL_DEPLOYMENT_NAME (same env var as the manifest path). In --no-prompt mode, existing matches are auto-used and unmatched models are auto-deployed. Closes #8922 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../internal/cmd/init_adopt.go | 608 +++++++++++++++++- 1 file changed, 607 insertions(+), 1 deletion(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go index c9fc5929095..ff78f75aac2 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go @@ -14,17 +14,22 @@ import ( "net/url" "os" "path/filepath" + "slices" "strings" "azureaiagent/internal/cmd/nextstep" "azureaiagent/internal/exterrors" + "azureaiagent/internal/project" + "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/azure/azure-dev/cli/azd/pkg/exec" "github.com/azure/azure-dev/cli/azd/pkg/input" "github.com/azure/azure-dev/cli/azd/pkg/osutil" "github.com/azure/azure-dev/cli/azd/pkg/output" "github.com/azure/azure-dev/cli/azd/pkg/tools/github" + "github.com/fatih/color" + "google.golang.org/protobuf/types/known/structpb" "gopkg.in/yaml.v3" ) @@ -89,6 +94,537 @@ func foundryProjectName(content []byte) string { return "" } +// foundryDeploymentEntry holds a parsed deployment along with the service key +// it was declared in, so the azure.yaml can be updated after verification. +type foundryDeploymentEntry struct { + ServiceName string + Deployment project.Deployment +} + +// foundryDeployments parses the azure.yaml content and returns all model +// deployments declared under services with `host: azure.ai.project`. +func foundryDeployments(content []byte) []foundryDeploymentEntry { + var top map[string]any + if err := yaml.Unmarshal(content, &top); err != nil { + return nil + } + + services, ok := top["services"].(map[string]any) + if !ok { + return nil + } + + var entries []foundryDeploymentEntry + for svcName, svc := range services { + svcMap, ok := svc.(map[string]any) + if !ok { + continue + } + host, _ := svcMap["host"].(string) + if host != "azure.ai.project" { + continue + } + + rawDeployments, ok := svcMap["deployments"].([]any) + if !ok { + continue + } + + for _, raw := range rawDeployments { + d, ok := raw.(map[string]any) + if !ok { + continue + } + + dep := project.Deployment{ + Name: stringField(d, "name"), + } + + if model, ok := d["model"].(map[string]any); ok { + dep.Model = project.DeploymentModel{ + Format: stringField(model, "format"), + Name: stringField(model, "name"), + Version: stringField(model, "version"), + } + } + if sku, ok := d["sku"].(map[string]any); ok { + dep.Sku = project.DeploymentSku{ + Name: stringField(sku, "name"), + Capacity: intField(sku, "capacity"), + } + } + + entries = append(entries, foundryDeploymentEntry{ + ServiceName: svcName, + Deployment: dep, + }) + } + } + return entries +} + +// stringField safely extracts a string field from a map[string]any. +func stringField(m map[string]any, key string) string { + v, _ := m[key].(string) + return v +} + +// intField safely extracts an int field from a map[string]any, handling both +// int and float64 (YAML unmarshals numbers as int with yaml.v3). +func intField(m map[string]any, key string) int { + switch v := m[key].(type) { + case int: + return v + case float64: + return int(v) + default: + return 0 + } +} + +// verifyAzureYamlDeployments checks each model deployment declared in the +// unified azure.yaml against the selected Foundry project's existing +// deployments. It prompts the user for each deployment and returns the filtered +// list of deployments that should remain in the azure.yaml (i.e. those that +// need provisioning) and the full list of referenced deployments (for env var). +func verifyAzureYamlDeployments( + ctx context.Context, + azdClient *azdext.AzdClient, + credential azcore.TokenCredential, + azureContext *azdext.AzureContext, + envName string, + entries []foundryDeploymentEntry, + noPrompt bool, +) (deploymentsToKeep []project.Deployment, referencedDeployments []project.Deployment, err error) { + // Get the Foundry project ID from the environment. + resp, err := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ + EnvName: envName, + Key: "AZURE_AI_PROJECT_ID", + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to get AZURE_AI_PROJECT_ID: %w", err) + } + + foundryProjectId := resp.Value + if foundryProjectId == "" { + // No project selected (deferred setup) — keep all deployments as-is. + for _, e := range entries { + deploymentsToKeep = append(deploymentsToKeep, e.Deployment) + } + return deploymentsToKeep, nil, nil + } + + parts := strings.Split(foundryProjectId, "/") + if len(parts) < 9 { + return nil, nil, fmt.Errorf( + "invalid AZURE_AI_PROJECT_ID format: expected at least 9 path segments, got %d", len(parts)) + } + + subscription := parts[2] + resourceGroup := parts[4] + accountName := parts[8] + + allDeployments, listErr := listProjectDeployments(ctx, credential, subscription, resourceGroup, accountName) + if listErr != nil { + return nil, nil, fmt.Errorf("failed to list deployments in Foundry project: %w", listErr) + } + + for _, entry := range entries { + dep := entry.Deployment + + // Find matching deployments by model name. + matchingDeployments := make(map[string]*FoundryDeploymentInfo) + for i := range allDeployments { + d := &allDeployments[i] + if d.ModelName == dep.Model.Name { + matchingDeployments[d.Name] = d + } + } + + if len(matchingDeployments) > 0 { + // Sort for deterministic selection. + sortedNames := make([]string, 0, len(matchingDeployments)) + for name := range matchingDeployments { + sortedNames = append(sortedNames, name) + } + slices.Sort(sortedNames) + + if noPrompt { + // Auto-use the first matching deployment. + name := sortedNames[0] + existing := matchingDeployments[name] + log.Printf( + "--no-prompt: using existing deployment '%s' (version: %s) for model '%s'", + name, existing.Version, dep.Model.Name, + ) + referencedDeployments = append(referencedDeployments, project.Deployment{ + Name: name, + Model: project.DeploymentModel{ + Name: dep.Model.Name, + Format: existing.ModelFormat, + Version: existing.Version, + }, + Sku: project.DeploymentSku{ + Name: existing.SkuName, + Capacity: existing.SkuCapacity, + }, + }) + continue + } + + // Show deployment details and prompt. + fmt.Printf("\nModel deployment %s is defined in the azure.yaml:\n", output.WithHighLightFormat("'%s'", dep.Name)) + fmt.Printf(" Model: %s (%s), version %s\n", dep.Model.Name, dep.Model.Format, dep.Model.Version) + fmt.Printf(" SKU: %s, capacity %d\n", dep.Sku.Name, dep.Sku.Capacity) + fmt.Println() + + fmt.Println("Matching deployment(s) already exist in your Foundry project:") + for _, name := range sortedNames { + d := matchingDeployments[name] + fmt.Printf(" • %s — version %s, SKU: %s (capacity %d)\n", + name, d.Version, d.SkuName, d.SkuCapacity) + } + fmt.Println() + + // Build prompt choices: use each existing + deploy as specified + choose different + skip + choices := make([]*azdext.SelectChoice, 0, len(sortedNames)+3) + for _, name := range sortedNames { + d := matchingDeployments[name] + choices = append(choices, &azdext.SelectChoice{ + Value: "use:" + name, + Label: fmt.Sprintf("Use existing deployment '%s' (version: %s, SKU: %s)", + name, d.Version, d.SkuName), + }) + } + choices = append(choices, + &azdext.SelectChoice{ + Value: "deploy", + Label: "Deploy as specified in azure.yaml", + }, + &azdext.SelectChoice{ + Value: "change", + Label: "Choose a different model", + }, + &azdext.SelectChoice{ + Value: "skip", + Label: "Skip this model entirely (remove from azure.yaml)", + }, + ) + + defaultIdx := int32(0) + selectResp, err := azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: "How would you like to proceed?", + Choices: choices, + SelectedIndex: &defaultIdx, + }, + }) + if err != nil { + if exterrors.IsCancellation(err) { + return nil, nil, exterrors.Cancelled("model deployment verification was cancelled") + } + return nil, nil, fmt.Errorf("failed to prompt for deployment choice: %w", err) + } + + selected := choices[*selectResp.Value].Value + switch { + case strings.HasPrefix(selected, "use:"): + name := strings.TrimPrefix(selected, "use:") + existing := matchingDeployments[name] + referencedDeployments = append(referencedDeployments, project.Deployment{ + Name: name, + Model: project.DeploymentModel{ + Name: dep.Model.Name, + Format: existing.ModelFormat, + Version: existing.Version, + }, + Sku: project.DeploymentSku{ + Name: existing.SkuName, + Capacity: existing.SkuCapacity, + }, + }) + fmt.Printf("Using existing deployment '%s'.\n", name) + + case selected == "deploy": + deploymentsToKeep = append(deploymentsToKeep, dep) + referencedDeployments = append(referencedDeployments, dep) + + case selected == "change": + newDep, err := promptAlternativeDeployment(ctx, azdClient, azureContext, allDeployments) + if err != nil { + return nil, nil, err + } + if newDep != nil { + deploymentsToKeep = append(deploymentsToKeep, *newDep) + referencedDeployments = append(referencedDeployments, *newDep) + } + + case selected == "skip": + fmt.Println(output.WithWarningFormat( + "Skipped model '%s'. It will be removed from the azure.yaml.", dep.Model.Name)) + } + + } else { + // No matching deployment in the project. + if noPrompt { + // Auto-deploy as specified. + log.Printf("--no-prompt: no matching deployment for model '%s', will deploy as specified", + dep.Model.Name) + deploymentsToKeep = append(deploymentsToKeep, dep) + referencedDeployments = append(referencedDeployments, dep) + continue + } + + color.Yellow( + "\nNo existing deployment for model '%s' was found in your Foundry project.\n", + dep.Model.Name, + ) + fmt.Printf("Model deployment %s is defined in the azure.yaml:\n", output.WithHighLightFormat("'%s'", dep.Name)) + fmt.Printf(" Model: %s (%s), version %s\n", dep.Model.Name, dep.Model.Format, dep.Model.Version) + fmt.Printf(" SKU: %s, capacity %d\n\n", dep.Sku.Name, dep.Sku.Capacity) + + noMatchChoices := []*azdext.SelectChoice{ + {Value: "deploy", Label: "Deploy as specified in azure.yaml"}, + {Value: "change", Label: "Choose a different model"}, + {Value: "skip", Label: "Skip this model entirely (remove from azure.yaml)"}, + } + + defaultIdx := int32(0) + selectResp, err := azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: "How would you like to proceed?", + Choices: noMatchChoices, + SelectedIndex: &defaultIdx, + }, + }) + if err != nil { + if exterrors.IsCancellation(err) { + return nil, nil, exterrors.Cancelled("model deployment verification was cancelled") + } + return nil, nil, fmt.Errorf("failed to prompt for deployment choice: %w", err) + } + + switch noMatchChoices[*selectResp.Value].Value { + case "deploy": + deploymentsToKeep = append(deploymentsToKeep, dep) + referencedDeployments = append(referencedDeployments, dep) + + case "change": + newDep, err := promptAlternativeDeployment(ctx, azdClient, azureContext, allDeployments) + if err != nil { + return nil, nil, err + } + if newDep != nil { + deploymentsToKeep = append(deploymentsToKeep, *newDep) + referencedDeployments = append(referencedDeployments, *newDep) + } + + case "skip": + fmt.Println(output.WithWarningFormat( + "Skipped model '%s'. It will be removed from the azure.yaml.", dep.Model.Name)) + } + } + } + + return deploymentsToKeep, referencedDeployments, nil +} + +// promptAlternativeDeployment lets the user browse the model catalog or pick an +// existing deployment from the project. It returns the chosen deployment, or nil +// if no selection was made. +func promptAlternativeDeployment( + ctx context.Context, + azdClient *azdext.AzdClient, + azureContext *azdext.AzureContext, + allDeployments []FoundryDeploymentInfo, +) (*project.Deployment, error) { + // Offer "browse catalog" or "use existing deployment" if any exist. + altChoices := []*azdext.SelectChoice{ + {Value: "catalog", Label: "Browse the model catalog"}, + } + if len(allDeployments) > 0 { + altChoices = append(altChoices, &azdext.SelectChoice{ + Value: "existing", Label: "Use an existing deployment from this project", + }) + } + + defaultIdx := int32(0) + altResp, err := azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: "How would you like to choose a model?", + Choices: altChoices, + SelectedIndex: &defaultIdx, + }, + }) + if err != nil { + if exterrors.IsCancellation(err) { + return nil, exterrors.Cancelled("model selection was cancelled") + } + return nil, fmt.Errorf("failed to prompt for alternative model choice: %w", err) + } + + switch altChoices[*altResp.Value].Value { + case "catalog": + // Use the full model + deployment prompt which handles version, + // SKU, and capacity selection (same as manifest path). + promptReq := &azdext.PromptAiModelRequest{ + AzureContext: azureContext, + Filter: agentModelFilter([]string{azureContext.Scope.Location}, nil), + SelectOptions: &azdext.SelectOptions{ + Message: "Select a model", + }, + } + + modelResp, err := azdClient.Prompt().PromptAiModel(ctx, promptReq) + if err != nil { + if exterrors.IsCancellation(err) { + return nil, exterrors.Cancelled("model selection was cancelled") + } + return nil, fmt.Errorf("failed to prompt for model selection: %w", err) + } + + model := modelResp.Model + + var defaultCap int32 = 50 + deploymentResp, err := azdClient.Prompt().PromptAiDeployment(ctx, &azdext.PromptAiDeploymentRequest{ + AzureContext: azureContext, + ModelName: model.Name, + Options: &azdext.AiModelDeploymentOptions{ + Locations: []string{azureContext.Scope.Location}, + Capacity: &defaultCap, + }, + Quota: &azdext.QuotaCheckOptions{ + MinRemainingCapacity: 1, + }, + }) + if err != nil { + if exterrors.IsCancellation(err) { + return nil, exterrors.Cancelled("deployment configuration was cancelled") + } + return nil, fmt.Errorf("failed to prompt for deployment details: %w", err) + } + + d := deploymentResp.Deployment + skuName := "GlobalStandard" + if d.Sku != nil && d.Sku.Name != "" { + skuName = d.Sku.Name + } + + return &project.Deployment{ + Name: d.ModelName, + Model: project.DeploymentModel{ + Name: d.ModelName, + Format: d.Format, + Version: d.Version, + }, + Sku: project.DeploymentSku{ + Name: skuName, + Capacity: int(d.Capacity), + }, + }, nil + + case "existing": + // Let user pick from all deployments in the project. + type labeledDep struct { + label string + info *FoundryDeploymentInfo + } + items := make([]labeledDep, 0, len(allDeployments)) + for i := range allDeployments { + d := &allDeployments[i] + items = append(items, labeledDep{ + label: fmt.Sprintf("%s (%s, version %s)", d.Name, d.ModelName, d.Version), + info: d, + }) + } + slices.SortFunc(items, func(a, b labeledDep) int { + return strings.Compare(a.label, b.label) + }) + + choices := make([]*azdext.SelectChoice, len(items)) + for i, item := range items { + choices[i] = &azdext.SelectChoice{ + Value: item.label, + Label: item.label, + } + } + + defaultIdx := int32(0) + selResp, err := azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: "Select a deployment", + Choices: choices, + SelectedIndex: &defaultIdx, + }, + }) + if err != nil { + if exterrors.IsCancellation(err) { + return nil, exterrors.Cancelled("deployment selection was cancelled") + } + return nil, fmt.Errorf("failed to select existing deployment: %w", err) + } + + selected := items[*selResp.Value] + d := selected.info + return &project.Deployment{ + Name: d.Name, + Model: project.DeploymentModel{ + Name: d.ModelName, + Format: d.ModelFormat, + Version: d.Version, + }, + Sku: project.DeploymentSku{ + Name: d.SkuName, + Capacity: d.SkuCapacity, + }, + }, nil + } + + return nil, nil +} + +// updateAzureYamlDeployments writes the filtered deployment list back to the +// azure.yaml project service. Deployments the user chose to "use existing" or +// "skip" are excluded, leaving only those that need provisioning. +func updateAzureYamlDeployments( + ctx context.Context, + azdClient *azdext.AzdClient, + serviceName string, + deployments []project.Deployment, +) error { + // Convert deployments to a structpb-compatible value. + depSlice := make([]any, 0, len(deployments)) + for _, d := range deployments { + depSlice = append(depSlice, map[string]any{ + "name": d.Name, + "model": map[string]any{ + "format": d.Model.Format, + "name": d.Model.Name, + "version": d.Model.Version, + }, + "sku": map[string]any{ + "name": d.Sku.Name, + "capacity": d.Sku.Capacity, + }, + }) + } + + val, err := structpb.NewValue(depSlice) + if err != nil { + return fmt.Errorf("encoding deployments for service %q: %w", serviceName, err) + } + + if _, err := azdClient.Project().SetServiceConfigValue(ctx, &azdext.SetServiceConfigValueRequest{ + ServiceName: serviceName, + Path: "deployments", + Value: val, + }); err != nil { + return fmt.Errorf("updating deployments in azure.yaml for service %q: %w", serviceName, err) + } + + return nil +} + // readManifestContentForInitDetection returns the pointed-at YAML content for // init-mode routing. It first uses the cheap peek path; when that cannot read a // GitHub URL (for example, a private repository), it falls back to the @@ -224,7 +760,7 @@ func runInitFromAzureYaml( return err } - _, err = configureFoundryProject( + result, err := configureFoundryProject( ctx, azdClient, azureContext, env.Name, flags.projectResourceId, flags.noPrompt, true, // skipACR — unified azure.yaml does not use container builds @@ -236,6 +772,76 @@ func runInitFromAzureYaml( return err } + // --- Model deployment verification --- + // Parse deployments from the azure.yaml and verify them against the + // selected Foundry project. If the user opts to use existing deployments + // or skip, we update the on-disk azure.yaml accordingly. + deploymentEntries := foundryDeployments(content) + if len(deploymentEntries) > 0 && result != nil && result.Credential != nil { + deploymentsToKeep, referencedDeployments, err := verifyAzureYamlDeployments( + ctx, azdClient, result.Credential, azureContext, env.Name, + deploymentEntries, flags.noPrompt, + ) + if err != nil { + if exterrors.IsCancellation(err) { + return exterrors.Cancelled("initialization was cancelled") + } + return err + } + + // Update the azure.yaml if the kept deployments differ from the original. + if len(deploymentsToKeep) != len(deploymentEntries) || len(deploymentsToKeep) == 0 { + // Group deployments by service name for updating. + byService := make(map[string][]project.Deployment) + for _, entry := range deploymentEntries { + // Initialize to empty — ensures services with all removed get an empty list. + if _, ok := byService[entry.ServiceName]; !ok { + byService[entry.ServiceName] = nil + } + } + for _, dep := range deploymentsToKeep { + // Find the service name for this deployment from original entries. + for _, entry := range deploymentEntries { + if entry.Deployment.Name == dep.Name || entry.Deployment.Model.Name == dep.Model.Name { + byService[entry.ServiceName] = append(byService[entry.ServiceName], dep) + break + } + } + } + // Also handle new deployments (from "choose different") that don't match original entries. + for _, dep := range deploymentsToKeep { + found := false + for _, entry := range deploymentEntries { + if entry.Deployment.Name == dep.Name || entry.Deployment.Model.Name == dep.Model.Name { + found = true + break + } + } + if !found { + // New model chosen — assign to the first service. + for svc := range byService { + byService[svc] = append(byService[svc], dep) + break + } + } + } + + for svcName, deps := range byService { + if err := updateAzureYamlDeployments(ctx, azdClient, svcName, deps); err != nil { + return err + } + } + } + + // Persist the first referenced deployment name as AZURE_AI_MODEL_DEPLOYMENT_NAME. + setEnv := func(ctx context.Context, key, value string) error { + return setEnvValue(ctx, azdClient, env.Name, key, value) + } + if err := persistFirstDeploymentName(ctx, setEnv, referencedDeployments); err != nil { + return fmt.Errorf("failed to set AZURE_AI_MODEL_DEPLOYMENT_NAME: %w", err) + } + } + fmt.Printf( "\nAdopted the sample's azure.yaml as the project manifest at %s.\n", output.WithHighLightFormat("azure.yaml"), From 70db77b5d6090052c881d87a9db51c8ff0f14064 Mon Sep 17 00:00:00 2001 From: trangevi Date: Wed, 1 Jul 2026 16:17:04 -0700 Subject: [PATCH 3/9] test: add unit tests for foundryDeployments parser and helpers Tests cover: - foundryDeployments: single/multiple deployments, missing sections, non-project hosts, empty/malformed content, partial fields - stringField: correct type, wrong type, missing, empty - intField: int, float64, wrong type, missing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../internal/cmd/init_adopt_test.go | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go index a16e4d436b4..fa67a01b5b6 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go @@ -8,6 +8,8 @@ import ( "path/filepath" "testing" + "azureaiagent/internal/project" + "github.com/stretchr/testify/require" ) @@ -274,3 +276,169 @@ func TestStageAzureYamlTemplate_LocalRenamesToAzureYaml(t *testing.T) { // Sibling files are carried into the staging directory. require.True(t, fileExists(filepath.Join(staging, "agents", "main.py"))) } + +func TestFoundryDeployments(t *testing.T) { + tests := []struct { + name string + content string + want []foundryDeploymentEntry + }{ + { + name: "single deployment under ai-project", + content: `name: foundry-simple +services: + ai-project: + host: azure.ai.project + deployments: + - name: gpt-4o-mini + model: + format: OpenAI + name: gpt-4o-mini + version: "2024-07-18" + sku: + name: GlobalStandard + capacity: 50 + assistant: + host: azure.ai.agent +`, + want: []foundryDeploymentEntry{ + { + ServiceName: "ai-project", + Deployment: project.Deployment{ + Name: "gpt-4o-mini", + Model: project.DeploymentModel{Format: "OpenAI", Name: "gpt-4o-mini", Version: "2024-07-18"}, + Sku: project.DeploymentSku{Name: "GlobalStandard", Capacity: 50}, + }, + }, + }, + }, + { + name: "multiple deployments", + content: `name: multi-model +services: + ai-project: + host: azure.ai.project + deployments: + - name: gpt-4o + model: + format: OpenAI + name: gpt-4o + version: "2024-08-06" + sku: + name: GlobalStandard + capacity: 100 + - name: text-embedding + model: + format: OpenAI + name: text-embedding-ada-002 + version: "2" + sku: + name: Standard + capacity: 10 +`, + want: []foundryDeploymentEntry{ + { + ServiceName: "ai-project", + Deployment: project.Deployment{ + Name: "gpt-4o", + Model: project.DeploymentModel{Format: "OpenAI", Name: "gpt-4o", Version: "2024-08-06"}, + Sku: project.DeploymentSku{Name: "GlobalStandard", Capacity: 100}, + }, + }, + { + ServiceName: "ai-project", + Deployment: project.Deployment{ + Name: "text-embedding", + Model: project.DeploymentModel{Format: "OpenAI", Name: "text-embedding-ada-002", Version: "2"}, + Sku: project.DeploymentSku{Name: "Standard", Capacity: 10}, + }, + }, + }, + }, + { + name: "no deployments section", + content: `name: no-deploy +services: + ai-project: + host: azure.ai.project +`, + want: nil, + }, + { + name: "non-project host ignored", + content: `name: agent-only +services: + assistant: + host: azure.ai.agent + deployments: + - name: should-be-ignored + model: + name: gpt-4o +`, + want: nil, + }, + { + name: "empty content", + content: "", + want: nil, + }, + { + name: "malformed yaml", + content: "name: [oops", + want: nil, + }, + { + name: "missing model and sku fields", + content: `name: partial +services: + ai-project: + host: azure.ai.project + deployments: + - name: bare-deploy +`, + want: []foundryDeploymentEntry{ + { + ServiceName: "ai-project", + Deployment: project.Deployment{ + Name: "bare-deploy", + Model: project.DeploymentModel{}, + Sku: project.DeploymentSku{}, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := foundryDeployments([]byte(tt.content)) + require.Equal(t, tt.want, got) + }) + } +} + +func TestStringField(t *testing.T) { + m := map[string]any{ + "name": "gpt-4o", + "number": 42, + "present": "", + } + + require.Equal(t, "gpt-4o", stringField(m, "name")) + require.Equal(t, "", stringField(m, "number")) // wrong type + require.Equal(t, "", stringField(m, "missing")) // absent + require.Equal(t, "", stringField(m, "present")) // empty string +} + +func TestIntField(t *testing.T) { + m := map[string]any{ + "capacity_int": 50, + "capacity_float": float64(100), + "name": "not-a-number", + } + + require.Equal(t, 50, intField(m, "capacity_int")) + require.Equal(t, 100, intField(m, "capacity_float")) + require.Equal(t, 0, intField(m, "name")) // wrong type + require.Equal(t, 0, intField(m, "missing")) // absent +} From 47428df765a5d65cf2abc353da087d4ae47bc6de Mon Sep 17 00:00:00 2001 From: trangevi Date: Wed, 1 Jul 2026 16:26:42 -0700 Subject: [PATCH 4/9] refactor: use typed YAML unmarshal in foundryDeployments Replace map[string]any manual extraction with typed structs (azureYamlServices, azureYamlService) that unmarshal directly into project.Deployment. This gives strong typing at parse time and removes the stringField/intField helpers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../internal/cmd/init_adopt.go | 80 ++++--------------- .../internal/cmd/init_adopt_test.go | 24 +----- 2 files changed, 21 insertions(+), 83 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go index ff78f75aac2..3f227487d13 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go @@ -101,59 +101,32 @@ type foundryDeploymentEntry struct { Deployment project.Deployment } +// azureYamlServices is the minimal typed structure for parsing deployments from +// a unified azure.yaml. Only the fields needed for deployment verification are +// declared; yaml.v3 ignores unrecognized keys. +type azureYamlServices struct { + Services map[string]azureYamlService `yaml:"services"` +} + +type azureYamlService struct { + Host string `yaml:"host"` + Deployments []project.Deployment `yaml:"deployments"` +} + // foundryDeployments parses the azure.yaml content and returns all model // deployments declared under services with `host: azure.ai.project`. func foundryDeployments(content []byte) []foundryDeploymentEntry { - var top map[string]any - if err := yaml.Unmarshal(content, &top); err != nil { - return nil - } - - services, ok := top["services"].(map[string]any) - if !ok { + var doc azureYamlServices + if err := yaml.Unmarshal(content, &doc); err != nil { return nil } var entries []foundryDeploymentEntry - for svcName, svc := range services { - svcMap, ok := svc.(map[string]any) - if !ok { + for svcName, svc := range doc.Services { + if svc.Host != "azure.ai.project" { continue } - host, _ := svcMap["host"].(string) - if host != "azure.ai.project" { - continue - } - - rawDeployments, ok := svcMap["deployments"].([]any) - if !ok { - continue - } - - for _, raw := range rawDeployments { - d, ok := raw.(map[string]any) - if !ok { - continue - } - - dep := project.Deployment{ - Name: stringField(d, "name"), - } - - if model, ok := d["model"].(map[string]any); ok { - dep.Model = project.DeploymentModel{ - Format: stringField(model, "format"), - Name: stringField(model, "name"), - Version: stringField(model, "version"), - } - } - if sku, ok := d["sku"].(map[string]any); ok { - dep.Sku = project.DeploymentSku{ - Name: stringField(sku, "name"), - Capacity: intField(sku, "capacity"), - } - } - + for _, dep := range svc.Deployments { entries = append(entries, foundryDeploymentEntry{ ServiceName: svcName, Deployment: dep, @@ -163,25 +136,6 @@ func foundryDeployments(content []byte) []foundryDeploymentEntry { return entries } -// stringField safely extracts a string field from a map[string]any. -func stringField(m map[string]any, key string) string { - v, _ := m[key].(string) - return v -} - -// intField safely extracts an int field from a map[string]any, handling both -// int and float64 (YAML unmarshals numbers as int with yaml.v3). -func intField(m map[string]any, key string) int { - switch v := m[key].(type) { - case int: - return v - case float64: - return int(v) - default: - return 0 - } -} - // verifyAzureYamlDeployments checks each model deployment declared in the // unified azure.yaml against the selected Foundry project's existing // deployments. It prompts the user for each deployment and returns the filtered diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go index fa67a01b5b6..1d13efa31ef 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go @@ -418,27 +418,11 @@ services: } func TestStringField(t *testing.T) { - m := map[string]any{ - "name": "gpt-4o", - "number": 42, - "present": "", - } - - require.Equal(t, "gpt-4o", stringField(m, "name")) - require.Equal(t, "", stringField(m, "number")) // wrong type - require.Equal(t, "", stringField(m, "missing")) // absent - require.Equal(t, "", stringField(m, "present")) // empty string + // stringField was removed — this test is no longer needed. + t.Skip("stringField helper removed in favor of typed YAML unmarshal") } func TestIntField(t *testing.T) { - m := map[string]any{ - "capacity_int": 50, - "capacity_float": float64(100), - "name": "not-a-number", - } - - require.Equal(t, 50, intField(m, "capacity_int")) - require.Equal(t, 100, intField(m, "capacity_float")) - require.Equal(t, 0, intField(m, "name")) // wrong type - require.Equal(t, 0, intField(m, "missing")) // absent + // intField was removed — this test is no longer needed. + t.Skip("intField helper removed in favor of typed YAML unmarshal") } From f230f01224ea521238c1b79972b3b6da465433fe Mon Sep 17 00:00:00 2001 From: trangevi Date: Wed, 1 Jul 2026 16:36:06 -0700 Subject: [PATCH 5/9] chore: add yaml struct tags to project.Deployment types Makes the YAML field mapping an explicit contract rather than relying on yaml.v3's lowercase-field-name fallback behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azure.ai.agents/internal/project/config.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/config.go b/cli/azd/extensions/azure.ai.agents/internal/project/config.go index d8af08f12aa..bbd9eaaf496 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/config.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/config.go @@ -69,34 +69,34 @@ type ResourceSettings struct { // Deployment represents a single model deployment type Deployment struct { // Specify the name of model deployment. - Name string `json:"name"` + Name string `json:"name" yaml:"name"` // Required. Properties of model deployment. - Model DeploymentModel `json:"model"` + Model DeploymentModel `json:"model" yaml:"model"` // The resource model definition representing SKU. - Sku DeploymentSku `json:"sku"` + Sku DeploymentSku `json:"sku" yaml:"sku"` } // DeploymentModel represents the model configuration for a model deployment type DeploymentModel struct { // Required. The name of model deployment. - Name string `json:"name"` + Name string `json:"name" yaml:"name"` // Required. The format of model deployment. - Format string `json:"format"` + Format string `json:"format" yaml:"format"` // Required. The version of model deployment. - Version string `json:"version"` + Version string `json:"version" yaml:"version"` } // DeploymentSku represents the resource model definition representing SKU type DeploymentSku struct { // Required. The name of the resource model definition representing SKU. - Name string `json:"name"` + Name string `json:"name" yaml:"name"` // The capacity of the resource model definition representing SKU. - Capacity int `json:"capacity"` + Capacity int `json:"capacity" yaml:"capacity"` } // Resource represents an external resource for agent execution From 71a3d7cf4e87bec94dc46c51b3098c801f1ba9f8 Mon Sep 17 00:00:00 2001 From: trangevi Date: Wed, 1 Jul 2026 16:41:28 -0700 Subject: [PATCH 6/9] fix: resolve cspell spelling issue Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azure.ai.agents/internal/cmd/init_foundry_project_setup.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_project_setup.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_project_setup.go index f236cf14f6d..4501750fbce 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_project_setup.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_project_setup.go @@ -26,7 +26,7 @@ type foundryProjectSetupResult struct { // // 1. --project-id provided: validate + select the specified project. // 2. --no-prompt with missing Azure context: defer setup (print what's needed). -// 3. --no-prompt with Azure context present: configure new project headlessly. +// 3. --no-prompt with Azure context present: configure new project without prompts. // 4. Interactive: prompt "Use an existing Foundry project" vs "Create new". // // This is the shared core extracted from configureModelChoice's From 858b0bb0dbfde1a0e36cd65b378f725900eee42b Mon Sep 17 00:00:00 2001 From: trangevi Date: Wed, 1 Jul 2026 16:51:27 -0700 Subject: [PATCH 7/9] feat: wire --model-deployment and --model flags into azure.yaml adoption path When --model-deployment is provided, auto-select the named deployment from the Foundry project without interactive prompts (error if not found). When --model is provided, pre-select it as the default in the catalog prompt when the user chooses a different model. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../internal/cmd/init_adopt.go | 43 +++++++++++++++++-- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go index 3f227487d13..8ba2b6b89e4 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go @@ -149,6 +149,8 @@ func verifyAzureYamlDeployments( envName string, entries []foundryDeploymentEntry, noPrompt bool, + modelDeploymentFlag string, + modelFlag string, ) (deploymentsToKeep []project.Deployment, referencedDeployments []project.Deployment, err error) { // Get the Foundry project ID from the environment. resp, err := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ @@ -183,6 +185,35 @@ func verifyAzureYamlDeployments( return nil, nil, fmt.Errorf("failed to list deployments in Foundry project: %w", listErr) } + // --model-deployment flag: auto-select the named deployment, skip interactive loop. + if modelDeploymentFlag != "" { + for _, d := range allDeployments { + if strings.EqualFold(d.Name, modelDeploymentFlag) { + log.Printf("--model-deployment: using existing deployment '%s' (model: %s, version: %s)", + d.Name, d.ModelName, d.Version) + referencedDeployments = append(referencedDeployments, project.Deployment{ + Name: d.Name, + Model: project.DeploymentModel{ + Name: d.ModelName, + Format: d.ModelFormat, + Version: d.Version, + }, + Sku: project.DeploymentSku{ + Name: d.SkuName, + Capacity: d.SkuCapacity, + }, + }) + // All azure.yaml deployments are removed (existing deployment is used instead). + return nil, referencedDeployments, nil + } + } + return nil, nil, exterrors.Validation( + exterrors.CodeModelDeploymentNotFound, + fmt.Sprintf("model deployment %q not found in Foundry project", modelDeploymentFlag), + "verify the deployment name or omit --model-deployment to select interactively", + ) + } + for _, entry := range entries { dep := entry.Deployment @@ -304,7 +335,7 @@ func verifyAzureYamlDeployments( referencedDeployments = append(referencedDeployments, dep) case selected == "change": - newDep, err := promptAlternativeDeployment(ctx, azdClient, azureContext, allDeployments) + newDep, err := promptAlternativeDeployment(ctx, azdClient, azureContext, allDeployments, modelFlag) if err != nil { return nil, nil, err } @@ -364,7 +395,7 @@ func verifyAzureYamlDeployments( referencedDeployments = append(referencedDeployments, dep) case "change": - newDep, err := promptAlternativeDeployment(ctx, azdClient, azureContext, allDeployments) + newDep, err := promptAlternativeDeployment(ctx, azdClient, azureContext, allDeployments, modelFlag) if err != nil { return nil, nil, err } @@ -391,6 +422,7 @@ func promptAlternativeDeployment( azdClient *azdext.AzdClient, azureContext *azdext.AzureContext, allDeployments []FoundryDeploymentInfo, + modelFlag string, ) (*project.Deployment, error) { // Offer "browse catalog" or "use existing deployment" if any exist. altChoices := []*azdext.SelectChoice{ @@ -421,12 +453,17 @@ func promptAlternativeDeployment( case "catalog": // Use the full model + deployment prompt which handles version, // SKU, and capacity selection (same as manifest path). + defaultModel := "gpt-4.1-mini" + if modelFlag != "" { + defaultModel = modelFlag + } promptReq := &azdext.PromptAiModelRequest{ AzureContext: azureContext, Filter: agentModelFilter([]string{azureContext.Scope.Location}, nil), SelectOptions: &azdext.SelectOptions{ Message: "Select a model", }, + DefaultValue: defaultModel, } modelResp, err := azdClient.Prompt().PromptAiModel(ctx, promptReq) @@ -734,7 +771,7 @@ func runInitFromAzureYaml( if len(deploymentEntries) > 0 && result != nil && result.Credential != nil { deploymentsToKeep, referencedDeployments, err := verifyAzureYamlDeployments( ctx, azdClient, result.Credential, azureContext, env.Name, - deploymentEntries, flags.noPrompt, + deploymentEntries, flags.noPrompt, flags.modelDeployment, flags.model, ) if err != nil { if exterrors.IsCancellation(err) { From 13d3f67e02d28a2ca205f3dc65c6002080f0d576 Mon Sep 17 00:00:00 2001 From: trangevi Date: Thu, 2 Jul 2026 10:16:39 -0700 Subject: [PATCH 8/9] fix: address PR review feedback for deployment verification - Fix azure.yaml rewrite guard: use a 'modified' flag instead of length-only comparison, so model swaps via 'Choose a different model' correctly trigger a rewrite. - Fix non-deterministic service assignment: return keptEntries with ServiceName preserved from the original entry, eliminating the map-iteration fallback. - Fix deferred project path: return deploymentsToKeep as referencedDeployments when AZURE_AI_PROJECT_ID is empty, so AZURE_AI_MODEL_DEPLOYMENT_NAME is still written. - Delete always-skipped TestStringField and TestIntField stubs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../internal/cmd/init_adopt.go | 100 +++++++++--------- .../internal/cmd/init_adopt_test.go | 10 -- 2 files changed, 51 insertions(+), 59 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go index 8ba2b6b89e4..6efac0a914e 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go @@ -151,28 +151,32 @@ func verifyAzureYamlDeployments( noPrompt bool, modelDeploymentFlag string, modelFlag string, -) (deploymentsToKeep []project.Deployment, referencedDeployments []project.Deployment, err error) { +) (keptEntries []foundryDeploymentEntry, referencedDeployments []project.Deployment, modified bool, err error) { // Get the Foundry project ID from the environment. resp, err := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ EnvName: envName, Key: "AZURE_AI_PROJECT_ID", }) if err != nil { - return nil, nil, fmt.Errorf("failed to get AZURE_AI_PROJECT_ID: %w", err) + return nil, nil, false, fmt.Errorf("failed to get AZURE_AI_PROJECT_ID: %w", err) } foundryProjectId := resp.Value if foundryProjectId == "" { // No project selected (deferred setup) — keep all deployments as-is. for _, e := range entries { - deploymentsToKeep = append(deploymentsToKeep, e.Deployment) + keptEntries = append(keptEntries, e) } - return deploymentsToKeep, nil, nil + // Return kept entries as referenced so AZURE_AI_MODEL_DEPLOYMENT_NAME is still written. + for _, e := range entries { + referencedDeployments = append(referencedDeployments, e.Deployment) + } + return keptEntries, referencedDeployments, false, nil } parts := strings.Split(foundryProjectId, "/") if len(parts) < 9 { - return nil, nil, fmt.Errorf( + return nil, nil, false, fmt.Errorf( "invalid AZURE_AI_PROJECT_ID format: expected at least 9 path segments, got %d", len(parts)) } @@ -182,7 +186,7 @@ func verifyAzureYamlDeployments( allDeployments, listErr := listProjectDeployments(ctx, credential, subscription, resourceGroup, accountName) if listErr != nil { - return nil, nil, fmt.Errorf("failed to list deployments in Foundry project: %w", listErr) + return nil, nil, false, fmt.Errorf("failed to list deployments in Foundry project: %w", listErr) } // --model-deployment flag: auto-select the named deployment, skip interactive loop. @@ -204,10 +208,10 @@ func verifyAzureYamlDeployments( }, }) // All azure.yaml deployments are removed (existing deployment is used instead). - return nil, referencedDeployments, nil + return nil, referencedDeployments, true, nil } } - return nil, nil, exterrors.Validation( + return nil, nil, false, exterrors.Validation( exterrors.CodeModelDeploymentNotFound, fmt.Sprintf("model deployment %q not found in Foundry project", modelDeploymentFlag), "verify the deployment name or omit --model-deployment to select interactively", @@ -254,6 +258,7 @@ func verifyAzureYamlDeployments( Capacity: existing.SkuCapacity, }, }) + modified = true continue } @@ -306,9 +311,9 @@ func verifyAzureYamlDeployments( }) if err != nil { if exterrors.IsCancellation(err) { - return nil, nil, exterrors.Cancelled("model deployment verification was cancelled") + return nil, nil, false, exterrors.Cancelled("model deployment verification was cancelled") } - return nil, nil, fmt.Errorf("failed to prompt for deployment choice: %w", err) + return nil, nil, false, fmt.Errorf("failed to prompt for deployment choice: %w", err) } selected := choices[*selectResp.Value].Value @@ -328,23 +333,32 @@ func verifyAzureYamlDeployments( Capacity: existing.SkuCapacity, }, }) + modified = true fmt.Printf("Using existing deployment '%s'.\n", name) case selected == "deploy": - deploymentsToKeep = append(deploymentsToKeep, dep) + keptEntries = append(keptEntries, foundryDeploymentEntry{ + ServiceName: entry.ServiceName, + Deployment: dep, + }) referencedDeployments = append(referencedDeployments, dep) case selected == "change": newDep, err := promptAlternativeDeployment(ctx, azdClient, azureContext, allDeployments, modelFlag) if err != nil { - return nil, nil, err + return nil, nil, false, err } if newDep != nil { - deploymentsToKeep = append(deploymentsToKeep, *newDep) + keptEntries = append(keptEntries, foundryDeploymentEntry{ + ServiceName: entry.ServiceName, + Deployment: *newDep, + }) referencedDeployments = append(referencedDeployments, *newDep) } + modified = true case selected == "skip": + modified = true fmt.Println(output.WithWarningFormat( "Skipped model '%s'. It will be removed from the azure.yaml.", dep.Model.Name)) } @@ -355,7 +369,10 @@ func verifyAzureYamlDeployments( // Auto-deploy as specified. log.Printf("--no-prompt: no matching deployment for model '%s', will deploy as specified", dep.Model.Name) - deploymentsToKeep = append(deploymentsToKeep, dep) + keptEntries = append(keptEntries, foundryDeploymentEntry{ + ServiceName: entry.ServiceName, + Deployment: dep, + }) referencedDeployments = append(referencedDeployments, dep) continue } @@ -384,34 +401,42 @@ func verifyAzureYamlDeployments( }) if err != nil { if exterrors.IsCancellation(err) { - return nil, nil, exterrors.Cancelled("model deployment verification was cancelled") + return nil, nil, false, exterrors.Cancelled("model deployment verification was cancelled") } - return nil, nil, fmt.Errorf("failed to prompt for deployment choice: %w", err) + return nil, nil, false, fmt.Errorf("failed to prompt for deployment choice: %w", err) } switch noMatchChoices[*selectResp.Value].Value { case "deploy": - deploymentsToKeep = append(deploymentsToKeep, dep) + keptEntries = append(keptEntries, foundryDeploymentEntry{ + ServiceName: entry.ServiceName, + Deployment: dep, + }) referencedDeployments = append(referencedDeployments, dep) case "change": newDep, err := promptAlternativeDeployment(ctx, azdClient, azureContext, allDeployments, modelFlag) if err != nil { - return nil, nil, err + return nil, nil, false, err } if newDep != nil { - deploymentsToKeep = append(deploymentsToKeep, *newDep) + keptEntries = append(keptEntries, foundryDeploymentEntry{ + ServiceName: entry.ServiceName, + Deployment: *newDep, + }) referencedDeployments = append(referencedDeployments, *newDep) } + modified = true case "skip": + modified = true fmt.Println(output.WithWarningFormat( "Skipped model '%s'. It will be removed from the azure.yaml.", dep.Model.Name)) } } } - return deploymentsToKeep, referencedDeployments, nil + return keptEntries, referencedDeployments, modified, nil } // promptAlternativeDeployment lets the user browse the model catalog or pick an @@ -769,7 +794,7 @@ func runInitFromAzureYaml( // or skip, we update the on-disk azure.yaml accordingly. deploymentEntries := foundryDeployments(content) if len(deploymentEntries) > 0 && result != nil && result.Credential != nil { - deploymentsToKeep, referencedDeployments, err := verifyAzureYamlDeployments( + keptEntries, referencedDeployments, deploymentsModified, err := verifyAzureYamlDeployments( ctx, azdClient, result.Credential, azureContext, env.Name, deploymentEntries, flags.noPrompt, flags.modelDeployment, flags.model, ) @@ -780,9 +805,9 @@ func runInitFromAzureYaml( return err } - // Update the azure.yaml if the kept deployments differ from the original. - if len(deploymentsToKeep) != len(deploymentEntries) || len(deploymentsToKeep) == 0 { - // Group deployments by service name for updating. + // Update the azure.yaml if deployments were modified. + if deploymentsModified { + // Group kept deployments by their originating service name. byService := make(map[string][]project.Deployment) for _, entry := range deploymentEntries { // Initialize to empty — ensures services with all removed get an empty list. @@ -790,31 +815,8 @@ func runInitFromAzureYaml( byService[entry.ServiceName] = nil } } - for _, dep := range deploymentsToKeep { - // Find the service name for this deployment from original entries. - for _, entry := range deploymentEntries { - if entry.Deployment.Name == dep.Name || entry.Deployment.Model.Name == dep.Model.Name { - byService[entry.ServiceName] = append(byService[entry.ServiceName], dep) - break - } - } - } - // Also handle new deployments (from "choose different") that don't match original entries. - for _, dep := range deploymentsToKeep { - found := false - for _, entry := range deploymentEntries { - if entry.Deployment.Name == dep.Name || entry.Deployment.Model.Name == dep.Model.Name { - found = true - break - } - } - if !found { - // New model chosen — assign to the first service. - for svc := range byService { - byService[svc] = append(byService[svc], dep) - break - } - } + for _, kept := range keptEntries { + byService[kept.ServiceName] = append(byService[kept.ServiceName], kept.Deployment) } for svcName, deps := range byService { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go index 1d13efa31ef..e39cbe1f617 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go @@ -416,13 +416,3 @@ services: }) } } - -func TestStringField(t *testing.T) { - // stringField was removed — this test is no longer needed. - t.Skip("stringField helper removed in favor of typed YAML unmarshal") -} - -func TestIntField(t *testing.T) { - // intField was removed — this test is no longer needed. - t.Skip("intField helper removed in favor of typed YAML unmarshal") -} From c48df52c1b84026b39d0f690c1fe7ebf7e64407c Mon Sep 17 00:00:00 2001 From: trangevi Date: Thu, 2 Jul 2026 14:25:06 -0700 Subject: [PATCH 9/9] fix: improve model deployment verification UX for new project path - Show model details and prompt for confirmation even when creating a new project (no early return on empty AZURE_AI_PROJECT_ID). - Skip redundant 'Deploy as specified' option when an exact match exists. - Skip intermediate catalog/existing prompt when no deployments exist. - Use context-appropriate messaging (no 'not found in project' when there is no project yet). - Remove deployment from azure.yaml when user picks an existing one via 'Choose a different model' (no provisioning needed). - Update 'matching deployments' message for clarity. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../internal/cmd/init_adopt.go | 257 +++++++++--------- 1 file changed, 134 insertions(+), 123 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go index 6efac0a914e..0a95f747039 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go @@ -161,32 +161,23 @@ func verifyAzureYamlDeployments( return nil, nil, false, fmt.Errorf("failed to get AZURE_AI_PROJECT_ID: %w", err) } + var allDeployments []FoundryDeploymentInfo foundryProjectId := resp.Value - if foundryProjectId == "" { - // No project selected (deferred setup) — keep all deployments as-is. - for _, e := range entries { - keptEntries = append(keptEntries, e) + if foundryProjectId != "" { + parts := strings.Split(foundryProjectId, "/") + if len(parts) < 9 { + return nil, nil, false, fmt.Errorf( + "invalid AZURE_AI_PROJECT_ID format: expected at least 9 path segments, got %d", len(parts)) } - // Return kept entries as referenced so AZURE_AI_MODEL_DEPLOYMENT_NAME is still written. - for _, e := range entries { - referencedDeployments = append(referencedDeployments, e.Deployment) - } - return keptEntries, referencedDeployments, false, nil - } - parts := strings.Split(foundryProjectId, "/") - if len(parts) < 9 { - return nil, nil, false, fmt.Errorf( - "invalid AZURE_AI_PROJECT_ID format: expected at least 9 path segments, got %d", len(parts)) - } + subscription := parts[2] + resourceGroup := parts[4] + accountName := parts[8] - subscription := parts[2] - resourceGroup := parts[4] - accountName := parts[8] - - allDeployments, listErr := listProjectDeployments(ctx, credential, subscription, resourceGroup, accountName) - if listErr != nil { - return nil, nil, false, fmt.Errorf("failed to list deployments in Foundry project: %w", listErr) + allDeployments, err = listProjectDeployments(ctx, credential, subscription, resourceGroup, accountName) + if err != nil { + return nil, nil, false, fmt.Errorf("failed to list deployments in Foundry project: %w", err) + } } // --model-deployment flag: auto-select the named deployment, skip interactive loop. @@ -268,7 +259,7 @@ func verifyAzureYamlDeployments( fmt.Printf(" SKU: %s, capacity %d\n", dep.Sku.Name, dep.Sku.Capacity) fmt.Println() - fmt.Println("Matching deployment(s) already exist in your Foundry project:") + fmt.Println("Existing deployment(s) using the same model were found in your Foundry project:") for _, name := range sortedNames { d := matchingDeployments[name] fmt.Printf(" • %s — version %s, SKU: %s (capacity %d)\n", @@ -276,7 +267,7 @@ func verifyAzureYamlDeployments( } fmt.Println() - // Build prompt choices: use each existing + deploy as specified + choose different + skip + // Build prompt choices: use each existing + optionally deploy as specified + choose different + skip choices := make([]*azdext.SelectChoice, 0, len(sortedNames)+3) for _, name := range sortedNames { d := matchingDeployments[name] @@ -286,11 +277,24 @@ func verifyAzureYamlDeployments( name, d.Version, d.SkuName), }) } - choices = append(choices, - &azdext.SelectChoice{ + // Only offer "deploy as specified" if no existing deployment is an exact match. + hasExactMatch := false + for _, d := range matchingDeployments { + if d.Name == dep.Name && + d.Version == dep.Model.Version && + d.SkuName == dep.Sku.Name && + d.SkuCapacity == dep.Sku.Capacity { + hasExactMatch = true + break + } + } + if !hasExactMatch { + choices = append(choices, &azdext.SelectChoice{ Value: "deploy", Label: "Deploy as specified in azure.yaml", - }, + }) + } + choices = append(choices, &azdext.SelectChoice{ Value: "change", Label: "Choose a different model", @@ -344,15 +348,17 @@ func verifyAzureYamlDeployments( referencedDeployments = append(referencedDeployments, dep) case selected == "change": - newDep, err := promptAlternativeDeployment(ctx, azdClient, azureContext, allDeployments, modelFlag) + newDep, isExisting, err := promptAlternativeDeployment(ctx, azdClient, azureContext, allDeployments, modelFlag) if err != nil { return nil, nil, false, err } if newDep != nil { - keptEntries = append(keptEntries, foundryDeploymentEntry{ - ServiceName: entry.ServiceName, - Deployment: *newDep, - }) + if !isExisting { + keptEntries = append(keptEntries, foundryDeploymentEntry{ + ServiceName: entry.ServiceName, + Deployment: *newDep, + }) + } referencedDeployments = append(referencedDeployments, *newDep) } modified = true @@ -364,7 +370,7 @@ func verifyAzureYamlDeployments( } } else { - // No matching deployment in the project. + // No matching deployment in the project (or no project yet). if noPrompt { // Auto-deploy as specified. log.Printf("--no-prompt: no matching deployment for model '%s', will deploy as specified", @@ -377,11 +383,17 @@ func verifyAzureYamlDeployments( continue } - color.Yellow( - "\nNo existing deployment for model '%s' was found in your Foundry project.\n", - dep.Model.Name, - ) - fmt.Printf("Model deployment %s is defined in the azure.yaml:\n", output.WithHighLightFormat("'%s'", dep.Name)) + if foundryProjectId == "" { + fmt.Printf("\nModel deployment %s is defined in the azure.yaml:\n", + output.WithHighLightFormat("'%s'", dep.Name)) + } else { + color.Yellow( + "\nNo existing deployment for model '%s' was found in your Foundry project.\n", + dep.Model.Name, + ) + fmt.Printf("Model deployment %s is defined in the azure.yaml:\n", + output.WithHighLightFormat("'%s'", dep.Name)) + } fmt.Printf(" Model: %s (%s), version %s\n", dep.Model.Name, dep.Model.Format, dep.Model.Version) fmt.Printf(" SKU: %s, capacity %d\n\n", dep.Sku.Name, dep.Sku.Capacity) @@ -415,15 +427,17 @@ func verifyAzureYamlDeployments( referencedDeployments = append(referencedDeployments, dep) case "change": - newDep, err := promptAlternativeDeployment(ctx, azdClient, azureContext, allDeployments, modelFlag) + newDep, isExisting, err := promptAlternativeDeployment(ctx, azdClient, azureContext, allDeployments, modelFlag) if err != nil { return nil, nil, false, err } if newDep != nil { - keptEntries = append(keptEntries, foundryDeploymentEntry{ - ServiceName: entry.ServiceName, - Deployment: *newDep, - }) + if !isExisting { + keptEntries = append(keptEntries, foundryDeploymentEntry{ + ServiceName: entry.ServiceName, + Deployment: *newDep, + }) + } referencedDeployments = append(referencedDeployments, *newDep) } modified = true @@ -441,41 +455,41 @@ func verifyAzureYamlDeployments( // promptAlternativeDeployment lets the user browse the model catalog or pick an // existing deployment from the project. It returns the chosen deployment, or nil -// if no selection was made. +// if no selection was made. The isExisting flag indicates whether the user picked +// an already-deployed model (true) or a new one from the catalog (false). func promptAlternativeDeployment( ctx context.Context, azdClient *azdext.AzdClient, azureContext *azdext.AzureContext, allDeployments []FoundryDeploymentInfo, modelFlag string, -) (*project.Deployment, error) { - // Offer "browse catalog" or "use existing deployment" if any exist. - altChoices := []*azdext.SelectChoice{ - {Value: "catalog", Label: "Browse the model catalog"}, - } +) (dep *project.Deployment, isExisting bool, err error) { + // Determine whether to prompt for catalog vs existing, or skip straight to catalog. + useCatalog := true if len(allDeployments) > 0 { - altChoices = append(altChoices, &azdext.SelectChoice{ - Value: "existing", Label: "Use an existing deployment from this project", - }) - } + altChoices := []*azdext.SelectChoice{ + {Value: "catalog", Label: "Browse the model catalog"}, + {Value: "existing", Label: "Use an existing deployment from this project"}, + } - defaultIdx := int32(0) - altResp, err := azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ - Options: &azdext.SelectOptions{ - Message: "How would you like to choose a model?", - Choices: altChoices, - SelectedIndex: &defaultIdx, - }, - }) - if err != nil { - if exterrors.IsCancellation(err) { - return nil, exterrors.Cancelled("model selection was cancelled") + defaultIdx := int32(0) + altResp, err := azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: "How would you like to choose a model?", + Choices: altChoices, + SelectedIndex: &defaultIdx, + }, + }) + if err != nil { + if exterrors.IsCancellation(err) { + return nil, false, exterrors.Cancelled("model selection was cancelled") + } + return nil, false, fmt.Errorf("failed to prompt for alternative model choice: %w", err) } - return nil, fmt.Errorf("failed to prompt for alternative model choice: %w", err) + useCatalog = altChoices[*altResp.Value].Value == "catalog" } - switch altChoices[*altResp.Value].Value { - case "catalog": + if useCatalog { // Use the full model + deployment prompt which handles version, // SKU, and capacity selection (same as manifest path). defaultModel := "gpt-4.1-mini" @@ -494,9 +508,9 @@ func promptAlternativeDeployment( modelResp, err := azdClient.Prompt().PromptAiModel(ctx, promptReq) if err != nil { if exterrors.IsCancellation(err) { - return nil, exterrors.Cancelled("model selection was cancelled") + return nil, false, exterrors.Cancelled("model selection was cancelled") } - return nil, fmt.Errorf("failed to prompt for model selection: %w", err) + return nil, false, fmt.Errorf("failed to prompt for model selection: %w", err) } model := modelResp.Model @@ -515,9 +529,9 @@ func promptAlternativeDeployment( }) if err != nil { if exterrors.IsCancellation(err) { - return nil, exterrors.Cancelled("deployment configuration was cancelled") + return nil, false, exterrors.Cancelled("deployment configuration was cancelled") } - return nil, fmt.Errorf("failed to prompt for deployment details: %w", err) + return nil, false, fmt.Errorf("failed to prompt for deployment details: %w", err) } d := deploymentResp.Deployment @@ -537,66 +551,63 @@ func promptAlternativeDeployment( Name: skuName, Capacity: int(d.Capacity), }, - }, nil + }, false, nil + } - case "existing": - // Let user pick from all deployments in the project. - type labeledDep struct { - label string - info *FoundryDeploymentInfo - } - items := make([]labeledDep, 0, len(allDeployments)) - for i := range allDeployments { - d := &allDeployments[i] - items = append(items, labeledDep{ - label: fmt.Sprintf("%s (%s, version %s)", d.Name, d.ModelName, d.Version), - info: d, - }) - } - slices.SortFunc(items, func(a, b labeledDep) int { - return strings.Compare(a.label, b.label) + // Let user pick from all deployments in the project. + type labeledDep struct { + label string + info *FoundryDeploymentInfo + } + items := make([]labeledDep, 0, len(allDeployments)) + for i := range allDeployments { + d := &allDeployments[i] + items = append(items, labeledDep{ + label: fmt.Sprintf("%s (%s, version %s)", d.Name, d.ModelName, d.Version), + info: d, }) + } + slices.SortFunc(items, func(a, b labeledDep) int { + return strings.Compare(a.label, b.label) + }) - choices := make([]*azdext.SelectChoice, len(items)) - for i, item := range items { - choices[i] = &azdext.SelectChoice{ - Value: item.label, - Label: item.label, - } + choices := make([]*azdext.SelectChoice, len(items)) + for i, item := range items { + choices[i] = &azdext.SelectChoice{ + Value: item.label, + Label: item.label, } + } - defaultIdx := int32(0) - selResp, err := azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ - Options: &azdext.SelectOptions{ - Message: "Select a deployment", - Choices: choices, - SelectedIndex: &defaultIdx, - }, - }) - if err != nil { - if exterrors.IsCancellation(err) { - return nil, exterrors.Cancelled("deployment selection was cancelled") - } - return nil, fmt.Errorf("failed to select existing deployment: %w", err) + defaultIdx := int32(0) + selResp, err := azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: "Select a deployment", + Choices: choices, + SelectedIndex: &defaultIdx, + }, + }) + if err != nil { + if exterrors.IsCancellation(err) { + return nil, false, exterrors.Cancelled("deployment selection was cancelled") } - - selected := items[*selResp.Value] - d := selected.info - return &project.Deployment{ - Name: d.Name, - Model: project.DeploymentModel{ - Name: d.ModelName, - Format: d.ModelFormat, - Version: d.Version, - }, - Sku: project.DeploymentSku{ - Name: d.SkuName, - Capacity: d.SkuCapacity, - }, - }, nil + return nil, false, fmt.Errorf("failed to select existing deployment: %w", err) } - return nil, nil + selected := items[*selResp.Value] + d := selected.info + return &project.Deployment{ + Name: d.Name, + Model: project.DeploymentModel{ + Name: d.ModelName, + Format: d.ModelFormat, + Version: d.Version, + }, + Sku: project.DeploymentSku{ + Name: d.SkuName, + Capacity: d.SkuCapacity, + }, + }, true, nil } // updateAzureYamlDeployments writes the filtered deployment list back to the