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 cded804053b..e4fbf460ae2 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -14,24 +14,21 @@ import ( "net/url" "os" "path/filepath" - "regexp" "strings" "time" "azureaiagent/internal/exterrors" "azureaiagent/internal/pkg/agents/agent_yaml" "azureaiagent/internal/pkg/agents/registry_api" - "azureaiagent/internal/pkg/azure" "azureaiagent/internal/project" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" - "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices/v2" "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" "github.com/spf13/cobra" @@ -103,6 +100,80 @@ func checkAiModelServiceAvailable(ctx context.Context, azdClient *azdext.AzdClie return nil } +// runInitFromManifest sets up Azure context, credentials, console, and runs the +// InitAction for a given manifest pointer. This is the shared code path used when +// initializing from a manifest URL/path (the -m flag, agent template, or azd template +// that contains an agent manifest). +func runInitFromManifest( + ctx context.Context, + flags *initFlags, + azdClient *azdext.AzdClient, + httpClient *http.Client, +) error { + // Ensure project and environment exist (no subscription/location prompting yet) + projectConfig, err := ensureProject(ctx, flags, azdClient) + if err != nil { + return err + } + + // Get or create environment + env := getExistingEnvironment(ctx, flags, azdClient) + if env == nil { + fmt.Println("Lets create a new default azd environment for your project.") + env, err = createNewEnvironment(ctx, azdClient, flags.env) + if err != nil { + return err + } + } + + // Load whatever Azure context values already exist in the environment + azureContext, err := loadAzureContext(ctx, azdClient, env.Name) + if err != nil { + return err + } + + // Create credential with whatever tenant is available (may be empty → default tenant) + credential, err := azidentity.NewAzureDeveloperCLICredential( + &azidentity.AzureDeveloperCLICredentialOptions{ + TenantID: azureContext.Scope.TenantId, + AdditionallyAllowedTenants: []string{"*"}, + }, + ) + if err != nil { + return exterrors.Auth( + exterrors.CodeCredentialCreationFailed, + fmt.Sprintf("failed to create Azure credential: %s", err), + "run 'azd auth login' to authenticate", + ) + } + + console := input.NewConsole( + false, // noPrompt + true, // isTerminal + input.Writers{Output: os.Stdout}, + input.ConsoleHandles{ + Stderr: os.Stderr, + Stdin: os.Stdin, + Stdout: os.Stdout, + }, + nil, // formatter + nil, // externalPromptCfg + ) + + action := &InitAction{ + azdClient: azdClient, + azureContext: azureContext, + console: console, + credential: credential, + projectConfig: projectConfig, + environment: env, + flags: flags, + httpClient: httpClient, + } + + return action.Run(ctx) +} + func newInitCommand(rootFlags *rootFlagsDefinition) *cobra.Command { flags := &initFlags{ rootFlagsDefinition: rootFlags, @@ -142,70 +213,125 @@ func newInitCommand(rootFlags *rootFlagsDefinition) *cobra.Command { } if flags.manifestPointer != "" { - azureContext, projectConfig, environment, err := ensureAzureContext(ctx, flags, azdClient) - if err != nil { + if err := runInitFromManifest(ctx, flags, azdClient, httpClient); err != nil { if exterrors.IsCancellation(err) { return exterrors.Cancelled("initialization was cancelled") } return err } - - credential, err := azidentity.NewAzureDeveloperCLICredential(&azidentity.AzureDeveloperCLICredentialOptions{ - TenantID: azureContext.Scope.TenantId, - AdditionallyAllowedTenants: []string{"*"}, - }) + } else { + // No manifest provided - prompt user for init mode + initMode, err := promptInitMode(ctx, azdClient) if err != nil { - return exterrors.Auth( - exterrors.CodeCredentialCreationFailed, - fmt.Sprintf("failed to create Azure credential: %s", err), - "run 'azd auth login' to authenticate", - ) - } - - console := input.NewConsole( - false, // noPrompt - true, // isTerminal - input.Writers{Output: os.Stdout}, - input.ConsoleHandles{ - Stderr: os.Stderr, - Stdin: os.Stdin, - Stdout: os.Stdout, - }, - nil, // formatter - nil, // externalPromptCfg - ) - - action := &InitAction{ - azdClient: azdClient, - // azureClient: azure.NewAzureClient(credential), - azureContext: azureContext, - // composedResources: getComposedResourcesResponse.Resources, - console: console, - credential: credential, - projectConfig: projectConfig, - environment: environment, - flags: flags, - httpClient: httpClient, - } - - if err := action.Run(ctx); err != nil { if exterrors.IsCancellation(err) { return exterrors.Cancelled("initialization was cancelled") } return err } - } else { - action := &InitFromCodeAction{ - azdClient: azdClient, - flags: flags, - httpClient: httpClient, - } - if err := action.Run(ctx); err != nil { - if exterrors.IsCancellation(err) { - return exterrors.Cancelled("initialization was cancelled") + switch initMode { + case initModeTemplate: + // User chose to start from a template - select one + selectedTemplate, err := promptAgentTemplate(ctx, azdClient, httpClient, flags.NoPrompt) + if err != nil { + if exterrors.IsCancellation(err) { + return exterrors.Cancelled("initialization was cancelled") + } + return err + } + + switch selectedTemplate.EffectiveType() { + case TemplateTypeAzd: + // Full azd template - dispatch azd init -t + initArgs := []string{"init", "-t", selectedTemplate.Source} + if flags.env != "" { + initArgs = append(initArgs, "--environment", flags.env) + } else { + cwd, err := os.Getwd() + if err == nil { + sanitizedDirectoryName := sanitizeAgentName(filepath.Base(cwd)) + initArgs = append( + initArgs, "--environment", sanitizedDirectoryName+"-dev", + ) + } + } + + workflow := &azdext.Workflow{ + Name: "init", + Steps: []*azdext.WorkflowStep{ + {Command: &azdext.WorkflowCommand{Args: initArgs}}, + }, + } + + _, err := azdClient.Workflow().Run(ctx, &azdext.RunWorkflowRequest{ + Workflow: workflow, + }) + if err != nil { + if exterrors.IsCancellation(err) { + return exterrors.Cancelled("initialization was cancelled") + } + return exterrors.Dependency( + exterrors.CodeProjectInitFailed, + fmt.Sprintf( + "failed to initialize project from template: %s", err, + ), + "", + ) + } + + fmt.Printf( + "\nProject initialized from template: %s\n", + selectedTemplate.Title, + ) + + // Search for an agent manifest in the scaffolded project + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("getting current directory: %w", err) + } + + manifestPath, err := findAgentManifest(cwd) + if err != nil { + return fmt.Errorf("searching for agent manifest: %w", err) + } + + if manifestPath != "" { + flags.manifestPointer = manifestPath + if err := runInitFromManifest(ctx, flags, azdClient, httpClient); err != nil { + if exterrors.IsCancellation(err) { + return exterrors.Cancelled("initialization was cancelled") + } + return err + } + } else { + fmt.Println("No agent manifest found in the scaffolded project.") + } + + default: + // Agent manifest template - use existing -m flow + flags.manifestPointer = selectedTemplate.Source + if err := runInitFromManifest(ctx, flags, azdClient, httpClient); err != nil { + if exterrors.IsCancellation(err) { + return exterrors.Cancelled("initialization was cancelled") + } + return err + } + } + + default: + // initModeFromCode - use existing code in current directory + action := &InitFromCodeAction{ + azdClient: azdClient, + flags: flags, + httpClient: httpClient, + } + + if err := action.Run(ctx); err != nil { + if exterrors.IsCancellation(err) { + return exterrors.Cancelled("initialization was cancelled") + } + return err } - return err } } @@ -254,19 +380,6 @@ func (a *InitAction) Run(ctx context.Context) error { // If --manifest is given if a.flags.manifestPointer != "" { - // If --project-id is given - if a.flags.projectResourceId != "" { - // projectResourceId is a string of the format - // /subscriptions/[AZURE_SUBSCRIPTION]/resourceGroups/[AZURE_RESOURCE_GROUP]/providers/Microsoft.CognitiveServices/accounts/[AI_ACCOUNT_NAME]/projects/[AI_PROJECT_NAME] - // extract each of those fields from the string, issue an error if it doesn't match the format - fmt.Println("Setting up your azd environment to use the provided Microsoft Foundry project resource ID...") - if err := a.parseAndSetProjectResourceId(ctx); err != nil { - return fmt.Errorf("failed to parse project resource ID: %w", err) - } - - color.Green("\nYour azd environment has been initialized to use your existing Microsoft Foundry project.") - } - // Validate that the manifest pointer is either a valid URL or existing file path isValidURL := false isValidFile := false @@ -293,12 +406,23 @@ func (a *InitAction) Run(ctx context.Context) error { ) } - // Download/read agent.yaml file from the provided URI or file path and save it to project's "agents" directory + // Download/read agent.yaml file from the provided URI or file path agentManifest, targetDir, err := a.downloadAgentYaml(ctx, a.flags.manifestPointer, a.flags.src) if err != nil { return fmt.Errorf("downloading agent.yaml: %w", err) } + // Model configuration: prompt user for "use existing" vs "deploy new" + agentManifest, err = a.configureModelChoice(ctx, agentManifest) + if err != nil { + return fmt.Errorf("configuring model choice: %w", err) + } + + // Write the final agent.yaml to disk (after deployment names have been injected) + if err := writeAgentDefinitionFile(targetDir, agentManifest); err != nil { + return fmt.Errorf("writing agent definition: %w", err) + } + // Add the agent to the azd project (azure.yaml) services if err := a.addToProject(ctx, targetDir, agentManifest, a.flags.host); err != nil { return fmt.Errorf("failed to add agent to azure.yaml: %w", err) @@ -391,540 +515,141 @@ func getExistingEnvironment(ctx context.Context, flags *initFlags, azdClient *az return env } -func ensureEnvironment(ctx context.Context, flags *initFlags, azdClient *azdext.AzdClient) (*azdext.Environment, error) { - var foundryProject *FoundryProject - var foundryProjectLocation string - - if flags.projectResourceId != "" { - var err error - foundryProject, err = extractProjectDetails(flags.projectResourceId) - if err != nil { - return nil, exterrors.Validation( - exterrors.CodeInvalidProjectResourceId, - fmt.Sprintf("failed to parse Microsoft Foundry project ID: %s", err), - "provide a valid project resource ID in the format /subscriptions/.../providers/Microsoft.CognitiveServices/accounts/.../projects/...", - ) - } - - // Get the tenant ID - tenantResponse, err := azdClient.Account().LookupTenant(ctx, &azdext.LookupTenantRequest{ - SubscriptionId: foundryProject.SubscriptionId, - }) - if err != nil { - return nil, exterrors.Auth( - exterrors.CodeTenantLookupFailed, - fmt.Sprintf("failed to get tenant ID for subscription %s: %s", foundryProject.SubscriptionId, err), - "verify your Azure login with 'azd auth login'", - ) - } - - credential, err := azidentity.NewAzureDeveloperCLICredential(&azidentity.AzureDeveloperCLICredentialOptions{ - TenantID: tenantResponse.TenantId, - AdditionallyAllowedTenants: []string{"*"}, - }) - if err != nil { - return nil, exterrors.Auth( - exterrors.CodeCredentialCreationFailed, - fmt.Sprintf("failed to create Azure credential: %s", err), - "run 'azd auth login' to authenticate", - ) - } - - // Create Cognitive Services Projects client - projectsClient, err := armcognitiveservices.NewProjectsClient(foundryProject.SubscriptionId, credential, azure.NewArmClientOptions()) - if err != nil { - return nil, exterrors.Internal(exterrors.CodeCognitiveServicesClientFailed, fmt.Sprintf("failed to create Cognitive Services Projects client: %s", err)) - } - - // Get the Microsoft Foundry project - projectResp, err := projectsClient.Get(ctx, foundryProject.ResourceGroupName, foundryProject.AiAccountName, foundryProject.AiProjectName, nil) - if err != nil { - return nil, exterrors.ServiceFromAzure(err, exterrors.OpGetFoundryProject) - } - - foundryProjectLocation = *projectResp.Location +// manifestHasModelResources returns true if the manifest contains any model resources +// that need deployment configuration. Prompt agents always have a model. Hosted agents +// only need model config if they have resources with kind "model". +func manifestHasModelResources(manifest *agent_yaml.AgentManifest) bool { + if _, ok := manifest.Template.(agent_yaml.PromptAgent); ok { + return true } - // Get specified or current environment if it exists - existingEnv := getExistingEnvironment(ctx, flags, azdClient) - if existingEnv == nil { - // Dispatch `azd env new` to create a new environment with interactive flow - fmt.Println("Lets create a new default azd environment for your project.") - - envArgs := []string{"env", "new"} - if flags.env != "" { - envArgs = append(envArgs, flags.env) - } - - if flags.projectResourceId != "" { - envArgs = append(envArgs, "--subscription", foundryProject.SubscriptionId) - envArgs = append(envArgs, "--location", foundryProjectLocation) - } - - // Dispatch a workflow to create a new environment - // Handles both interactive and no-prompt flows - workflow := &azdext.Workflow{ - Name: "env new", - Steps: []*azdext.WorkflowStep{ - {Command: &azdext.WorkflowCommand{Args: envArgs}}, - }, - } - - _, err := azdClient.Workflow().Run(ctx, &azdext.RunWorkflowRequest{ - Workflow: workflow, - }) - if err != nil { - if exterrors.IsCancellation(err) { - return nil, exterrors.Cancelled("environment creation was cancelled") - } - return nil, exterrors.Dependency( - exterrors.CodeEnvironmentCreationFailed, - fmt.Sprintf("failed to create new azd environment: %s", err), - "run 'azd env new' manually to create an environment", - ) - } - - // Re-fetch the environment after creation - existingEnv = getExistingEnvironment(ctx, flags, azdClient) - if existingEnv == nil { - return nil, exterrors.Dependency( - exterrors.CodeEnvironmentNotFound, - "azd environment not found after creation", - "run 'azd env new' to create an environment and try again", - ) - } - } else if flags.projectResourceId != "" { - currentSubscription, err := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ - EnvName: existingEnv.Name, - Key: "AZURE_SUBSCRIPTION_ID", - }) - if err != nil { - return nil, fmt.Errorf("failed to get current AZURE_SUBSCRIPTION_ID from azd environment: %w", err) - } - - if currentSubscription.Value == "" { - // Set the subscription ID in the environment - _, err = azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ - EnvName: existingEnv.Name, - Key: "AZURE_SUBSCRIPTION_ID", - Value: foundryProject.SubscriptionId, - }) - if err != nil { - return nil, fmt.Errorf("failed to set AZURE_SUBSCRIPTION_ID in azd environment: %w", err) + if manifest.Resources != nil { + for _, resource := range manifest.Resources { + if _, ok := resource.(agent_yaml.ModelResource); ok { + return true } - } else if currentSubscription.Value != foundryProject.SubscriptionId { - return nil, exterrors.Validation( - exterrors.CodeSubscriptionMismatch, - fmt.Sprintf("subscription ID mismatch: environment has %s but project uses %s", currentSubscription.Value, foundryProject.SubscriptionId), - "update or recreate your environment with 'azd env new'", - ) } + } - // Resolve and set the tenant ID for the subscription so credentials are scoped correctly - currentTenant, err := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ - EnvName: existingEnv.Name, - Key: "AZURE_TENANT_ID", - }) - if err != nil { - return nil, fmt.Errorf("failed to get current AZURE_TENANT_ID from azd environment: %w", err) - } + return false +} - tenantResp, err := azdClient.Account().LookupTenant(ctx, &azdext.LookupTenantRequest{ - SubscriptionId: foundryProject.SubscriptionId, - }) +// configureModelChoice presents the "use existing / deploy new" model configuration choice +// and establishes the necessary Azure context (subscription, location, project) before +// ProcessModels is called. This defers subscription/location prompting until we know +// which path the user wants. +func (a *InitAction) configureModelChoice( + ctx context.Context, agentManifest *agent_yaml.AgentManifest, +) (*agent_yaml.AgentManifest, error) { + // If --project-id is provided, validate the ARM format and extract the subscription ID + // so ensureSubscription can skip the prompt and just resolve the tenant + if a.flags.projectResourceId != "" { + projectDetails, err := extractProjectDetails(a.flags.projectResourceId) if err != nil { - return nil, exterrors.Auth( - exterrors.CodeTenantLookupFailed, - fmt.Sprintf("failed to lookup tenant for subscription %s: %s", foundryProject.SubscriptionId, err), - "verify your Azure login with 'azd auth login'", - ) - } - - if currentTenant.Value == "" { - _, err = azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ - EnvName: existingEnv.Name, - Key: "AZURE_TENANT_ID", - Value: tenantResp.TenantId, - }) - if err != nil { - return nil, fmt.Errorf("failed to set AZURE_TENANT_ID in azd environment: %w", err) - } - } else if currentTenant.Value != tenantResp.TenantId { return nil, exterrors.Validation( - exterrors.CodeTenantMismatch, - fmt.Sprintf("tenant ID mismatch: environment has %s but project uses %s", currentTenant.Value, tenantResp.TenantId), - "update or recreate your environment with 'azd env new'", - ) - } - - // Get current location from environment - currentLocation, err := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ - EnvName: existingEnv.Name, - Key: "AZURE_LOCATION", - }) - if err != nil { - return nil, fmt.Errorf("failed to get AZURE_LOCATION from azd environment: %w", err) - } - - if currentLocation.Value == "" { - // Set the location in the environment - _, err = azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ - EnvName: existingEnv.Name, - Key: "AZURE_LOCATION", - Value: foundryProjectLocation, - }) - if err != nil { - return nil, fmt.Errorf("failed to set AZURE_LOCATION in environment: %w", err) - } - } else if currentLocation.Value != foundryProjectLocation { - return nil, exterrors.Validation( - exterrors.CodeLocationMismatch, - fmt.Sprintf("location mismatch: environment has %s but project uses %s", currentLocation.Value, foundryProjectLocation), - "update or recreate your environment with 'azd env new'", + 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/", ) } + a.azureContext.Scope.SubscriptionId = projectDetails.SubscriptionId } - return existingEnv, nil -} - -func ensureAzureContext( - ctx context.Context, - flags *initFlags, - azdClient *azdext.AzdClient, -) (*azdext.AzureContext, *azdext.ProjectConfig, *azdext.Environment, error) { - project, err := ensureProject(ctx, flags, azdClient) - if err != nil { - return nil, nil, nil, err - } - - env, err := ensureEnvironment(ctx, flags, azdClient) - if err != nil { - return nil, nil, nil, err - } - - envValues, err := azdClient.Environment().GetValues(ctx, &azdext.GetEnvironmentRequest{ - Name: env.Name, - }) - if err != nil { - return nil, nil, nil, exterrors.Dependency( - exterrors.CodeEnvironmentValuesFailed, - fmt.Sprintf("failed to get environment values: %s", err), - "run 'azd env get-values' to verify environment state", + // If the manifest has no model resources, skip the model configuration prompt + // but still ensure subscription and location are set for agent creation + if !manifestHasModelResources(agentManifest) { + 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 + return agentManifest, nil } - envValueMap := make(map[string]string) - for _, value := range envValues.KeyValues { - envValueMap[value.Key] = value.Value - } - - azureContext := &azdext.AzureContext{ - Scope: &azdext.AzureScope{ - TenantId: envValueMap["AZURE_TENANT_ID"], - SubscriptionId: envValueMap["AZURE_SUBSCRIPTION_ID"], - Location: envValueMap["AZURE_LOCATION"], - }, - Resources: []string{}, + modelConfigChoices := []*azdext.SelectChoice{ + {Label: "Deploy new model(s) from the catalog", Value: "new"}, + {Label: "Use existing model deployment(s) from a Foundry project", Value: "existing"}, } - if azureContext.Scope.SubscriptionId == "" { - fmt.Print() - fmt.Println("We need to connect to your Azure subscription. This will be the subscription which contains your ") - fmt.Println("Foundry project and where your resources will be provisioned.") + var modelConfigChoice string - subscriptionResponse, err := azdClient.Prompt().PromptSubscription(ctx, &azdext.PromptSubscriptionRequest{}) + if a.flags.projectResourceId != "" { + // --project-id provided: auto-select "existing" path + modelConfigChoice = "existing" + } else { + defaultIndex := int32(0) + modelConfigResp, err := a.azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: "How would you like to configure model(s) for your agent?", + Choices: modelConfigChoices, + SelectedIndex: &defaultIndex, + }, + }) if err != nil { if exterrors.IsCancellation(err) { - return nil, nil, nil, exterrors.Cancelled("subscription selection was cancelled") + return nil, exterrors.Cancelled("model configuration choice was cancelled") } - return nil, nil, nil, exterrors.FromPrompt(err, "failed to prompt for subscription") + return nil, fmt.Errorf("failed to prompt for model configuration choice: %w", err) } + modelConfigChoice = modelConfigChoices[*modelConfigResp.Value].Value + } - azureContext.Scope.SubscriptionId = subscriptionResponse.Subscription.Id - azureContext.Scope.TenantId = subscriptionResponse.Subscription.UserTenantId - - // Set the subscription ID in the environment - _, err = azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ - EnvName: env.Name, - Key: "AZURE_TENANT_ID", - Value: azureContext.Scope.TenantId, - }) + switch modelConfigChoice { + case "existing": + // Ensure subscription for project listing + newCred, err := ensureSubscription( + ctx, a.azdClient, a.azureContext, a.environment.Name, + "Select an Azure subscription to look up available models and provision your Foundry project resources.", + ) if err != nil { - return nil, nil, nil, fmt.Errorf("failed to set AZURE_TENANT_ID in environment: %w", err) + return nil, err } + a.credential = newCred - // Set the tenant ID in the environment - _, err = azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ - EnvName: env.Name, - Key: "AZURE_SUBSCRIPTION_ID", - Value: azureContext.Scope.SubscriptionId, - }) + // Select a Foundry project (sets AZURE_AI_PROJECT_ID, ACR, AppInsights env vars) + selectedProject, err := selectFoundryProject( + ctx, a.azdClient, a.credential, a.azureContext, a.environment.Name, + a.azureContext.Scope.SubscriptionId, a.flags.projectResourceId, + ) if err != nil { - return nil, nil, nil, fmt.Errorf("failed to set AZURE_SUBSCRIPTION_ID in environment: %w", err) + return nil, err } - } - if azureContext.Scope.Location == "" { - fmt.Println() - fmt.Println( - "Next, we need to select a default Azure location that will be used as the target for your resources.", - ) - - locationResponse, err := azdClient.Prompt().PromptLocation(ctx, &azdext.PromptLocationRequest{ - AzureContext: azureContext, - }) - if err != nil { - if exterrors.IsCancellation(err) { - return nil, nil, nil, exterrors.Cancelled("location selection was cancelled") + if selectedProject == nil { + // No existing project selected (no projects found or user chose "Create new") → fall back to "deploy new" path + _, _ = color.New(color.Faint).Println( + "No existing Foundry project was selected. Falling back to deploying a new model.", + ) + if err := ensureLocation(ctx, a.azdClient, a.azureContext, a.environment.Name); err != nil { + return nil, err } - return nil, nil, nil, exterrors.FromPrompt(err, "failed to prompt for location") } - azureContext.Scope.Location = locationResponse.Location.Name - - // Set the location in the environment - _, err = azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ - EnvName: env.Name, - Key: "AZURE_LOCATION", - Value: azureContext.Scope.Location, - }) + case "new": + // Ensure subscription + location for model catalog + newCred, err := ensureSubscriptionAndLocation( + ctx, a.azdClient, a.azureContext, a.environment.Name, + "Select an Azure subscription to look up available models and provision your Foundry project resources.", + ) if err != nil { - return nil, nil, nil, fmt.Errorf("failed to set AZURE_LOCATION in environment: %w", err) + return nil, err } + a.credential = newCred } - return azureContext, project, env, nil -} - -type FoundryProject struct { - SubscriptionId string `json:"subscriptionId"` - ResourceGroupName string `json:"resourceGroupName"` - AiAccountName string `json:"aiAccountName"` - AiProjectName string `json:"aiProjectName"` -} - -func extractProjectDetails(projectResourceId string) (*FoundryProject, error) { - /// Define the regex pattern for the project resource ID - pattern := `^/subscriptions/([^/]+)/resourceGroups/([^/]+)/providers/Microsoft\.CognitiveServices/accounts/([^/]+)/projects/([^/]+)$` - - regex, err := regexp.Compile(pattern) - if err != nil { - return nil, fmt.Errorf("failed to compile regex pattern: %w", err) - } - - matches := regex.FindStringSubmatch(projectResourceId) - if matches == nil || len(matches) != 5 { - return nil, fmt.Errorf( - "the given Microsoft Foundry project ID does not match expected format: " + - "/subscriptions/[SUBSCRIPTION_ID]/resourceGroups/[RESOURCE_GROUP]/providers/" + - "Microsoft.CognitiveServices/accounts/[ACCOUNT_NAME]/projects/[PROJECT_NAME]", - ) - } - - // Extract the components - return &FoundryProject{ - SubscriptionId: matches[1], - ResourceGroupName: matches[2], - AiAccountName: matches[3], - AiProjectName: matches[4], - }, nil -} - -func (a *InitAction) parseAndSetProjectResourceId(ctx context.Context) error { - foundryProject, err := extractProjectDetails(a.flags.projectResourceId) - if err != nil { - return fmt.Errorf("extracting project details: %w", err) - } - - if err := a.setEnvVar(ctx, "AZURE_AI_PROJECT_ID", a.flags.projectResourceId); err != nil { - return err - } - - // Set the extracted values as environment variables - if err := a.setEnvVar(ctx, "AZURE_RESOURCE_GROUP", foundryProject.ResourceGroupName); err != nil { - return err - } - - if err := a.setEnvVar(ctx, "AZURE_AI_ACCOUNT_NAME", foundryProject.AiAccountName); err != nil { - return err - } - - if err := a.setEnvVar(ctx, "AZURE_AI_PROJECT_NAME", foundryProject.AiProjectName); err != nil { - return err - } - - // Set the Microsoft Foundry endpoint URL - aiFoundryEndpoint := fmt.Sprintf("https://%s.services.ai.azure.com/api/projects/%s", foundryProject.AiAccountName, foundryProject.AiProjectName) - if err := a.setEnvVar(ctx, "AZURE_AI_PROJECT_ENDPOINT", aiFoundryEndpoint); err != nil { - return err - } - - aoaiEndpoint := fmt.Sprintf("https://%s.openai.azure.com/", foundryProject.AiAccountName) - if err := a.setEnvVar(ctx, "AZURE_OPENAI_ENDPOINT", aoaiEndpoint); err != nil { - return err - } - - // Create FoundryProjectsClient and get connections - foundryClient, err := azure.NewFoundryProjectsClient(foundryProject.AiAccountName, foundryProject.AiProjectName, a.credential) - if err != nil { - return fmt.Errorf("creating Foundry client: %w", err) - } - connections, err := foundryClient.GetAllConnections(ctx) + // Now process models — getModelDeploymentDetails will branch based on AZURE_AI_PROJECT_ID + agentManifest, deploymentDetails, err := a.ProcessModels(ctx, agentManifest) if err != nil { - fmt.Printf("Could not get Microsoft Foundry project connections to initialize AZURE_CONTAINER_REGISTRY_ENDPOINT: %v. Please set this environment variable manually.\n", err) - } else { - // Filter connections by ContainerRegistry type - var acrConnections []azure.Connection - var appInsightsConnections []azure.Connection - for _, conn := range connections { - switch conn.Type { - case azure.ConnectionTypeContainerRegistry: - acrConnections = append(acrConnections, conn) - case azure.ConnectionTypeAppInsights: - connWithCreds, err := foundryClient.GetConnectionWithCredentials(ctx, conn.Name) - if err != nil { - fmt.Printf("Could not get full details for Application Insights connection '%s': %v\n", conn.Name, err) - continue - } - if connWithCreds != nil { - conn = *connWithCreds - } - - appInsightsConnections = append(appInsightsConnections, conn) - } - } - - if len(acrConnections) == 0 { - fmt.Println(output.WithWarningFormat( - "Agent deployment prerequisites not satisfied. To deploy this agent, you will need to " + - "provision an Azure Container Registry (ACR) and grant the required permissions. " + - "You can either do this manually before deployment, or use an infrastructure template. " + - "See aka.ms/azdaiagent/docs for details.")) - - resp, err := a.azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ - Options: &azdext.PromptOptions{ - Message: "If you have an ACR that you want to use with this agent, enter the azurecr.io endpoint for the ACR. " + - "If you plan to provision one through the `azd provision` or `azd up` flow, leave blank.", - IgnoreHintKeys: true, - }, - }) - if err != nil { - return fmt.Errorf("prompting for ACR endpoint: %w", err) - } - - if resp.Value != "" { - if err := a.setEnvVar(ctx, "AZURE_CONTAINER_REGISTRY_ENDPOINT", resp.Value); err != nil { - return err - } - } - } else { - var selectedConnection *azure.Connection - - if len(acrConnections) == 1 { - selectedConnection = &acrConnections[0] - - fmt.Printf("Using container registry connection: %s (%s)\n", selectedConnection.Name, selectedConnection.Target) - } else { - // Multiple connections found, prompt user to select - fmt.Printf("Found %d container registry connections:\n", len(acrConnections)) - - choices := make([]*azdext.SelectChoice, len(acrConnections)) - for i, conn := range acrConnections { - choices[i] = &azdext.SelectChoice{ - Label: conn.Name, - Value: fmt.Sprintf("%d", i), - } - } - - defaultIndex := int32(0) - selectResp, err := a.azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ - Options: &azdext.SelectOptions{ - Message: "Select a container registry connection to use for this agent", - Choices: choices, - SelectedIndex: &defaultIndex, - }, - }) - if err != nil { - fmt.Printf("failed to prompt for connection selection: %v\n", err) - } else { - selectedConnection = &acrConnections[int(*selectResp.Value)] - } - } - - if err := a.setEnvVar(ctx, "AZURE_CONTAINER_REGISTRY_ENDPOINT", selectedConnection.Target); err != nil { - return err - } - } - - // Handle App Insights connections - if len(appInsightsConnections) == 0 { - fmt.Println(output.WithWarningFormat( - "No Application Insights connection found. To enable telemetry for this agent, you will need to " + - "provision an Application Insights resource and grant the required permissions. " + - "You can either do this manually before deployment, or use an infrastructure template. " + - "See aka.ms/azdaiagent/docs for details.")) - - resp, err := a.azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ - Options: &azdext.PromptOptions{ - Message: "If you have an Application Insights resource that you want to use with this agent, enter the connection string. " + - "If you plan to provision one through the `azd provision` or `azd up` flow, leave blank.", - IgnoreHintKeys: true, - }, - }) - if err != nil { - return fmt.Errorf("prompting for Application Insights connection string: %w", err) - } - - if resp.Value != "" { - if err := a.setEnvVar(ctx, "APPLICATIONINSIGHTS_CONNECTION_STRING", resp.Value); err != nil { - return err - } - } - } else { - var selectedConnection *azure.Connection - - if len(appInsightsConnections) == 1 { - selectedConnection = &appInsightsConnections[0] - - fmt.Printf("Using Application Insights connection: %s (%s)\n", selectedConnection.Name, selectedConnection.Target) - } else { - // Multiple connections found, prompt user to select - fmt.Printf("Found %d Application Insights connections:\n", len(appInsightsConnections)) - - choices := make([]*azdext.SelectChoice, len(appInsightsConnections)) - for i, conn := range appInsightsConnections { - choices[i] = &azdext.SelectChoice{ - Label: conn.Name, - Value: fmt.Sprintf("%d", i), - } - } - - defaultIndex := int32(0) - selectResp, err := a.azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ - Options: &azdext.SelectOptions{ - Message: "Select an Application Insights connection to use for this agent", - Choices: choices, - SelectedIndex: &defaultIndex, - }, - }) - if err != nil { - fmt.Printf("failed to prompt for connection selection: %v\n", err) - } else { - selectedConnection = &appInsightsConnections[int(*selectResp.Value)] - } - } - - if selectedConnection != nil && selectedConnection.Credentials.Key != "" { - if err := a.setEnvVar(ctx, "APPLICATIONINSIGHTS_CONNECTION_STRING", selectedConnection.Credentials.Key); err != nil { - return err - } - } - } + return nil, fmt.Errorf("failed to process model resources: %w", err) } + a.deploymentDetails = deploymentDetails - fmt.Printf("Successfully parsed and set environment variables from Microsoft Foundry project ID\n") - return nil + return agentManifest, nil } func (a *InitAction) isLocalFilePath(path string) bool { @@ -1275,13 +1000,6 @@ func (a *InitAction) downloadAgentYaml( return nil, "", fmt.Errorf("failed to process manifest parameters: %w", err) } - agentManifest, deploymentDetails, err := a.ProcessModels(ctx, agentManifest) - if err != nil { - return nil, "", fmt.Errorf("failed to process model resources: %w", err) - } - - a.deploymentDetails = deploymentDetails - _, isPromptAgent := agentManifest.Template.(agent_yaml.PromptAgent) if isPromptAgent { agentManifest, err = agent_yaml.ProcessPromptAgentToolsConnections(ctx, agentManifest, a.azdClient) @@ -1338,27 +1056,31 @@ func (a *InitAction) downloadAgentYaml( } } - content, err = yaml.Marshal(agentManifest.Template) + return agentManifest, targetDir, nil +} + +// writeAgentDefinitionFile writes the agent definition to disk as agent.yaml in targetDir. +// This should be called after all parameter/deployment injection is complete so the on-disk +// file has fully resolved values (no `{{...}}` placeholders). +func writeAgentDefinitionFile(targetDir string, agentManifest *agent_yaml.AgentManifest) error { + content, err := yaml.Marshal(agentManifest.Template) if err != nil { - return nil, "", fmt.Errorf("marshaling agent manifest to YAML after parameter processing: %w", err) + return fmt.Errorf("marshaling agent manifest to YAML: %w", err) } annotation := "# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml" agentFileContents := bytes.NewBufferString(annotation + "\n\n") - _, err = agentFileContents.Write(content) - if err != nil { - return nil, "", fmt.Errorf("preparing new project file contents: %w", err) + if _, err = agentFileContents.Write(content); err != nil { + return fmt.Errorf("preparing agent.yaml file contents: %w", err) } - // Save the file to the target directory filePath := filepath.Join(targetDir, "agent.yaml") if err := os.WriteFile(filePath, agentFileContents.Bytes(), osutil.PermissionFile); err != nil { - return nil, "", fmt.Errorf("saving file to %s: %w", filePath, err) + return fmt.Errorf("saving file to %s: %w", filePath, err) } fmt.Printf("Processed agent.yaml at %s\n", filePath) - - return agentManifest, targetDir, nil + return nil } func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentManifest *agent_yaml.AgentManifest, host string) error { @@ -1475,11 +1197,14 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa } fmt.Printf("\nAdded your agent as a service entry named '%s' under the file azure.yaml.\n", agentDef.Name) - fmt.Printf("To provision and deploy the whole solution, use %s.\n", color.HiBlueString("azd up")) - fmt.Printf( - "If you already have your project provisioned with hosted agents requirements, "+ - "you can directly use %s.\n", - color.HiBlueString("azd deploy %s", agentDef.Name)) + if projectID, _ := a.azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ + EnvName: a.environment.Name, + Key: "AZURE_AI_PROJECT_ID", + }); projectID != nil && projectID.Value != "" { + fmt.Printf("To deploy your agent, use %s.\n", color.HiBlueString("azd deploy %s", agentDef.Name)) + } else { + fmt.Printf("To provision and deploy the whole solution, use %s.\n", color.HiBlueString("azd up")) + } return nil } @@ -1697,7 +1422,7 @@ func downloadDirectoryContents( if itemType == "file" { // Download file - fmt.Printf("Downloading file: %s\n", itemPath) + fmt.Printf("%s\n", color.New(color.Faint).Sprintf("Downloading file: %s", itemPath)) fileApiPath := fmt.Sprintf("/repos/%s/contents/%s", repoSlug, itemPath) if branch != "" { fileApiPath += fmt.Sprintf("?ref=%s", branch) @@ -1786,7 +1511,7 @@ func downloadDirectoryContentsWithoutGhCli( if itemType == "file" { // Download file using GitHub Contents API with raw accept header - fmt.Printf("Downloading file: %s\n", itemPath) + fmt.Printf("%s\n", color.New(color.Faint).Sprintf("Downloading file: %s", itemPath)) fileURL := &url.URL{ Scheme: "https", Host: "api.github.com", @@ -1841,17 +1566,3 @@ func downloadDirectoryContentsWithoutGhCli( return nil } - -func (a *InitAction) setEnvVar(ctx context.Context, key, value string) error { - _, err := a.azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ - EnvName: a.environment.Name, - Key: key, - Value: value, - }) - if err != nil { - return fmt.Errorf("failed to set environment variable %s=%s: %w", key, value, err) - } - - fmt.Printf("Set environment variable: %s=%s\n", key, value) - return nil -} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go new file mode 100644 index 00000000000..00110c1ce23 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go @@ -0,0 +1,1075 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/azure" + "context" + "fmt" + "regexp" + "slices" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + armcognitiveservices "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices/v2" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerregistry/armcontainerregistry" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/ux" +) + +// FoundryProjectInfo holds information about a discovered or parsed Foundry project. +// This is the unified type used by both init flows. +type FoundryProjectInfo struct { + SubscriptionId string + ResourceGroupName string + AccountName string + ProjectName string + Location string // may be empty when parsed from resource ID alone + ResourceId string // full ARM resource ID +} + +// FoundryDeploymentInfo holds information about an existing model deployment in a Foundry project. +type FoundryDeploymentInfo struct { + Name string + ModelName string + ModelFormat string + Version string + SkuName string + SkuCapacity int +} + +// setEnvValue sets a single environment variable in the azd environment. +func setEnvValue(ctx context.Context, azdClient *azdext.AzdClient, envName, key, value string) error { + _, err := azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ + EnvName: envName, + Key: key, + Value: value, + }) + if err != nil { + return fmt.Errorf("failed to set environment variable %s: %w", key, err) + } + + return nil +} + +// extractProjectDetails parses an ARM resource ID into a FoundryProjectInfo. +func extractProjectDetails(projectResourceId string) (*FoundryProjectInfo, error) { + pattern := `^/subscriptions/([^/]+)/resourceGroups/([^/]+)/providers/Microsoft\.CognitiveServices/accounts/([^/]+)/projects/([^/]+)$` + + regex, err := regexp.Compile(pattern) + if err != nil { + return nil, fmt.Errorf("failed to compile regex pattern: %w", err) + } + + matches := regex.FindStringSubmatch(projectResourceId) + if matches == nil || len(matches) != 5 { + return nil, fmt.Errorf( + "the given Microsoft Foundry project ID does not match expected format: " + + "/subscriptions/[SUBSCRIPTION_ID]/resourceGroups/[RESOURCE_GROUP]/providers/" + + "Microsoft.CognitiveServices/accounts/[ACCOUNT_NAME]/projects/[PROJECT_NAME]", + ) + } + + return &FoundryProjectInfo{ + SubscriptionId: matches[1], + ResourceGroupName: matches[2], + AccountName: matches[3], + ProjectName: matches[4], + ResourceId: projectResourceId, + }, nil +} + +// extractSubscriptionId extracts the subscription ID from an Azure resource ID. +func extractSubscriptionId(resourceId string) string { + parts := strings.Split(resourceId, "/") + for i, part := range parts { + if strings.EqualFold(part, "subscriptions") && i+1 < len(parts) { + return parts[i+1] + } + } + return "" +} + +// extractResourceGroup extracts the resource group name from an Azure resource ID. +func extractResourceGroup(resourceId string) string { + parts := strings.Split(resourceId, "/") + for i, part := range parts { + if strings.EqualFold(part, "resourceGroups") && i+1 < len(parts) { + return parts[i+1] + } + } + return "" +} + +// listFoundryProjects enumerates all Foundry projects in a subscription by listing +// CognitiveServices accounts and their projects. +func listFoundryProjects( + ctx context.Context, + credential azcore.TokenCredential, + subscriptionId string, +) ([]FoundryProjectInfo, error) { + accountsClient, err := armcognitiveservices.NewAccountsClient(subscriptionId, credential, azure.NewArmClientOptions()) + if err != nil { + return nil, fmt.Errorf("failed to create accounts client: %w", err) + } + + projectsClient, err := armcognitiveservices.NewProjectsClient(subscriptionId, credential, azure.NewArmClientOptions()) + if err != nil { + return nil, fmt.Errorf("failed to create projects client: %w", err) + } + + var results []FoundryProjectInfo + + accountPager := accountsClient.NewListPager(nil) + for accountPager.More() { + page, err := accountPager.NextPage(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list accounts: %w", err) + } + + for _, account := range page.Value { + if account.Kind == nil { + continue + } + kind := strings.ToLower(*account.Kind) + if kind != "aiservices" && kind != "openai" { + continue + } + + accountId := "" + if account.ID != nil { + accountId = *account.ID + } + rgName := extractResourceGroup(accountId) + if rgName == "" { + continue + } + accountName := "" + if account.Name != nil { + accountName = *account.Name + } + accountLocation := "" + if account.Location != nil { + accountLocation = *account.Location + } + + projectPager := projectsClient.NewListPager(rgName, accountName, nil) + for projectPager.More() { + projectPage, err := projectPager.NextPage(ctx) + if err != nil { + // Skip accounts we can't list projects for (permissions, etc.) + break + } + for _, proj := range projectPage.Value { + projName := "" + if proj.Name != nil { + fullName := *proj.Name + if idx := strings.LastIndex(fullName, "/"); idx != -1 { + projName = fullName[idx+1:] + } else { + projName = fullName + } + } + projLocation := accountLocation + if proj.Location != nil { + projLocation = *proj.Location + } + resourceId := fmt.Sprintf( + "/subscriptions/%s/resourceGroups/%s/providers/Microsoft.CognitiveServices/accounts/%s/projects/%s", + subscriptionId, rgName, accountName, projName) + + results = append(results, FoundryProjectInfo{ + SubscriptionId: subscriptionId, + ResourceGroupName: rgName, + AccountName: accountName, + ProjectName: projName, + Location: projLocation, + ResourceId: resourceId, + }) + } + } + } + } + + return results, nil +} + +// listProjectDeployments lists all model deployments in a Foundry account. +func listProjectDeployments( + ctx context.Context, + credential azcore.TokenCredential, + subscriptionId, resourceGroup, accountName string, +) ([]FoundryDeploymentInfo, error) { + deploymentsClient, err := armcognitiveservices.NewDeploymentsClient(subscriptionId, credential, azure.NewArmClientOptions()) + if err != nil { + return nil, fmt.Errorf("failed to create deployments client: %w", err) + } + + pager := deploymentsClient.NewListPager(resourceGroup, accountName, nil) + var results []FoundryDeploymentInfo + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list deployments: %w", err) + } + for _, deployment := range page.Value { + info := FoundryDeploymentInfo{} + if deployment.Name != nil { + info.Name = *deployment.Name + } + if deployment.Properties != nil && deployment.Properties.Model != nil { + m := deployment.Properties.Model + if m.Name != nil { + info.ModelName = *m.Name + } + if m.Format != nil { + info.ModelFormat = *m.Format + } + if m.Version != nil { + info.Version = *m.Version + } + } + if deployment.SKU != nil { + if deployment.SKU.Name != nil { + info.SkuName = *deployment.SKU.Name + } + if deployment.SKU.Capacity != nil { + info.SkuCapacity = int(*deployment.SKU.Capacity) + } + } + results = append(results, info) + } + } + return results, nil +} + +// lookupAcrResourceId finds the ARM resource ID for an ACR given its login server endpoint. +func lookupAcrResourceId( + ctx context.Context, + credential azcore.TokenCredential, + subscriptionId string, + loginServer string, +) (string, error) { + parts := strings.Split(loginServer, ".") + if len(parts) < 2 || parts[0] == "" { + return "", fmt.Errorf("invalid login server format: %q, expected e.g. %q", loginServer, "registry.azurecr.io") + } + registryName := parts[0] + + client, err := armcontainerregistry.NewRegistriesClient(subscriptionId, credential, azure.NewArmClientOptions()) + if err != nil { + return "", fmt.Errorf("failed to create container registry client: %w", err) + } + + pager := client.NewListPager(nil) + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return "", fmt.Errorf("failed to list registries: %w", err) + } + for _, registry := range page.Value { + if registry.Name != nil && strings.EqualFold(*registry.Name, registryName) { + if registry.ID != nil { + return *registry.ID, nil + } + } + } + } + + return "", fmt.Errorf("container registry '%s' not found in subscription", registryName) +} + +// configureFoundryProjectEnv sets all Foundry project environment variables and discovers +// ACR and AppInsights connections. This is the shared implementation used by both init flows. +func configureFoundryProjectEnv( + ctx context.Context, + azdClient *azdext.AzdClient, + credential azcore.TokenCredential, + envName string, + project FoundryProjectInfo, + subscriptionId string, +) error { + resourceId := project.ResourceId + if resourceId == "" { + resourceId = fmt.Sprintf( + "/subscriptions/%s/resourceGroups/%s/providers/Microsoft.CognitiveServices/accounts/%s/projects/%s", + project.SubscriptionId, project.ResourceGroupName, project.AccountName, project.ProjectName) + } + + if err := setEnvValue(ctx, azdClient, envName, "AZURE_AI_PROJECT_ID", resourceId); err != nil { + return err + } + + if err := setEnvValue(ctx, azdClient, envName, "AZURE_RESOURCE_GROUP", project.ResourceGroupName); err != nil { + return err + } + + if err := setEnvValue(ctx, azdClient, envName, "AZURE_AI_ACCOUNT_NAME", project.AccountName); err != nil { + return err + } + + if err := setEnvValue(ctx, azdClient, envName, "AZURE_AI_PROJECT_NAME", project.ProjectName); err != nil { + return err + } + + aiFoundryEndpoint := fmt.Sprintf("https://%s.services.ai.azure.com/api/projects/%s", project.AccountName, project.ProjectName) + if err := setEnvValue(ctx, azdClient, envName, "AZURE_AI_PROJECT_ENDPOINT", aiFoundryEndpoint); err != nil { + return err + } + + aoaiEndpoint := fmt.Sprintf("https://%s.openai.azure.com/", project.AccountName) + if err := setEnvValue(ctx, azdClient, envName, "AZURE_OPENAI_ENDPOINT", aoaiEndpoint); err != nil { + return err + } + + // Discover and configure connections (ACR, AppInsights) + foundryClient, err := azure.NewFoundryProjectsClient(project.AccountName, project.ProjectName, credential) + if err != nil { + return fmt.Errorf("creating Foundry client: %w", err) + } + connections, err := foundryClient.GetAllConnections(ctx) + if err != nil { + fmt.Printf("Could not get Microsoft Foundry project connections: %v. Please set connection environment variables manually.\n", err) + return nil + } + + var acrConnections []azure.Connection + var appInsightsConnections []azure.Connection + for _, conn := range connections { + switch conn.Type { + case azure.ConnectionTypeContainerRegistry: + acrConnections = append(acrConnections, conn) + case azure.ConnectionTypeAppInsights: + connWithCreds, err := foundryClient.GetConnectionWithCredentials(ctx, conn.Name) + if err != nil { + fmt.Printf("Could not get full details for Application Insights connection '%s': %v\n", conn.Name, err) + continue + } + if connWithCreds != nil { + conn = *connWithCreds + } + appInsightsConnections = append(appInsightsConnections, conn) + } + } + + if err := configureAcrConnection(ctx, azdClient, credential, envName, subscriptionId, acrConnections); err != nil { + return err + } + + if err := configureAppInsightsConnection(ctx, azdClient, envName, appInsightsConnections); err != nil { + return err + } + + return nil +} + +// configureAcrConnection handles ACR connection selection and env var setting. +func configureAcrConnection( + ctx context.Context, + azdClient *azdext.AzdClient, + credential azcore.TokenCredential, + envName string, + subscriptionId string, + acrConnections []azure.Connection, +) error { + if len(acrConnections) == 0 { + fmt.Println("\n" + + "An Azure Container Registry (ACR) is required\n\n" + + "Foundry Hosted Agents need an Azure Container Registry to store container images before deployment.\n\n" + + "You can:\n" + + " • Use an existing ACR\n" + + " • Or create a new one from the template during 'azd up'\n\n" + + "Learn more: aka.ms/azdaiagent/docs") + + resp, err := azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ + Options: &azdext.PromptOptions{ + Message: "Enter your ACR login server (e.g., myregistry.azurecr.io), or leave blank to create a new one", + IgnoreHintKeys: true, + }, + }) + if err != nil { + return fmt.Errorf("prompting for ACR endpoint: %w", err) + } + + if resp.Value != "" { + resourceId, err := lookupAcrResourceId(ctx, credential, subscriptionId, resp.Value) + if err != nil { + return fmt.Errorf("failed to lookup ACR resource ID: %w", err) + } + + if err := setEnvValue(ctx, azdClient, envName, "AZURE_CONTAINER_REGISTRY_ENDPOINT", resp.Value); err != nil { + return err + } + if err := setEnvValue(ctx, azdClient, envName, "AZURE_CONTAINER_REGISTRY_RESOURCE_ID", resourceId); err != nil { + return err + } + } + return nil + } + + var selectedConnection *azure.Connection + + if len(acrConnections) == 1 { + selectedConnection = &acrConnections[0] + fmt.Printf("Using container registry connection: %s (%s)\n", selectedConnection.Name, selectedConnection.Target) + } else { + fmt.Printf("Found %d container registry connections:\n", len(acrConnections)) + + choices := make([]*azdext.SelectChoice, len(acrConnections)) + for i, conn := range acrConnections { + choices[i] = &azdext.SelectChoice{ + Label: conn.Name, + Value: fmt.Sprintf("%d", i), + } + } + + defaultIndex := int32(0) + selectResp, err := azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: "Select a container registry connection to use for this agent", + Choices: choices, + SelectedIndex: &defaultIndex, + }, + }) + if err != nil { + return fmt.Errorf("failed to prompt for connection selection: %w", err) + } + selectedConnection = &acrConnections[int(*selectResp.Value)] + } + + if err := setEnvValue(ctx, azdClient, envName, "AZURE_AI_PROJECT_ACR_CONNECTION_NAME", selectedConnection.Name); err != nil { + return err + } + if err := setEnvValue(ctx, azdClient, envName, "AZURE_CONTAINER_REGISTRY_ENDPOINT", selectedConnection.Target); err != nil { + return err + } + + return nil +} + +// configureAppInsightsConnection handles AppInsights connection selection and env var setting. +func configureAppInsightsConnection( + ctx context.Context, + azdClient *azdext.AzdClient, + envName string, + appInsightsConnections []azure.Connection, +) error { + if len(appInsightsConnections) == 0 { + fmt.Println("\n" + + "Application Insights (optional)\n\n" + + "Enable telemetry to collect logs, traces, and diagnostics for this agent.\n\n" + + "You can:\n" + + " • Use an existing Application Insights resource\n" + + " • Or create a new one during 'azd up'\n\n" + + "Docs: aka.ms/azdaiagent/docs") + + resourceIdResp, err := azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ + Options: &azdext.PromptOptions{ + Message: "Enter your Application Insights resource ID, or leave blank to create a new one", + IgnoreHintKeys: true, + }, + }) + if err != nil { + return fmt.Errorf("prompting for Application Insights resource ID: %w", err) + } + + if resourceIdResp.Value != "" { + if err := setEnvValue(ctx, azdClient, envName, "APPLICATIONINSIGHTS_RESOURCE_ID", resourceIdResp.Value); err != nil { + return err + } + + connStrResp, err := azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ + Options: &azdext.PromptOptions{ + Message: "Enter your Application Insights connection string", + IgnoreHintKeys: true, + }, + }) + if err != nil { + return fmt.Errorf("prompting for Application Insights connection string: %w", err) + } + + if connStrResp.Value != "" { + if err := setEnvValue(ctx, azdClient, envName, "APPLICATIONINSIGHTS_CONNECTION_STRING", connStrResp.Value); err != nil { + return err + } + } + } + return nil + } + + var selectedConnection *azure.Connection + + if len(appInsightsConnections) == 1 { + selectedConnection = &appInsightsConnections[0] + fmt.Printf("Using Application Insights connection: %s (%s)\n", selectedConnection.Name, selectedConnection.Target) + } else { + fmt.Printf("Found %d Application Insights connections:\n", len(appInsightsConnections)) + + choices := make([]*azdext.SelectChoice, len(appInsightsConnections)) + for i, conn := range appInsightsConnections { + choices[i] = &azdext.SelectChoice{ + Label: conn.Name, + Value: fmt.Sprintf("%d", i), + } + } + + defaultIndex := int32(0) + selectResp, err := azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: "Select an Application Insights connection to use for this agent", + Choices: choices, + SelectedIndex: &defaultIndex, + }, + }) + if err != nil { + return fmt.Errorf("failed to prompt for connection selection: %w", err) + } + selectedConnection = &appInsightsConnections[int(*selectResp.Value)] + } + + if selectedConnection != nil && selectedConnection.Credentials.Key != "" { + if err := setEnvValue(ctx, azdClient, envName, "APPLICATIONINSIGHTS_CONNECTION_STRING", selectedConnection.Credentials.Key); err != nil { + return err + } + } + + return nil +} + +// --- Shared project/environment/context setup helpers --- + +// createNewEnvironment creates a new azd environment with the given name via the azd workflow, +// then fetches and returns it. Both init flows use this same mechanism. +func createNewEnvironment( + ctx context.Context, + azdClient *azdext.AzdClient, + envName string, +) (*azdext.Environment, error) { + envArgs := []string{"env", "new"} + if envName != "" { + envArgs = append(envArgs, envName) + } + + workflow := &azdext.Workflow{ + Name: "env new", + Steps: []*azdext.WorkflowStep{ + {Command: &azdext.WorkflowCommand{Args: envArgs}}, + }, + } + + _, err := azdClient.Workflow().Run(ctx, &azdext.RunWorkflowRequest{ + Workflow: workflow, + }) + if err != nil { + if exterrors.IsCancellation(err) { + return nil, exterrors.Cancelled("environment creation was cancelled") + } + return nil, exterrors.Dependency( + exterrors.CodeEnvironmentCreationFailed, + fmt.Sprintf("failed to create new azd environment: %s", err), + "run 'azd env new' manually to create an environment", + ) + } + + // Re-fetch the environment after creation + env := getExistingEnvironment(ctx, &initFlags{env: envName}, azdClient) + if env == nil { + return nil, exterrors.Dependency( + exterrors.CodeEnvironmentNotFound, + "azd environment not found after creation", + "run 'azd env new' to create an environment and try again", + ) + } + + return env, nil +} + +// loadAzureContext reads the current Azure context values (tenant, subscription, location) +// from the azd environment and returns a populated AzureContext. Missing values are left empty. +func loadAzureContext( + ctx context.Context, + azdClient *azdext.AzdClient, + envName string, +) (*azdext.AzureContext, error) { + envValues, err := azdClient.Environment().GetValues(ctx, &azdext.GetEnvironmentRequest{ + Name: envName, + }) + if err != nil { + return nil, exterrors.Dependency( + exterrors.CodeEnvironmentValuesFailed, + fmt.Sprintf("failed to get environment values: %s", err), + "run 'azd env get-values' to verify environment state", + ) + } + + envValueMap := make(map[string]string) + for _, value := range envValues.KeyValues { + envValueMap[value.Key] = value.Value + } + + return &azdext.AzureContext{ + Scope: &azdext.AzureScope{ + TenantId: envValueMap["AZURE_TENANT_ID"], + SubscriptionId: envValueMap["AZURE_SUBSCRIPTION_ID"], + Location: envValueMap["AZURE_LOCATION"], + }, + Resources: []string{}, + }, nil +} + +// --- Shared subscription/location helpers --- + +// ensureSubscription prompts for a subscription if not already set in the AzureContext. +// If a subscription is already set, looks up the tenant for it. Returns the (possibly refreshed) +// credential scoped to the resolved tenant. Both init flows use this. +func ensureSubscription( + ctx context.Context, + azdClient *azdext.AzdClient, + azureContext *azdext.AzureContext, + envName string, + promptMessage string, +) (azcore.TokenCredential, error) { + if azureContext.Scope.SubscriptionId == "" { + fmt.Println(promptMessage) + + subscriptionResponse, err := azdClient.Prompt().PromptSubscription(ctx, &azdext.PromptSubscriptionRequest{}) + if err != nil { + if exterrors.IsCancellation(err) { + return nil, exterrors.Cancelled("subscription selection was cancelled") + } + return nil, exterrors.FromPrompt(err, "failed to prompt for subscription") + } + + azureContext.Scope.SubscriptionId = subscriptionResponse.Subscription.Id + azureContext.Scope.TenantId = subscriptionResponse.Subscription.UserTenantId + } else { + tenantResponse, err := azdClient.Account().LookupTenant(ctx, &azdext.LookupTenantRequest{ + SubscriptionId: azureContext.Scope.SubscriptionId, + }) + if err != nil { + return nil, exterrors.Auth( + exterrors.CodeTenantLookupFailed, + fmt.Sprintf("failed to lookup tenant for subscription %s: %s", azureContext.Scope.SubscriptionId, err), + "verify your Azure login with 'azd auth login'", + ) + } + azureContext.Scope.TenantId = tenantResponse.TenantId + } + + // Persist to environment + if err := setEnvValue(ctx, azdClient, envName, "AZURE_SUBSCRIPTION_ID", azureContext.Scope.SubscriptionId); err != nil { + return nil, err + } + if err := setEnvValue(ctx, azdClient, envName, "AZURE_TENANT_ID", azureContext.Scope.TenantId); err != nil { + return nil, err + } + + // Refresh credential with the resolved tenant + newCredential, err := azidentity.NewAzureDeveloperCLICredential(&azidentity.AzureDeveloperCLICredentialOptions{ + TenantID: azureContext.Scope.TenantId, + AdditionallyAllowedTenants: []string{"*"}, + }) + if err != nil { + return nil, exterrors.Auth( + exterrors.CodeCredentialCreationFailed, + fmt.Sprintf("failed to create Azure credential: %s", err), + "run 'azd auth login' to authenticate", + ) + } + + return newCredential, nil +} + +// ensureLocation prompts for an Azure location if not already set in the AzureContext. +// Both init flows use this. +func ensureLocation( + ctx context.Context, + azdClient *azdext.AzdClient, + azureContext *azdext.AzureContext, + envName string, +) error { + if azureContext.Scope.Location != "" { + return nil + } + + fmt.Println("Select an Azure location. This determines which models are available and where your Foundry project resources will be deployed.") + + locationResponse, err := azdClient.Prompt().PromptLocation(ctx, &azdext.PromptLocationRequest{ + AzureContext: azureContext, + }) + if err != nil { + if exterrors.IsCancellation(err) { + return exterrors.Cancelled("location selection was cancelled") + } + return exterrors.FromPrompt(err, "failed to prompt for location") + } + + azureContext.Scope.Location = locationResponse.Location.Name + + return setEnvValue(ctx, azdClient, envName, "AZURE_LOCATION", azureContext.Scope.Location) +} + +// ensureSubscriptionAndLocation ensures both subscription and location are set. +// Returns the (possibly refreshed) credential. +func ensureSubscriptionAndLocation( + ctx context.Context, + azdClient *azdext.AzdClient, + azureContext *azdext.AzureContext, + envName string, + subscriptionMessage string, +) (azcore.TokenCredential, error) { + newCredential, err := ensureSubscription(ctx, azdClient, azureContext, envName, subscriptionMessage) + if err != nil { + return nil, err + } + + if err := ensureLocation(ctx, azdClient, azureContext, envName); err != nil { + return nil, err + } + + return newCredential, nil +} + +// --- Shared model helpers --- + +// selectNewModel prompts the user to select a model from the AI catalog, filtered by location. +// Both init flows use this for the "deploy new model" path. +func selectNewModel( + ctx context.Context, + azdClient *azdext.AzdClient, + azureContext *azdext.AzureContext, + modelFlag string, +) (*azdext.AiModel, error) { + defaultModel := "gpt-4.1-mini" + if modelFlag != "" { + defaultModel = modelFlag + } + + promptReq := &azdext.PromptAiModelRequest{ + AzureContext: azureContext, + SelectOptions: &azdext.SelectOptions{ + Message: "Select a model", + }, + Quota: &azdext.QuotaCheckOptions{ + MinRemainingCapacity: 1, + }, + Filter: &azdext.AiModelFilterOptions{ + Locations: []string{azureContext.Scope.Location}, + }, + DefaultValue: defaultModel, + } + + modelResp, err := azdClient.Prompt().PromptAiModel(ctx, promptReq) + if err != nil { + return nil, exterrors.FromPrompt(err, "failed to prompt for model selection") + } + + return modelResp.Model, nil +} + +// resolveModelDeployment resolves a model deployment without prompting, selecting the best +// candidate based on default versions, SKU priority, and available quota. Both init flows +// use this for the "deploy new model" path in non-interactive mode. +func resolveModelDeployment( + ctx context.Context, + azdClient *azdext.AzdClient, + azureContext *azdext.AzureContext, + model *azdext.AiModel, + location string, +) (*azdext.AiModelDeployment, error) { + resolveResp, err := azdClient.Ai().ResolveModelDeployments(ctx, &azdext.ResolveModelDeploymentsRequest{ + AzureContext: azureContext, + ModelName: model.Name, + Options: &azdext.AiModelDeploymentOptions{ + Locations: []string{location}, + }, + Quota: &azdext.QuotaCheckOptions{ + MinRemainingCapacity: 1, + }, + }) + if err != nil { + return nil, exterrors.FromAiService(err, exterrors.CodeModelResolutionFailed) + } + + if len(resolveResp.Deployments) == 0 { + return nil, exterrors.Dependency( + exterrors.CodeModelResolutionFailed, + fmt.Sprintf("no deployment candidates found for model '%s' in location '%s'", model.Name, location), + "", + ) + } + + orderedCandidates := make([]*azdext.AiModelDeployment, len(resolveResp.Deployments)) + copy(orderedCandidates, resolveResp.Deployments) + + defaultVersions := make(map[string]struct{}, len(model.Versions)) + for _, version := range model.Versions { + if version.IsDefault { + defaultVersions[version.Version] = struct{}{} + } + } + + sortModelDeploymentCandidates(orderedCandidates, defaultVersions) + + for _, candidate := range orderedCandidates { + capacity, ok := resolveNoPromptCapacity(candidate) + if !ok { + continue + } + + return cloneDeploymentWithCapacity(candidate, capacity), nil + } + + return nil, fmt.Errorf("no deployment candidates found for model '%s' with a valid non-interactive capacity", model.Name) +} + +// sortModelDeploymentCandidates sorts deployment candidates by preference: +// default versions first, then by SKU priority, version, SKU name, usage name. +func sortModelDeploymentCandidates(candidates []*azdext.AiModelDeployment, defaultVersions map[string]struct{}) { + for i := range len(candidates) { + for j := i + 1; j < len(candidates); j++ { + a, b := candidates[i], candidates[j] + _, aDefault := defaultVersions[a.Version] + _, bDefault := defaultVersions[b.Version] + + swap := false + if aDefault != bDefault { + swap = !aDefault + } else if skuPriority(a.Sku.Name) != skuPriority(b.Sku.Name) { + swap = skuPriority(a.Sku.Name) > skuPriority(b.Sku.Name) + } else if a.Version != b.Version { + swap = a.Version > b.Version + } else if a.Sku.Name != b.Sku.Name { + swap = a.Sku.Name > b.Sku.Name + } else { + swap = a.Sku.UsageName > b.Sku.UsageName + } + + if swap { + candidates[i], candidates[j] = candidates[j], candidates[i] + } + } + } +} + +// --- Shared "select Foundry project" flow --- + +// selectFoundryProject lists Foundry projects in the subscription and prompts +// the user to select one. If projectResourceId is provided (from --project-id flag), +// finds the matching project without prompting. Returns nil if user chose +// "Create a new Foundry project" or no projects exist. +// When a project is selected, configures all project-related environment variables. +func selectFoundryProject( + ctx context.Context, + azdClient *azdext.AzdClient, + credential azcore.TokenCredential, + azureContext *azdext.AzureContext, + envName string, + subscriptionId string, + projectResourceId string, +) (*FoundryProjectInfo, error) { + spinnerText := "Searching for Foundry projects in your subscription..." + if projectResourceId != "" { + spinnerText = "Getting details on the provided Foundry project..." + } + + spinner := ux.NewSpinner(&ux.SpinnerOptions{ + Text: spinnerText, + ClearOnStop: true, + }) + if err := spinner.Start(ctx); err != nil { + return nil, fmt.Errorf("failed to start spinner: %w", err) + } + + projects, err := listFoundryProjects(ctx, credential, subscriptionId) + if stopErr := spinner.Stop(ctx); stopErr != nil { + return nil, stopErr + } + if err != nil { + return nil, fmt.Errorf("failed to list Foundry projects: %w", err) + } + + if len(projects) == 0 { + return nil, nil + } + + var selectedIdx int32 = -1 + + if projectResourceId != "" { + // Match from flag + for i, p := range projects { + if strings.EqualFold(p.ResourceId, projectResourceId) { + selectedIdx = int32(i) + break + } + } + if selectedIdx == -1 { + return nil, fmt.Errorf("provided project resource ID does not match any Foundry projects in the subscription") + } + } else { + // Sort projects alphabetically by account/project name for display + slices.SortFunc(projects, func(a, b FoundryProjectInfo) int { + labelA := fmt.Sprintf("%s / %s", a.AccountName, a.ProjectName) + labelB := fmt.Sprintf("%s / %s", b.AccountName, b.ProjectName) + return strings.Compare(labelA, labelB) + }) + + // Interactive prompt + projectChoices := make([]*azdext.SelectChoice, 0, len(projects)+1) + for i, p := range projects { + projectChoices = append(projectChoices, &azdext.SelectChoice{ + Label: fmt.Sprintf("%s / %s (%s)", p.AccountName, p.ProjectName, p.Location), + Value: fmt.Sprintf("%d", i), + }) + } + projectChoices = append(projectChoices, &azdext.SelectChoice{ + Label: "Create a new Foundry project", + Value: "__create_new__", + }) + + projectResp, err := azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: "Select a Foundry project", + Choices: projectChoices, + }, + }) + if err != nil { + if exterrors.IsCancellation(err) { + return nil, exterrors.Cancelled("project selection was cancelled") + } + return nil, fmt.Errorf("failed to prompt for project selection: %w", err) + } + + selectedIdx = *projectResp.Value + } + + if selectedIdx < 0 || int(selectedIdx) >= len(projects) { + // User chose "Create a new Foundry project" + return nil, nil + } + + selectedProject := projects[selectedIdx] + + // Set location from the selected project + azureContext.Scope.Location = selectedProject.Location + if err := setEnvValue(ctx, azdClient, envName, "AZURE_LOCATION", selectedProject.Location); err != nil { + return nil, fmt.Errorf("failed to set AZURE_LOCATION: %w", err) + } + + // Configure all Foundry project environment variables + if err := configureFoundryProjectEnv(ctx, azdClient, credential, envName, selectedProject, subscriptionId); err != nil { + return nil, fmt.Errorf("failed to configure Foundry project environment: %w", err) + } + + return &selectedProject, nil +} + +// --- Shared "select model deployment" flow --- + +// selectModelDeployment lists model deployments in a Foundry project and prompts +// the user to select one. If modelDeploymentFlag is provided, finds the matching +// deployment by name without prompting. modelFilter optionally restricts to deployments +// matching a specific model ID (from manifest). Returns nil if user chose +// "Create a new model deployment" or no deployments exist. +func selectModelDeployment( + ctx context.Context, + azdClient *azdext.AzdClient, + credential azcore.TokenCredential, + project FoundryProjectInfo, + modelDeploymentFlag string, + modelFilter string, +) (*FoundryDeploymentInfo, error) { + spinner := ux.NewSpinner(&ux.SpinnerOptions{ + Text: "Searching for model deployments in your Foundry Project...", + ClearOnStop: true, + }) + if err := spinner.Start(ctx); err != nil { + return nil, fmt.Errorf("failed to start spinner: %w", err) + } + + deployments, err := listProjectDeployments(ctx, credential, project.SubscriptionId, project.ResourceGroupName, project.AccountName) + if stopErr := spinner.Stop(ctx); stopErr != nil { + return nil, stopErr + } + if err != nil { + return nil, fmt.Errorf("failed to list deployments: %w", err) + } + + // Filter by model if specified + if modelFilter != "" { + filtered := make([]FoundryDeploymentInfo, 0) + for _, d := range deployments { + if strings.EqualFold(d.ModelName, modelFilter) { + filtered = append(filtered, d) + } + } + deployments = filtered + } + + if len(deployments) == 0 { + if modelFilter != "" { + fmt.Printf("No existing deployments found matching model '%s'.\n", modelFilter) + } else { + fmt.Println("No existing deployments found. You can create a new model deployment.") + } + return nil, nil + } + + if modelDeploymentFlag != "" { + // Flag provided: find matching deployment by name + for _, d := range deployments { + if strings.EqualFold(d.Name, modelDeploymentFlag) { + return &d, nil + } + } + return 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", + ) + } + + // Interactive prompt + deployChoices := make([]*azdext.SelectChoice, 0, len(deployments)+1) + for _, d := range deployments { + label := fmt.Sprintf("%s (%s v%s, %s)", d.Name, d.ModelName, d.Version, d.SkuName) + deployChoices = append(deployChoices, &azdext.SelectChoice{ + Label: label, + Value: d.Name, + }) + } + deployChoices = append(deployChoices, &azdext.SelectChoice{ + Label: "Create a new model deployment", + Value: "__create_new__", + }) + + deployResp, err := azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: "Select a model deployment", + Choices: deployChoices, + }, + }) + if err != nil { + if exterrors.IsCancellation(err) { + return nil, exterrors.Cancelled("model deployment selection was cancelled") + } + return nil, exterrors.Dependency( + exterrors.CodePromptFailed, + fmt.Sprintf("failed to prompt for deployment selection: %v", err), + "use --model-deployment to specify a deployment name in non-interactive mode", + ) + } + + deploymentIdx := *deployResp.Value + if deploymentIdx >= 0 && int(deploymentIdx) < len(deployments) { + d := deployments[deploymentIdx] + fmt.Printf("Model deployment name: %s\n", d.Name) + return &d, nil + } + + // User chose "Create a new model deployment" + return nil, nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers_test.go new file mode 100644 index 00000000000..3f10f875eeb --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers_test.go @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestExtractProjectDetails(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + resourceId string + wantSub string + wantRG string + wantAccount string + wantProject string + wantErr bool + }{ + { + name: "valid resource ID", + resourceId: "/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/my-rg/" + + "providers/Microsoft.CognitiveServices/accounts/my-account/projects/my-project", + wantSub: "00000000-0000-0000-0000-000000000001", + wantRG: "my-rg", + wantAccount: "my-account", + wantProject: "my-project", + }, + { + name: "resource ID with special characters in names", + resourceId: "/subscriptions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/resourceGroups/rg-with-dashes/" + + "providers/Microsoft.CognitiveServices/accounts/account_underscore/projects/proj.dots", + wantSub: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + wantRG: "rg-with-dashes", + wantAccount: "account_underscore", + wantProject: "proj.dots", + }, + { + name: "empty string", + resourceId: "", + wantErr: true, + }, + { + name: "malformed - missing projects segment", + resourceId: "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.CognitiveServices/accounts/acct1", + wantErr: true, + }, + { + name: "malformed - wrong provider", + resourceId: "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Storage/accounts/acct1/projects/proj1", + wantErr: true, + }, + { + name: "malformed - extra trailing segment", + resourceId: "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.CognitiveServices/accounts/acct1/projects/proj1/extra", + wantErr: true, + }, + { + name: "malformed - random string", + resourceId: "not-a-resource-id", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result, err := extractProjectDetails(tt.resourceId) + + if tt.wantErr { + require.Error(t, err) + require.Nil(t, result) + return + } + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, tt.wantSub, result.SubscriptionId) + require.Equal(t, tt.wantRG, result.ResourceGroupName) + require.Equal(t, tt.wantAccount, result.AccountName) + require.Equal(t, tt.wantProject, result.ProjectName) + require.Equal(t, tt.resourceId, result.ResourceId) + }) + } +} + +func TestFoundryProjectInfoResourceIdConstruction(t *testing.T) { + t.Parallel() + + // Verify round-trip: parse a resource ID then reconstruct it + originalId := "/subscriptions/aaaa/resourceGroups/rg-test/providers/Microsoft.CognitiveServices/accounts/acct-1/projects/proj-1" + + info, err := extractProjectDetails(originalId) + require.NoError(t, err) + + reconstructed := "/subscriptions/" + info.SubscriptionId + + "/resourceGroups/" + info.ResourceGroupName + + "/providers/Microsoft.CognitiveServices/accounts/" + info.AccountName + + "/projects/" + info.ProjectName + + require.Equal(t, originalId, reconstructed) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go index 5185bc23739..97f3c3089c8 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go @@ -6,7 +6,6 @@ package cmd import ( "azureaiagent/internal/exterrors" "azureaiagent/internal/pkg/agents/agent_yaml" - "azureaiagent/internal/pkg/azure" "azureaiagent/internal/project" "context" "encoding/json" @@ -21,9 +20,6 @@ import ( "strings" "github.com/Azure/azure-sdk-for-go/sdk/azcore" - "github.com/Azure/azure-sdk-for-go/sdk/azidentity" - "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices/v2" - "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerregistry/armcontainerregistry" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/azure/azure-dev/cli/azd/pkg/ux" "github.com/fatih/color" @@ -107,7 +103,16 @@ func (a *InitFromCodeAction) Run(ctx context.Context) error { } fmt.Println("\nYou can customize environment variables, cpu, memory, and replica settings in the agent.yaml.") - fmt.Printf("Next steps: Run %s to deploy your agent to Microsoft Foundry.\n", color.HiBlueString("azd up")) + if projectID, _ := a.azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ + EnvName: a.environment.Name, + Key: "AZURE_AI_PROJECT_ID", + }); projectID != nil && projectID.Value != "" { + fmt.Printf("Next steps: Run %s to deploy your agent to Microsoft Foundry.\n", + color.HiBlueString("azd deploy %s", localDefinition.Name)) + } else { + fmt.Printf("Next steps: Run %s to deploy your agent to Microsoft Foundry.\n", + color.HiBlueString("azd up")) + } } return nil @@ -410,7 +415,7 @@ func (a *InitFromCodeAction) createDefinitionFromLocalAgent(ctx context.Context) // Prompt user for agent name promptResp, err := a.azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ Options: &azdext.PromptOptions{ - Message: "Enter a name for your agent:", + Message: "Enter a name for your agent", DefaultValue: defaultName, }, }) @@ -424,9 +429,14 @@ func (a *InitFromCodeAction) createDefinitionFromLocalAgent(ctx context.Context) // Create the azd environment now that we have the agent name if a.environment == nil { - if err := a.createEnvironment(ctx, agentName+"-dev"); err != nil { + envName := sanitizeAgentName(agentName + "-dev") + env, err := createNewEnvironment(ctx, a.azdClient, envName) + if err != nil { return nil, fmt.Errorf("failed to create azd environment: %w", err) } + a.environment = env + a.flags.env = envName + fmt.Printf(" %s %s\n", color.GreenString("+"), color.GreenString(".azure/%s/.env", envName)) } // TODO: Prompt user for agent kind @@ -470,11 +480,16 @@ func (a *InitFromCodeAction) createDefinitionFromLocalAgent(ctx context.Context) case "new": // Path A: Deploy a new model from the catalog // Need subscription + location for model catalog - if err := a.ensureSubscriptionAndLocation(ctx); err != nil { + newCred, err := ensureSubscriptionAndLocation( + ctx, a.azdClient, a.azureContext, a.environment.Name, + "Select an Azure subscription to look up available models and provision your Foundry project resources.", + ) + if err != nil { return nil, err } + a.credential = newCred - selectedModel, err = a.selectNewModel(ctx) + selectedModel, err = selectNewModel(ctx, a.azdClient, a.azureContext, a.flags.model) if err != nil { return nil, fmt.Errorf("failed to select new model: %w", err) } @@ -482,193 +497,48 @@ func (a *InitFromCodeAction) createDefinitionFromLocalAgent(ctx context.Context) case "existing": // Path B: Select an existing model deployment from a Foundry project // Need subscription to enumerate projects - if err := a.ensureSubscription(ctx); err != nil { + newCred, err := ensureSubscription( + ctx, a.azdClient, a.azureContext, a.environment.Name, + "Select an Azure subscription to look up available models and provision your Foundry project resources.", + ) + if err != nil { return nil, err } + a.credential = newCred - spinnerText := "Searching for Foundry projects in your subscription..." - if a.flags.projectResourceId != "" { - spinnerText = "Getting details on the provided Foundry project..." - } - - spinner := ux.NewSpinner(&ux.SpinnerOptions{ - Text: spinnerText, - ClearOnStop: true, - }) - if err := spinner.Start(ctx); err != nil { - return nil, fmt.Errorf("failed to start spinner: %w", err) - } - - projects, err := a.listFoundryProjects(ctx, a.azureContext.Scope.SubscriptionId) - if stopErr := spinner.Stop(ctx); stopErr != nil { - return nil, stopErr - } + // Select a Foundry project + selectedProject, err := selectFoundryProject(ctx, a.azdClient, a.credential, a.azureContext, a.environment.Name, a.azureContext.Scope.SubscriptionId, a.flags.projectResourceId) if err != nil { - return nil, fmt.Errorf("failed to list Foundry projects: %w", err) + return nil, err } - if len(projects) == 0 { - fmt.Println("No Foundry projects found in your subscription. Falling back to deploying a new model.") - // Fall back to new model flow - if err := a.ensureSubscriptionAndLocation(ctx); err != nil { - return nil, err + if selectedProject == nil { + // No projects found or user chose "Create new" → fall back to new model + if a.azureContext.Scope.Location == "" { + if err := ensureLocation(ctx, a.azdClient, a.azureContext, a.environment.Name); err != nil { + return nil, err + } } - - selectedModel, err = a.selectNewModel(ctx) + selectedModel, err = selectNewModel(ctx, a.azdClient, a.azureContext, a.flags.model) if err != nil { return nil, fmt.Errorf("failed to select new model: %w", err) } } else { - var selectedIdx int32 - if a.flags.projectResourceId == "" { - // Let user pick a Foundry project - projectChoices := make([]*azdext.SelectChoice, len(projects)+1) - for i, p := range projects { - projectChoices[i] = &azdext.SelectChoice{ - Label: fmt.Sprintf("%s / %s (%s)", p.AccountName, p.ProjectName, p.Location), - Value: fmt.Sprintf("%d", i), - } - } - projectChoices = append(projectChoices, &azdext.SelectChoice{ - Label: "Create a new Foundry project", - Value: "__create_new__", - }) - - projectResp, err := a.azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ - Options: &azdext.SelectOptions{ - Message: "Select a Foundry project:", - Choices: projectChoices, - }, - }) - if err != nil { - return nil, fmt.Errorf("failed to prompt for project selection: %w", err) - } - - selectedIdx = *projectResp.Value - } else { - // If projectResourceId is provided, find the matching project and set selectedIdx accordingly - selectedIdx = -1 - for i, p := range projects { - if p.ResourceId == a.flags.projectResourceId { - selectedIdx = int32(i) - break - } - } - if selectedIdx == -1 { - return nil, fmt.Errorf("provided projectResourceId does not match any Foundry projects in the subscription") - } + // Select a deployment from the project + deployment, err := selectModelDeployment(ctx, a.azdClient, a.credential, *selectedProject, a.flags.modelDeployment, "") + if err != nil { + return nil, err } - if selectedIdx >= 0 && int(selectedIdx) < len(projects) { - // User selected an existing Foundry project - selectedProject := projects[selectedIdx] - - // Set the Foundry project context - a.azureContext.Scope.Location = selectedProject.Location - if err := a.setEnvVar(ctx, "AZURE_LOCATION", selectedProject.Location); err != nil { - return nil, fmt.Errorf("failed to set AZURE_LOCATION environment variable: %w", err) - } - - err := a.processExistingFoundryProject(ctx, selectedProject) - if err != nil { - return nil, fmt.Errorf("failed to set Foundry project context: %w", err) - } - - spinner := ux.NewSpinner(&ux.SpinnerOptions{ - Text: "Searching for model deployments in your Foundry Project...", - ClearOnStop: true, - }) - if err := spinner.Start(ctx); err != nil { - return nil, fmt.Errorf("failed to start spinner: %w", err) - } - - // List deployments in selected project - deployments, err := a.listProjectDeployments(ctx, selectedProject.SubscriptionId, selectedProject.ResourceGroupName, selectedProject.AccountName) - if stopErr := spinner.Stop(ctx); stopErr != nil { - return nil, stopErr - } - if err != nil { - return nil, fmt.Errorf("failed to list deployments: %w", err) - } - - if len(deployments) == 0 { - fmt.Println("No existing deployments found. You can create a new model deployment.") - } - - if a.flags.modelDeployment != "" { - // Flag provided: find the matching deployment by name - for _, d := range deployments { - if strings.EqualFold(d.Name, a.flags.modelDeployment) { - existingDeployment = &d - break - } - } - if existingDeployment == nil { - return nil, exterrors.Validation( - exterrors.CodeModelDeploymentNotFound, - fmt.Sprintf("model deployment %q not found in Foundry project", a.flags.modelDeployment), - "verify the deployment name or omit --model-deployment to select interactively", - ) - } - } else { - // No flag: prompt interactively - // Build choices: existing deployments + "Create a new model deployment" - deployChoices := make([]*azdext.SelectChoice, 0, len(deployments)+1) - for _, d := range deployments { - label := fmt.Sprintf("%s (%s v%s, %s)", d.Name, d.ModelName, d.Version, d.SkuName) - deployChoices = append(deployChoices, &azdext.SelectChoice{ - Label: label, - Value: d.Name, - }) - } - deployChoices = append(deployChoices, &azdext.SelectChoice{ - Label: "Create a new model deployment", - Value: "__create_new__", - }) - - deployResp, err := a.azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ - Options: &azdext.SelectOptions{ - Message: "Select a model deployment:", - Choices: deployChoices, - }, - }) - if err != nil { - if exterrors.IsCancellation(err) { - return nil, exterrors.Cancelled("model deployment selection was cancelled") - } - return nil, exterrors.Dependency( - exterrors.CodePromptFailed, - fmt.Sprintf("failed to prompt for deployment selection: %v", err), - "use --model-deployment to specify a deployment name in non-interactive mode", - ) - } - - deploymentIdx := *deployResp.Value - if deploymentIdx >= 0 && int(deploymentIdx) < len(deployments) { - // User selected an existing deployment - d := deployments[deploymentIdx] - existingDeployment = &d - fmt.Printf("Model deployment name: %s\n", d.Name) - } else { - // User wants to create a new deployment — region locked to the project's location - selectedModel, err = a.selectNewModel(ctx) - if err != nil { - return nil, fmt.Errorf("failed to select new model: %w", err) - } - } - } + if deployment != nil { + existingDeployment = deployment } else { - // User wants a new Foundry project - if err := a.ensureLocation(ctx); err != nil { - return nil, err - } - - selectedModel, err = a.selectNewModel(ctx) + // User wants to create a new deployment — region locked to the project's location + selectedModel, err = selectNewModel(ctx, a.azdClient, a.azureContext, a.flags.model) if err != nil { return nil, fmt.Errorf("failed to select new model: %w", err) } } - } case "skip": @@ -720,11 +590,11 @@ func (a *InitFromCodeAction) createDefinitionFromLocalAgent(ctx context.Context) Value: "${AZURE_AI_MODEL_DEPLOYMENT_NAME}", }) - if err := a.setEnvVar(ctx, "AZURE_AI_MODEL_DEPLOYMENT_NAME", existingDeployment.Name); err != nil { + if err := setEnvValue(ctx, a.azdClient, a.environment.Name, "AZURE_AI_MODEL_DEPLOYMENT_NAME", existingDeployment.Name); err != nil { return nil, fmt.Errorf("failed to set AZURE_AI_MODEL_DEPLOYMENT_NAME: %w", err) } } else if selectedModel != nil { - modelDetails, err := a.resolveModelDeploymentNoPrompt(ctx, selectedModel, a.azureContext.Scope.Location) + modelDetails, err := resolveModelDeployment(ctx, a.azdClient, a.azureContext, selectedModel, a.azureContext.Scope.Location) if err != nil { return nil, fmt.Errorf("failed to get model deployment details: %w", err) } @@ -747,7 +617,7 @@ func (a *InitFromCodeAction) createDefinitionFromLocalAgent(ctx context.Context) Value: "${AZURE_AI_MODEL_DEPLOYMENT_NAME}", }) - if err := a.setEnvVar(ctx, "AZURE_AI_MODEL_DEPLOYMENT_NAME", modelDetails.ModelName); err != nil { + if err := setEnvValue(ctx, a.azdClient, a.environment.Name, "AZURE_AI_MODEL_DEPLOYMENT_NAME", modelDetails.ModelName); err != nil { return nil, fmt.Errorf("failed to set AZURE_AI_MODEL_DEPLOYMENT_NAME: %w", err) } } @@ -779,179 +649,6 @@ func sanitizeAgentName(name string) string { return name } -// createEnvironment creates a new azd environment with the given name and sets -// it on the InitFromCodeAction so subsequent calls can use it. -func (a *InitFromCodeAction) createEnvironment(ctx context.Context, envName string) error { - envName = sanitizeAgentName(envName) - - workflow := &azdext.Workflow{ - Name: "env new", - Steps: []*azdext.WorkflowStep{ - {Command: &azdext.WorkflowCommand{Args: []string{"env", "new", envName}}}, - }, - } - - _, err := a.azdClient.Workflow().Run(ctx, &azdext.RunWorkflowRequest{ - Workflow: workflow, - }) - if err != nil { - return fmt.Errorf("failed to create environment %s: %w", envName, err) - } - - fmt.Printf(" %s %s\n", color.GreenString("+"), color.GreenString(".azure/%s/.env", envName)) - - a.flags.env = envName - env := getExistingEnvironment(ctx, a.flags, a.azdClient) - if env == nil { - return fmt.Errorf("environment %s was created but could not be found", envName) - } - - a.environment = env - return nil -} - -// ensureSubscriptionAndLocation prompts for subscription and location if not already set, -// with messaging that explains these are needed for model lookup and Foundry project resources. -func (a *InitFromCodeAction) ensureSubscriptionAndLocation(ctx context.Context) error { - if a.azureContext.Scope.SubscriptionId == "" { - err := a.ensureSubscription(ctx) - if err != nil { - return err - } - } - - if a.azureContext.Scope.Location == "" { - err := a.ensureLocation(ctx) - if err != nil { - return err - } - } - - return nil -} - -// ensureSubscription prompts for subscription only if not already set. -func (a *InitFromCodeAction) ensureSubscription(ctx context.Context) error { - if a.azureContext.Scope.SubscriptionId == "" { - fmt.Println("Select an Azure subscription to look up available models and provision your Foundry project resources.") - - subscriptionResponse, err := a.azdClient.Prompt().PromptSubscription(ctx, &azdext.PromptSubscriptionRequest{}) - if err != nil { - if exterrors.IsCancellation(err) { - return exterrors.Cancelled("subscription selection was cancelled") - } - return exterrors.FromPrompt(err, "failed to prompt for subscription") - } - - a.azureContext.Scope.SubscriptionId = subscriptionResponse.Subscription.Id - a.azureContext.Scope.TenantId = subscriptionResponse.Subscription.UserTenantId - } else { - tenantResponse, err := a.azdClient.Account().LookupTenant(ctx, &azdext.LookupTenantRequest{ - SubscriptionId: a.azureContext.Scope.SubscriptionId, - }) - if err != nil { - return exterrors.Auth( - exterrors.CodeTenantLookupFailed, - fmt.Sprintf("failed to lookup tenant for subscription %s: %s", a.azureContext.Scope.SubscriptionId, err), - "verify your Azure login with 'azd auth login'", - ) - } - a.azureContext.Scope.TenantId = tenantResponse.TenantId - } - - // Persist to environment - _, err := a.azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ - EnvName: a.environment.Name, - Key: "AZURE_SUBSCRIPTION_ID", - Value: a.azureContext.Scope.SubscriptionId, - }) - if err != nil { - return fmt.Errorf("failed to set AZURE_SUBSCRIPTION_ID in environment: %w", err) - } - - _, err = a.azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ - EnvName: a.environment.Name, - Key: "AZURE_TENANT_ID", - Value: a.azureContext.Scope.TenantId, - }) - if err != nil { - return fmt.Errorf("failed to set AZURE_TENANT_ID in environment: %w", err) - } - - // Refresh credential with the tenant - credential, err := azidentity.NewAzureDeveloperCLICredential(&azidentity.AzureDeveloperCLICredentialOptions{ - TenantID: a.azureContext.Scope.TenantId, - AdditionallyAllowedTenants: []string{"*"}, - }) - if err != nil { - return exterrors.Auth( - exterrors.CodeCredentialCreationFailed, - fmt.Sprintf("failed to create Azure credential: %s", err), - "run 'azd auth login' to authenticate", - ) - } - a.credential = credential - - return nil -} - -func (a *InitFromCodeAction) ensureLocation(ctx context.Context) error { - fmt.Println("Select an Azure location. This determines which models are available and where your Foundry project resources will be deployed.") - - locationResponse, err := a.azdClient.Prompt().PromptLocation(ctx, &azdext.PromptLocationRequest{ - AzureContext: a.azureContext, - }) - if err != nil { - if exterrors.IsCancellation(err) { - return exterrors.Cancelled("location selection was cancelled") - } - return exterrors.FromPrompt(err, "failed to prompt for location") - } - - a.azureContext.Scope.Location = locationResponse.Location.Name - - _, err = a.azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ - EnvName: a.environment.Name, - Key: "AZURE_LOCATION", - Value: a.azureContext.Scope.Location, - }) - if err != nil { - return fmt.Errorf("failed to set AZURE_LOCATION in environment: %w", err) - } - - return nil -} - -func (a *InitFromCodeAction) selectNewModel(ctx context.Context) (*azdext.AiModel, error) { - defaultModel := "gpt-4.1-mini" - if a.flags.model != "" { - defaultModel = a.flags.model - } - - promptReq := &azdext.PromptAiModelRequest{ - AzureContext: a.azureContext, - SelectOptions: &azdext.SelectOptions{ - Message: "Select a model", - }, - Quota: &azdext.QuotaCheckOptions{ - MinRemainingCapacity: 1, - }, - Filter: &azdext.AiModelFilterOptions{ - Locations: []string{a.azureContext.Scope.Location}, - }, - DefaultValue: defaultModel, - } - - modelResp, err := a.azdClient.Prompt().PromptAiModel(ctx, promptReq) - if err != nil { - return nil, exterrors.FromPrompt(err, "failed to prompt for model selection") - } - - selectedModel := modelResp.Model - - return selectedModel, nil -} - // normalizeForFuzzyMatch strips common separator characters (hyphens, dots, spaces, underscores) // and lowercases the string for fuzzy comparison. func normalizeForFuzzyMatch(s string) string { @@ -1011,233 +708,6 @@ func findDefaultModelIndex(modelNames []string) int32 { return 0 } -// FoundryProjectInfo holds information about a discovered Foundry project -type FoundryProjectInfo struct { - SubscriptionId string - ResourceGroupName string - AccountName string - ProjectName string - Location string - ResourceId string -} - -// extractSubscriptionId extracts the subscription ID from an Azure resource ID. -func extractSubscriptionId(resourceId string) string { - parts := strings.Split(resourceId, "/") - for i, part := range parts { - if strings.EqualFold(part, "subscriptions") && i+1 < len(parts) { - return parts[i+1] - } - } - return "" -} - -// extractResourceGroup extracts the resource group name from an Azure resource ID. -func extractResourceGroup(resourceId string) string { - parts := strings.Split(resourceId, "/") - for i, part := range parts { - if strings.EqualFold(part, "resourceGroups") && i+1 < len(parts) { - return parts[i+1] - } - } - return "" -} - -// listFoundryProjects enumerates all Foundry projects in a subscription by listing -// CognitiveServices accounts and their projects. -func (a *InitFromCodeAction) listFoundryProjects(ctx context.Context, subscriptionId string) ([]FoundryProjectInfo, error) { - accountsClient, err := armcognitiveservices.NewAccountsClient(subscriptionId, a.credential, azure.NewArmClientOptions()) - if err != nil { - return nil, fmt.Errorf("failed to create accounts client: %w", err) - } - - projectsClient, err := armcognitiveservices.NewProjectsClient(subscriptionId, a.credential, azure.NewArmClientOptions()) - if err != nil { - return nil, fmt.Errorf("failed to create projects client: %w", err) - } - - var results []FoundryProjectInfo - - // List all CognitiveServices accounts - accountPager := accountsClient.NewListPager(nil) - for accountPager.More() { - page, err := accountPager.NextPage(ctx) - if err != nil { - return nil, fmt.Errorf("failed to list accounts: %w", err) - } - - for _, account := range page.Value { - if account.Kind == nil { - continue - } - // Only include Foundry-compatible account types - kind := strings.ToLower(*account.Kind) - if kind != "aiservices" && kind != "openai" { - continue - } - - // Extract resource group from the account's ID - accountId := "" - if account.ID != nil { - accountId = *account.ID - } - rgName := extractResourceGroup(accountId) - if rgName == "" { - continue - } - accountName := "" - if account.Name != nil { - accountName = *account.Name - } - accountLocation := "" - if account.Location != nil { - accountLocation = *account.Location - } - - // List projects under this account - projectPager := projectsClient.NewListPager(rgName, accountName, nil) - for projectPager.More() { - projectPage, err := projectPager.NextPage(ctx) - if err != nil { - // Skip accounts we can't list projects for (permissions, etc.) - break - } - for _, proj := range projectPage.Value { - projName := "" - if proj.Name != nil { - // ARM returns nested resource names like "accountName/projectName" - // Extract just the project name (last segment) - fullName := *proj.Name - if idx := strings.LastIndex(fullName, "/"); idx != -1 { - projName = fullName[idx+1:] - } else { - projName = fullName - } - } - projLocation := accountLocation - if proj.Location != nil { - projLocation = *proj.Location - } - resourceId := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.CognitiveServices/accounts/%s/projects/%s", - subscriptionId, rgName, accountName, projName) - - results = append(results, FoundryProjectInfo{ - SubscriptionId: subscriptionId, - ResourceGroupName: rgName, - AccountName: accountName, - ProjectName: projName, - Location: projLocation, - ResourceId: resourceId, - }) - } - } - } - } - - return results, nil -} - -// FoundryDeploymentInfo holds information about an existing model deployment in a Foundry project. -type FoundryDeploymentInfo struct { - Name string - ModelName string - ModelFormat string - Version string - SkuName string - SkuCapacity int -} - -// listProjectDeployments lists all model deployments in a Foundry project (account). -func (a *InitFromCodeAction) listProjectDeployments(ctx context.Context, subscriptionId, resourceGroup, accountName string) ([]FoundryDeploymentInfo, error) { - deploymentsClient, err := armcognitiveservices.NewDeploymentsClient(subscriptionId, a.credential, azure.NewArmClientOptions()) - if err != nil { - return nil, fmt.Errorf("failed to create deployments client: %w", err) - } - - pager := deploymentsClient.NewListPager(resourceGroup, accountName, nil) - var results []FoundryDeploymentInfo - for pager.More() { - page, err := pager.NextPage(ctx) - if err != nil { - return nil, fmt.Errorf("failed to list deployments: %w", err) - } - for _, deployment := range page.Value { - info := FoundryDeploymentInfo{} - if deployment.Name != nil { - info.Name = *deployment.Name - } - if deployment.Properties != nil && deployment.Properties.Model != nil { - m := deployment.Properties.Model - if m.Name != nil { - info.ModelName = *m.Name - } - if m.Format != nil { - info.ModelFormat = *m.Format - } - if m.Version != nil { - info.Version = *m.Version - } - } - if deployment.SKU != nil { - if deployment.SKU.Name != nil { - info.SkuName = *deployment.SKU.Name - } - if deployment.SKU.Capacity != nil { - info.SkuCapacity = int(*deployment.SKU.Capacity) - } - } - results = append(results, info) - } - } - return results, nil -} - -func (a *InitFromCodeAction) setEnvVar(ctx context.Context, key, value string) error { - _, err := a.azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ - EnvName: a.environment.Name, - Key: key, - Value: value, - }) - if err != nil { - return fmt.Errorf("failed to set environment variable %s=%s: %w", key, value, err) - } - - return nil -} - -// lookupAcrResourceId finds the resource ID for an ACR given its login server endpoint -func (a *InitFromCodeAction) lookupAcrResourceId(ctx context.Context, subscriptionId string, loginServer string) (string, error) { - // Extract registry name from login server (e.g., "myregistry" from "myregistry.azurecr.io") - parts := strings.Split(loginServer, ".") - if len(parts) < 2 || parts[0] == "" { - return "", fmt.Errorf("invalid login server format: %q, expected e.g. %q", loginServer, "registry.azurecr.io") - } - registryName := parts[0] - - client, err := armcontainerregistry.NewRegistriesClient(subscriptionId, a.credential, azure.NewArmClientOptions()) - if err != nil { - return "", fmt.Errorf("failed to create container registry client: %w", err) - } - - // List all registries and find the matching one - pager := client.NewListPager(nil) - for pager.More() { - page, err := pager.NextPage(ctx) - if err != nil { - return "", fmt.Errorf("failed to list registries: %w", err) - } - for _, registry := range page.Value { - if registry.Name != nil && strings.EqualFold(*registry.Name, registryName) { - if registry.ID != nil { - return *registry.ID, nil - } - } - } - } - - return "", fmt.Errorf("container registry '%s' not found in subscription", registryName) -} - // writeDefinitionToSrcDir writes a ContainerAgent to a YAML file in the src directory and returns the path func (a *InitFromCodeAction) writeDefinitionToSrcDir(definition *agent_yaml.ContainerAgent, srcDir string) (string, error) { // Ensure the src directory exists @@ -1312,316 +782,5 @@ func (a *InitFromCodeAction) addToProject(ctx context.Context, targetDir string, } fmt.Printf("\nAdded your agent as a service entry named '%s' under the file azure.yaml.\n", agentName) - fmt.Printf("To provision and deploy the whole solution, use %s.\n", color.HiBlueString("azd up")) - fmt.Printf( - "If you already have your project provisioned with hosted agents requirements, "+ - "you can directly use %s.\n", - color.HiBlueString("azd deploy %s", agentName)) return nil } - -func (a *InitFromCodeAction) processExistingFoundryProject(ctx context.Context, foundryProject FoundryProjectInfo) error { - - if err := a.setEnvVar(ctx, "AZURE_AI_PROJECT_ID", foundryProject.ResourceId); err != nil { - return err - } - - // Set the extracted values as environment variables - if err := a.setEnvVar(ctx, "AZURE_RESOURCE_GROUP", foundryProject.ResourceGroupName); err != nil { - return err - } - - if err := a.setEnvVar(ctx, "AZURE_AI_ACCOUNT_NAME", foundryProject.AccountName); err != nil { - return err - } - - if err := a.setEnvVar(ctx, "AZURE_AI_PROJECT_NAME", foundryProject.ProjectName); err != nil { - return err - } - - // Set the Microsoft Foundry endpoint URL - aiFoundryEndpoint := fmt.Sprintf("https://%s.services.ai.azure.com/api/projects/%s", foundryProject.AccountName, foundryProject.ProjectName) - if err := a.setEnvVar(ctx, "AZURE_AI_PROJECT_ENDPOINT", aiFoundryEndpoint); err != nil { - return err - } - - aoaiEndpoint := fmt.Sprintf("https://%s.openai.azure.com/", foundryProject.AccountName) - if err := a.setEnvVar(ctx, "AZURE_OPENAI_ENDPOINT", aoaiEndpoint); err != nil { - return err - } - - // Create FoundryProjectsClient and get connections - foundryClient, err := azure.NewFoundryProjectsClient(foundryProject.AccountName, foundryProject.ProjectName, a.credential) - if err != nil { - return fmt.Errorf("creating Foundry client: %w", err) - } - connections, err := foundryClient.GetAllConnections(ctx) - if err != nil { - fmt.Printf("Could not get Microsoft Foundry project connections to initialize AZURE_CONTAINER_REGISTRY_ENDPOINT: %v. Please set this environment variable manually.\n", err) - } else { - // Filter connections by ContainerRegistry type - var acrConnections []azure.Connection - var appInsightsConnections []azure.Connection - for _, conn := range connections { - switch conn.Type { - case azure.ConnectionTypeContainerRegistry: - acrConnections = append(acrConnections, conn) - case azure.ConnectionTypeAppInsights: - connWithCreds, err := foundryClient.GetConnectionWithCredentials(ctx, conn.Name) - if err != nil { - fmt.Printf("Could not get full details for Application Insights connection '%s': %v\n", conn.Name, err) - continue - } - if connWithCreds != nil { - conn = *connWithCreds - } - - appInsightsConnections = append(appInsightsConnections, conn) - } - } - - if len(acrConnections) == 0 { - fmt.Println("\n" + - "An Azure Container Registry (ACR) is required\n\n" + - "Foundry Hosted Agents need an Azure Container Registry to store container images before deployment.\n\n" + - "You can:\n" + - " • Use an existing ACR\n" + - " • Or create a new one from the template during 'azd up'\n\n" + - "Learn more: aka.ms/azdaiagent/docs") - - resp, err := a.azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ - Options: &azdext.PromptOptions{ - Message: "Enter your ACR login server (e.g., myregistry.azurecr.io), or leave blank to create a new one", - IgnoreHintKeys: true, - }, - }) - if err != nil { - return fmt.Errorf("prompting for ACR endpoint: %w", err) - } - - if resp.Value != "" { - // Look up the ACR resource ID from the login server - resourceId, err := a.lookupAcrResourceId(ctx, a.azureContext.Scope.SubscriptionId, resp.Value) - if err != nil { - return fmt.Errorf("failed to lookup ACR resource ID: %w", err) - } - - if err := a.setEnvVar(ctx, "AZURE_CONTAINER_REGISTRY_ENDPOINT", resp.Value); err != nil { - return err - } - if err := a.setEnvVar(ctx, "AZURE_CONTAINER_REGISTRY_RESOURCE_ID", resourceId); err != nil { - return err - } - } - } else { - var selectedConnection *azure.Connection - - if len(acrConnections) == 1 { - selectedConnection = &acrConnections[0] - - fmt.Printf("Using container registry connection: %s (%s)\n", selectedConnection.Name, selectedConnection.Target) - } else { - // Multiple connections found, prompt user to select - fmt.Printf("Found %d container registry connections:\n", len(acrConnections)) - - choices := make([]*azdext.SelectChoice, len(acrConnections)) - for i, conn := range acrConnections { - choices[i] = &azdext.SelectChoice{ - Label: conn.Name, - Value: fmt.Sprintf("%d", i), - } - } - - defaultIndex := int32(0) - selectResp, err := a.azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ - Options: &azdext.SelectOptions{ - Message: "Select a container registry connection to use for this agent", - Choices: choices, - SelectedIndex: &defaultIndex, - }, - }) - if err != nil { - return fmt.Errorf("failed to prompt for connection selection: %w", err) - } else { - selectedConnection = &acrConnections[int(*selectResp.Value)] - } - } - - if err := a.setEnvVar(ctx, "AZURE_AI_PROJECT_ACR_CONNECTION_NAME", selectedConnection.Name); err != nil { - return err - } - - if err := a.setEnvVar(ctx, "AZURE_CONTAINER_REGISTRY_ENDPOINT", selectedConnection.Target); err != nil { - return err - } - } - - // Handle App Insights connections - if len(appInsightsConnections) == 0 { - fmt.Println("\n" + - "Application Insights (optional)\n\n" + - "Enable telemetry to collect logs, traces, and diagnostics for this agent.\n\n" + - "You can:\n" + - " • Use an existing Application Insights resource\n" + - " • Or create a new one during 'azd up'\n\n" + - "Docs: aka.ms/azdaiagent/docs") - - // First prompt for resource ID - resourceIdResp, err := a.azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ - Options: &azdext.PromptOptions{ - Message: "Enter your Application Insights resource ID, or leave blank to create a new one", - IgnoreHintKeys: true, - }, - }) - if err != nil { - return fmt.Errorf("prompting for Application Insights resource ID: %w", err) - } - - if resourceIdResp.Value != "" { - if err := a.setEnvVar(ctx, "APPLICATIONINSIGHTS_RESOURCE_ID", resourceIdResp.Value); err != nil { - return err - } - - // If user provided resource ID, also prompt for connection string - connStrResp, err := a.azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ - Options: &azdext.PromptOptions{ - Message: "Enter your Application Insights connection string", - IgnoreHintKeys: true, - }, - }) - if err != nil { - return fmt.Errorf("prompting for Application Insights connection string: %w", err) - } - - if connStrResp.Value != "" { - if err := a.setEnvVar(ctx, "APPLICATIONINSIGHTS_CONNECTION_STRING", connStrResp.Value); err != nil { - return err - } - } - } - - } else { - var selectedConnection *azure.Connection - - if len(appInsightsConnections) == 1 { - selectedConnection = &appInsightsConnections[0] - - fmt.Printf("Using Application Insights connection: %s (%s)\n", selectedConnection.Name, selectedConnection.Target) - } else { - // Multiple connections found, prompt user to select - fmt.Printf("Found %d Application Insights connections:\n", len(appInsightsConnections)) - - choices := make([]*azdext.SelectChoice, len(appInsightsConnections)) - for i, conn := range appInsightsConnections { - choices[i] = &azdext.SelectChoice{ - Label: conn.Name, - Value: fmt.Sprintf("%d", i), - } - } - - defaultIndex := int32(0) - selectResp, err := a.azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ - Options: &azdext.SelectOptions{ - Message: "Select an Application Insights connection to use for this agent", - Choices: choices, - SelectedIndex: &defaultIndex, - }, - }) - if err != nil { - fmt.Printf("failed to prompt for connection selection: %v\n", err) - } else { - selectedConnection = &appInsightsConnections[int(*selectResp.Value)] - } - } - - if selectedConnection != nil && selectedConnection.Credentials.Key != "" { - if err := a.setEnvVar(ctx, "APPLICATIONINSIGHTS_CONNECTION_NAME", selectedConnection.Name); err != nil { - return err - } - - if err := a.setEnvVar(ctx, "APPLICATIONINSIGHTS_CONNECTION_STRING", selectedConnection.Credentials.Key); err != nil { - return err - } - } - } - } - - return nil -} - -func (a *InitFromCodeAction) resolveModelDeploymentNoPrompt( - ctx context.Context, - model *azdext.AiModel, - location string, -) (*azdext.AiModelDeployment, error) { - resolveResp, err := a.azdClient.Ai().ResolveModelDeployments(ctx, &azdext.ResolveModelDeploymentsRequest{ - AzureContext: a.azureContext, - ModelName: model.Name, - Options: &azdext.AiModelDeploymentOptions{ - Locations: []string{location}, - }, - Quota: &azdext.QuotaCheckOptions{ - MinRemainingCapacity: 1, - }, - }) - if err != nil { - return nil, exterrors.FromAiService(err, exterrors.CodeModelResolutionFailed) - } - - if len(resolveResp.Deployments) == 0 { - return nil, exterrors.Dependency( - exterrors.CodeModelResolutionFailed, - fmt.Sprintf("no deployment candidates found for model '%s' in location '%s'", model.Name, location), - "", - ) - } - - orderedCandidates := slices.Clone(resolveResp.Deployments) - defaultVersions := make(map[string]struct{}, len(model.Versions)) - for _, version := range model.Versions { - if version.IsDefault { - defaultVersions[version.Version] = struct{}{} - } - } - - slices.SortFunc(orderedCandidates, func(a, b *azdext.AiModelDeployment) int { - _, aDefault := defaultVersions[a.Version] - _, bDefault := defaultVersions[b.Version] - if aDefault != bDefault { - if aDefault { - return -1 - } - return 1 - } - - aSkuPriority := skuPriority(a.Sku.Name) - bSkuPriority := skuPriority(b.Sku.Name) - if aSkuPriority != bSkuPriority { - if aSkuPriority < bSkuPriority { - return -1 - } - return 1 - } - - if cmp := strings.Compare(a.Version, b.Version); cmp != 0 { - return cmp - } - - if cmp := strings.Compare(a.Sku.Name, b.Sku.Name); cmp != 0 { - return cmp - } - - return strings.Compare(a.Sku.UsageName, b.Sku.UsageName) - }) - - for _, candidate := range orderedCandidates { - capacity, ok := resolveNoPromptCapacity(candidate) - if !ok { - continue - } - - return cloneDeploymentWithCapacity(candidate, capacity), nil - } - - return nil, fmt.Errorf("no deployment candidates found for model '%s' with a valid non-interactive capacity", model.Name) -} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers.go new file mode 100644 index 00000000000..d625152183d --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers.go @@ -0,0 +1,262 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "io" + "io/fs" + "net/http" + "os" + "path/filepath" + "slices" + "strings" + + "azureaiagent/internal/exterrors" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/fatih/color" +) + +const agentTemplatesURL = "https://aka.ms/foundry-agents" + +// Template type constants +const ( + // TemplateTypeAgent is a template that points to an agent.yaml manifest file. + TemplateTypeAgent = "agent" + + // TemplateTypeAzd is a full azd template repository. + TemplateTypeAzd = "azd" +) + +// AgentTemplate represents an agent template entry from the remote JSON catalog. +type AgentTemplate struct { + Title string `json:"title"` + Description string `json:"description"` + Language string `json:"language"` + Framework string `json:"framework"` + Source string `json:"source"` + Tags []string `json:"tags"` +} + +// EffectiveType determines the template type by inspecting the source URL. +// If it ends with agent.yaml or agent.manifest.yaml, it's an agent manifest. +// Otherwise, it's treated as a full azd template repo. +func (t *AgentTemplate) EffectiveType() string { + lower := strings.ToLower(t.Source) + if strings.HasSuffix(lower, "/agent.yaml") || + strings.HasSuffix(lower, "/agent.manifest.yaml") || + lower == "agent.yaml" || + lower == "agent.manifest.yaml" { + return TemplateTypeAgent + } + return TemplateTypeAzd +} + +const ( + initModeFromCode = "from_code" + initModeTemplate = "template" +) + +// promptInitMode asks the user whether to use existing code or start from a template. +// If the current directory is empty, automatically returns initModeTemplate. +// Returns initModeFromCode or initModeTemplate. +func promptInitMode(ctx context.Context, azdClient *azdext.AzdClient) (string, error) { + empty, err := dirIsEmpty(".") + if err != nil { + return "", fmt.Errorf("checking current directory: %w", err) + } + + if empty { + return initModeTemplate, nil + } + + choices := []*azdext.SelectChoice{ + {Label: "Use the code in the current directory", Value: initModeFromCode}, + {Label: "Start new from a template", Value: initModeTemplate}, + } + + defaultIndex := int32(0) + + resp, err := azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: "How do you want to initialize your agent?", + Choices: choices, + SelectedIndex: &defaultIndex, + }, + }) + if err != nil { + if exterrors.IsCancellation(err) { + return "", exterrors.Cancelled("initialization mode selection was cancelled") + } + return "", fmt.Errorf("failed to prompt for initialization mode: %w", err) + } + + return choices[*resp.Value].Value, nil +} + +// dirIsEmpty reports whether dir contains no entries at all. +func dirIsEmpty(dir string) (bool, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return false, err + } + + return len(entries) == 0, nil +} + +// fetchAgentTemplates retrieves the agent template catalog from the remote JSON URL. +func fetchAgentTemplates(ctx context.Context, httpClient *http.Client) ([]AgentTemplate, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, agentTemplatesURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + //nolint:gosec // URL is the hard-coded agentTemplatesURL constant, not user input + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch agent templates: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to fetch agent templates: HTTP %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read agent templates response: %w", err) + } + + var templates []AgentTemplate + if err := json.Unmarshal(body, &templates); err != nil { + return nil, fmt.Errorf("failed to parse agent templates: %w", err) + } + + return templates, nil +} + +// promptAgentTemplate guides the user through language selection and template selection. +// Returns the selected AgentTemplate. The caller should check EffectiveType() to determine +// whether to use the agent.yaml manifest flow or the full azd template flow. +func promptAgentTemplate( + ctx context.Context, + azdClient *azdext.AzdClient, + httpClient *http.Client, + noPrompt bool, +) (*AgentTemplate, error) { + if noPrompt { + return nil, exterrors.Validation( + exterrors.CodePromptFailed, + "template selection requires interactive mode", + "use 'azd ai agent init -m ' to initialize from a template non-interactively", + ) + } + + _, _ = color.New(color.Faint).Println("Retrieving agent templates...") + + templates, err := fetchAgentTemplates(ctx, httpClient) + if err != nil { + return nil, fmt.Errorf("failed to retrieve agent templates: %w", err) + } + + if len(templates) == 0 { + return nil, fmt.Errorf("no agent templates available") + } + + // Prompt for language + languageChoices := []*azdext.SelectChoice{ + {Label: "Python", Value: "python"}, + {Label: "C#", Value: "csharp"}, + } + + langResp, err := azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: "Select a language", + Choices: languageChoices, + }, + }) + if err != nil { + if exterrors.IsCancellation(err) { + return nil, exterrors.Cancelled("language selection was cancelled") + } + return nil, fmt.Errorf("failed to prompt for language: %w", err) + } + + selectedLanguage := languageChoices[*langResp.Value].Value + + // Filter templates by selected language + var filtered []AgentTemplate + for _, t := range templates { + if t.Language == selectedLanguage { + filtered = append(filtered, t) + } + } + + if len(filtered) == 0 { + return nil, fmt.Errorf("no agent templates available for %s", languageChoices[*langResp.Value].Label) + } + + // Sort templates alphabetically by title + slices.SortFunc(filtered, func(a, b AgentTemplate) int { + return strings.Compare(a.Title, b.Title) + }) + + // Build template choices with framework in label + templateChoices := make([]*azdext.SelectChoice, len(filtered)) + for i, t := range filtered { + label := fmt.Sprintf("%s (%s)", t.Title, t.Framework) + templateChoices[i] = &azdext.SelectChoice{ + Label: label, + Value: fmt.Sprintf("%d", i), + } + } + + templateResp, err := azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: "Select an agent template", + Choices: templateChoices, + }, + }) + if err != nil { + if exterrors.IsCancellation(err) { + return nil, exterrors.Cancelled("template selection was cancelled") + } + return nil, fmt.Errorf("failed to prompt for template: %w", err) + } + + selectedTemplate := filtered[*templateResp.Value] + return &selectedTemplate, nil +} + +// findAgentManifest searches the directory tree rooted at dir for the first +// agent.yaml or agent.manifest.yaml file. Returns the path if found, or empty string if not. +func findAgentManifest(dir string) (string, error) { + manifestNames := map[string]bool{ + "agent.yaml": true, + "agent.manifest.yaml": true, + } + + var found string + err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil // skip directories we can't read + } + if d.IsDir() { + return nil + } + if manifestNames[strings.ToLower(d.Name())] { + found = path + return filepath.SkipAll + } + return nil + }) + if err != nil { + return "", fmt.Errorf("searching for agent manifest: %w", err) + } + + return found, nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers_test.go new file mode 100644 index 00000000000..3fa7ecaca6a --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers_test.go @@ -0,0 +1,300 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestEffectiveType(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + source string + expected string + }{ + { + name: "agent.yaml suffix", + source: "https://github.com/org/repo/blob/main/samples/echo-agent/agent.yaml", + expected: TemplateTypeAgent, + }, + { + name: "agent.manifest.yaml suffix", + source: "https://github.com/org/repo/blob/main/samples/echo-agent/agent.manifest.yaml", + expected: TemplateTypeAgent, + }, + { + name: "bare agent.yaml", + source: "agent.yaml", + expected: TemplateTypeAgent, + }, + { + name: "bare agent.manifest.yaml", + source: "agent.manifest.yaml", + expected: TemplateTypeAgent, + }, + { + name: "case insensitive agent.yaml", + source: "https://github.com/org/repo/blob/main/Agent.YAML", + expected: TemplateTypeAgent, + }, + { + name: "case insensitive agent.manifest.yaml", + source: "https://github.com/org/repo/blob/main/Agent.Manifest.YAML", + expected: TemplateTypeAgent, + }, + { + name: "github repo slug", + source: "Azure-Samples/my-agent-template", + expected: TemplateTypeAzd, + }, + { + name: "github repo URL", + source: "https://github.com/Azure-Samples/my-agent-template", + expected: TemplateTypeAzd, + }, + { + name: "empty source", + source: "", + expected: TemplateTypeAzd, + }, + { + name: "yaml file that is not agent.yaml", + source: "https://github.com/org/repo/blob/main/config.yaml", + expected: TemplateTypeAzd, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + template := &AgentTemplate{Source: tt.source} + require.Equal(t, tt.expected, template.EffectiveType()) + }) + } +} + +func TestFetchAgentTemplates(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + + templates := []AgentTemplate{ + { + Title: "Echo Agent", + Language: "python", + Framework: "Agent Framework", + Source: "https://github.com/org/repo/blob/main/echo-agent/agent.yaml", + }, + { + Title: "Calculator Agent", + Language: "csharp", + Framework: "LangGraph", + Source: "Azure-Samples/calculator-agent", + }, + } + + data, err := json.Marshal(templates) + require.NoError(t, err) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(data) + })) + defer server.Close() + + // Use a custom URL by overriding the HTTP client to redirect + result, err := fetchAgentTemplatesFromURL(t.Context(), server.Client(), server.URL) + require.NoError(t, err) + require.Len(t, result, 2) + require.Equal(t, "Echo Agent", result[0].Title) + require.Equal(t, "python", result[0].Language) + require.Equal(t, "Calculator Agent", result[1].Title) + require.Equal(t, "csharp", result[1].Language) + }) + + t.Run("HTTP error", func(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + _, err := fetchAgentTemplatesFromURL(t.Context(), server.Client(), server.URL) + require.Error(t, err) + require.Contains(t, err.Error(), "HTTP 500") + }) + + t.Run("invalid JSON", func(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("not json")) + })) + defer server.Close() + + _, err := fetchAgentTemplatesFromURL(t.Context(), server.Client(), server.URL) + require.Error(t, err) + require.Contains(t, err.Error(), "failed to parse agent templates") + }) + + t.Run("empty array", func(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("[]")) + })) + defer server.Close() + + result, err := fetchAgentTemplatesFromURL(t.Context(), server.Client(), server.URL) + require.NoError(t, err) + require.Empty(t, result) + }) +} + +// fetchAgentTemplatesFromURL is a test helper that fetches templates from a custom URL. +func fetchAgentTemplatesFromURL( + ctx context.Context, + httpClient *http.Client, + url string, +) ([]AgentTemplate, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + //nolint:gosec // URL points to a local httptest server, not user input + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to fetch agent templates: HTTP %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var templates []AgentTemplate + if err := json.Unmarshal(body, &templates); err != nil { + return nil, fmt.Errorf("failed to parse agent templates: %w", err) + } + + return templates, nil +} + +func TestFindAgentManifest(t *testing.T) { + t.Parallel() + + t.Run("finds agent.yaml at root", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + manifestPath := filepath.Join(dir, "agent.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte("name: test"), 0600)) + + found, err := findAgentManifest(dir) + require.NoError(t, err) + require.Equal(t, manifestPath, found) + }) + + t.Run("finds agent.manifest.yaml at root", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + manifestPath := filepath.Join(dir, "agent.manifest.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte("name: test"), 0600)) + + found, err := findAgentManifest(dir) + require.NoError(t, err) + require.Equal(t, manifestPath, found) + }) + + t.Run("finds agent.yaml in subdirectory", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + subDir := filepath.Join(dir, "src", "my-agent") + require.NoError(t, os.MkdirAll(subDir, 0700)) + manifestPath := filepath.Join(subDir, "agent.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte("name: test"), 0600)) + + found, err := findAgentManifest(dir) + require.NoError(t, err) + require.Equal(t, manifestPath, found) + }) + + t.Run("returns empty when no manifest exists", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + // Create some other files + require.NoError(t, os.WriteFile(filepath.Join(dir, "azure.yaml"), []byte("name: test"), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "config.yaml"), []byte("key: val"), 0600)) + + found, err := findAgentManifest(dir) + require.NoError(t, err) + require.Empty(t, found) + }) + + t.Run("ignores non-agent yaml files", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "azure.yaml"), []byte("name: test"), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "config.yaml"), []byte("key: val"), 0600)) + + found, err := findAgentManifest(dir) + require.NoError(t, err) + require.Empty(t, found) + }) +} + +func TestDirIsEmpty(t *testing.T) { + t.Parallel() + + t.Run("empty directory", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + empty, err := dirIsEmpty(dir) + require.NoError(t, err) + require.True(t, empty) + }) + + t.Run("directory with files", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "main.py"), []byte("print()"), 0600)) + + empty, err := dirIsEmpty(dir) + require.NoError(t, err) + require.False(t, empty) + }) + + t.Run("directory with only subdirectories", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "subdir"), 0700)) + + empty, err := dirIsEmpty(dir) + require.NoError(t, err) + require.False(t, empty) + }) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_models.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_models.go index c4b4fea66d9..b426c32b256 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_models.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_models.go @@ -12,13 +12,12 @@ import ( "azureaiagent/internal/exterrors" "azureaiagent/internal/pkg/agents/agent_yaml" "azureaiagent/internal/pkg/agents/registry_api" - "azureaiagent/internal/pkg/azure" "azureaiagent/internal/project" - "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices/v2" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/azure/azure-dev/cli/azd/pkg/output" "github.com/azure/azure-dev/cli/azd/pkg/ux" + "github.com/fatih/color" "google.golang.org/genproto/googleapis/rpc/errdetails" "google.golang.org/grpc/status" "google.golang.org/protobuf/proto" @@ -162,33 +161,16 @@ func (a *InitAction) getModelDeploymentDetails(ctx context.Context, model agent_ resourceGroup := parts[4] accountName := parts[8] - deploymentsClient, err := armcognitiveservices.NewDeploymentsClient(subscription, a.credential, azure.NewArmClientOptions()) + allDeployments, err := listProjectDeployments(ctx, a.credential, subscription, resourceGroup, accountName) if err != nil { - return nil, fmt.Errorf("failed to create deployments client: %w", err) + return nil, fmt.Errorf("failed to list deployments: %w", err) } - pager := deploymentsClient.NewListPager(resourceGroup, accountName, nil) - var deployments []*armcognitiveservices.Deployment - for pager.More() { - page, err := pager.NextPage(ctx) - if err != nil { - return nil, fmt.Errorf("failed to list deployments: %w", err) - } - deployments = append(deployments, page.Value...) - } - - matchingDeployments := make(map[string]*armcognitiveservices.Deployment) - for _, deployment := range deployments { - if deployment.Name == nil || - deployment.Properties == nil || deployment.Properties.Model == nil || - deployment.Properties.Model.Name == nil || deployment.Properties.Model.Format == nil || - deployment.Properties.Model.Version == nil || - deployment.SKU == nil || deployment.SKU.Name == nil || deployment.SKU.Capacity == nil { - continue - } - - if *deployment.Properties.Model.Name == model.Id { - matchingDeployments[*deployment.Name] = deployment + matchingDeployments := make(map[string]*FoundryDeploymentInfo) + for i := range allDeployments { + d := &allDeployments[i] + if d.ModelName == model.Id { + matchingDeployments[d.Name] = d } } @@ -214,16 +196,87 @@ func (a *InitAction) getModelDeploymentDetails(ctx context.Context, model agent_ Name: selection, Model: project.DeploymentModel{ Name: model.Id, - Format: *deployment.Properties.Model.Format, - Version: *deployment.Properties.Model.Version, + Format: deployment.ModelFormat, + Version: deployment.Version, }, Sku: project.DeploymentSku{ - Name: *deployment.SKU.Name, - Capacity: int(*deployment.SKU.Capacity), + Name: deployment.SkuName, + Capacity: deployment.SkuCapacity, }, }, nil } } + } else { + color.Yellow( + "No existing deployment for model '%s' specified in the selected agent manifest was found in your Foundry project.\n", + model.Id, + ) + + noMatchChoices := []*azdext.SelectChoice{ + { + Label: fmt.Sprintf("Deploy a new '%s' model to the selected Foundry project", model.Id), + Value: "deploy_new", + }, + { + Label: "Use a different model already deployed in this project", + Value: "use_different", + }, + } + + defaultIdx := int32(0) + noMatchResp, err := a.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, exterrors.Cancelled("model deployment selection was cancelled") + } + return nil, fmt.Errorf("failed to prompt for no-match choice: %w", err) + } + + if noMatchChoices[*noMatchResp.Value].Value == "use_different" { + if len(allDeployments) == 0 { + fmt.Println("No deployments found in this project. A new deployment will be configured.") + } else { + // Let user pick from all deployments in the project + deploymentOptions := make([]string, 0, len(allDeployments)) + deploymentMap := make(map[string]*FoundryDeploymentInfo) + for i := range allDeployments { + d := &allDeployments[i] + label := fmt.Sprintf("%s (%s)", d.Name, d.ModelName) + deploymentOptions = append(deploymentOptions, label) + deploymentMap[label] = d + } + + slices.Sort(deploymentOptions) + + selection, err := a.selectFromList(ctx, "deployment", deploymentOptions, deploymentOptions[0]) + if err != nil { + return nil, fmt.Errorf("failed to select deployment: %w", err) + } + + if deployment, exists := deploymentMap[selection]; exists { + fmt.Printf("Using existing model deployment: %s\n", deployment.Name) + return &project.Deployment{ + Name: deployment.Name, + Model: project.DeploymentModel{ + Name: deployment.ModelName, + Format: deployment.ModelFormat, + Version: deployment.Version, + }, + Sku: project.DeploymentSku{ + Name: deployment.SkuName, + Capacity: deployment.SkuCapacity, + }, + }, nil + } + } + } + // "deploy_new" or no deployments available — fall through to deploy-new logic below } } @@ -299,7 +352,7 @@ func (a *InitAction) getModelDetails(ctx context.Context, modelName string) (*az if a.flags.NoPrompt { fmt.Println("No prompt mode enabled, automatically selecting a model deployment based on availability and quota...") - return a.resolveModelDeploymentNoPrompt(ctx, model, currentLocation) + return resolveModelDeployment(ctx, a.azdClient, a.azureContext, model, currentLocation) } for { @@ -344,83 +397,6 @@ func (a *InitAction) getModelDetails(ctx context.Context, modelName string) (*az } } -func (a *InitAction) resolveModelDeploymentNoPrompt( - ctx context.Context, - model *azdext.AiModel, - location string, -) (*azdext.AiModelDeployment, error) { - resolveResp, err := a.azdClient.Ai().ResolveModelDeployments(ctx, &azdext.ResolveModelDeploymentsRequest{ - AzureContext: a.azureContext, - ModelName: model.Name, - Options: &azdext.AiModelDeploymentOptions{ - Locations: []string{location}, - }, - Quota: &azdext.QuotaCheckOptions{ - MinRemainingCapacity: 1, - }, - }) - if err != nil { - return nil, exterrors.FromAiService(err, exterrors.CodeModelResolutionFailed) - } - - if len(resolveResp.Deployments) == 0 { - return nil, exterrors.Dependency( - exterrors.CodeModelResolutionFailed, - fmt.Sprintf("no deployment candidates found for model '%s' in location '%s'", model.Name, location), - "", - ) - } - - orderedCandidates := slices.Clone(resolveResp.Deployments) - defaultVersions := make(map[string]struct{}, len(model.Versions)) - for _, version := range model.Versions { - if version.IsDefault { - defaultVersions[version.Version] = struct{}{} - } - } - - slices.SortFunc(orderedCandidates, func(a, b *azdext.AiModelDeployment) int { - _, aDefault := defaultVersions[a.Version] - _, bDefault := defaultVersions[b.Version] - if aDefault != bDefault { - if aDefault { - return -1 - } - return 1 - } - - aSkuPriority := skuPriority(a.Sku.Name) - bSkuPriority := skuPriority(b.Sku.Name) - if aSkuPriority != bSkuPriority { - if aSkuPriority < bSkuPriority { - return -1 - } - return 1 - } - - if cmp := strings.Compare(a.Version, b.Version); cmp != 0 { - return cmp - } - - if cmp := strings.Compare(a.Sku.Name, b.Sku.Name); cmp != 0 { - return cmp - } - - return strings.Compare(a.Sku.UsageName, b.Sku.UsageName) - }) - - for _, candidate := range orderedCandidates { - capacity, ok := resolveNoPromptCapacity(candidate) - if !ok { - continue - } - - return cloneDeploymentWithCapacity(candidate, capacity), nil - } - - return nil, fmt.Errorf("no deployment candidates found for model '%s' with a valid non-interactive capacity", model.Name) -} - func resolveNoPromptCapacity(candidate *azdext.AiModelDeployment) (int32, bool) { capacity := candidate.Capacity if capacity <= 0 { diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/registry_api/helpers.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/registry_api/helpers.go index d619e7d5e2b..77586036d92 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/registry_api/helpers.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/registry_api/helpers.go @@ -495,8 +495,6 @@ func injectParameterValues(template string, paramValues ParameterValues) ([]byte // Check for any remaining unreplaced placeholders if strings.Contains(template, "{{") && strings.Contains(template, "}}") { fmt.Println("Warning: Template contains unresolved placeholders.") - } else { - fmt.Println("No remaining placeholders found.") } return []byte(template), nil