From 05d71b0e763fdc1e0a7efd229e47a9e37c7ab702 Mon Sep 17 00:00:00 2001 From: trangevi Date: Tue, 10 Feb 2026 08:40:50 -0800 Subject: [PATCH 01/34] Add support for init from code Signed-off-by: trangevi --- .../azure.ai.agents/internal/cmd/init.go | 146 +++++++++++++++++- 1 file changed, 145 insertions(+), 1 deletion(-) 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 48f3ef5f83f..5d6f7deefcd 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -178,6 +178,20 @@ func (a *InitAction) Run(ctx context.Context) error { color.Green("Initializing AI agent project...") fmt.Println() + // If src path is absolute, convert it to relative path compared to the azd project path + if a.flags.src != "" && filepath.IsAbs(a.flags.src) { + projectResponse, err := a.azdClient.Project().Get(ctx, &azdext.EmptyRequest{}) + if err != nil { + return fmt.Errorf("failed to get project path: %w", err) + } + + relPath, err := filepath.Rel(projectResponse.Project.Path, a.flags.src) + if err != nil { + return fmt.Errorf("failed to convert src path to relative path: %w", err) + } + a.flags.src = relPath + } + // If --project-id is given if a.flags.projectResourceId != "" { // projectResourceId is a string of the format @@ -223,6 +237,34 @@ func (a *InitAction) Run(ctx context.Context) error { } color.Green("\nAI agent definition added to your azd project successfully!") + } else { + // No manifest pointer provided - process local agent code + // Create a manifest from the local agent.yaml file + localManifest, err := a.createManifestFromLocalAgent(ctx) + if err != nil { + return fmt.Errorf("failed to create manifest from local agent: %w", err) + } + + if localManifest != nil { + // Write the manifest to a file in the src directory + manifestPath, err := a.writeManifestToSrcDir(localManifest, a.flags.src) + if err != nil { + return fmt.Errorf("failed to write manifest to src directory: %w", err) + } + + // Use the manifest file path to download/process the agent + agentManifest, targetDir, err := a.downloadAgentYaml(ctx, manifestPath, a.flags.src) + if err != nil { + return fmt.Errorf("downloading agent.yaml from local manifest: %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) + } + + color.Green("\nLocal AI agent definition added to your azd project successfully!") + } } // // Validate command flags @@ -244,7 +286,7 @@ func ensureProject(ctx context.Context, flags *initFlags, azdClient *azdext.AzdC fmt.Println("Lets get your project initialized.") // Environment creation is handled separately in ensureEnvironment - initArgs := []string{"init", "--minimal"} + initArgs := []string{"init", "-t", "Azure-Samples/azd-ai-starter-basic"} // We don't have a project yet // Dispatch a workflow to init the project @@ -863,6 +905,108 @@ func (a *InitAction) isRegistryUrl(manifestPointer string) (bool, *RegistryManif } } +// createManifestFromLocalAgent creates an AgentManifest for local agent code +// This is used when no manifest pointer is provided and we need to scaffold a new agent +func (a *InitAction) createManifestFromLocalAgent(ctx context.Context) (*agent_yaml.AgentManifest, error) { + // Prompt user for agent name + promptResp, err := a.azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ + Options: &azdext.PromptOptions{ + Message: "Enter a name for your agent:", + DefaultValue: "my-agent", + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to prompt for agent name: %w", err) + } + agentName := promptResp.Value + + // TODO: Prompt user for agent kind + agentKind := agent_yaml.AgentKindHosted + + // Prompt user to select a model from the catalog + modelCatalog, err := a.modelCatalogService.ListAllModels(ctx, a.azureContext.Scope.SubscriptionId, a.azureContext.Scope.Location) + if err != nil { + return nil, fmt.Errorf("failed to list models from catalog: %w", err) + } + + // Build model choices from the catalog + var modelChoices []*azdext.SelectChoice + var modelNames []string + for modelName := range modelCatalog { + modelNames = append(modelNames, modelName) + } + slices.Sort(modelNames) + for _, modelName := range modelNames { + modelChoices = append(modelChoices, &azdext.SelectChoice{ + Label: modelName, + Value: modelName, + }) + } + + modelResp, err := a.azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: "Select a model for your agent:", + Choices: modelChoices, + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to prompt for model selection: %w", err) + } + selectedModel := modelNames[*modelResp.Value] + + // Create a minimal AgentManifest with the Template as a ContainerAgent + manifest := &agent_yaml.AgentManifest{ + Name: agentName, + Template: agent_yaml.ContainerAgent{ + AgentDefinition: agent_yaml.AgentDefinition{ + Name: agentName, + Kind: agentKind, + }, + Protocols: []agent_yaml.ProtocolVersionRecord{ + { + Protocol: "responses", + Version: "v1", + }, + }, + }, + Resources: []any{ + agent_yaml.ModelResource{ + Resource: agent_yaml.Resource{ + Name: selectedModel, + Kind: agent_yaml.ResourceKindModel, + }, + Id: selectedModel, + }, + }, + } + + return manifest, nil +} + +// writeManifestToSrcDir writes an AgentManifest to a YAML file in the src directory and returns the path +func (a *InitAction) writeManifestToSrcDir(manifest *agent_yaml.AgentManifest, srcDir string) (string, error) { + // Ensure the src directory exists + if err := os.MkdirAll(srcDir, 0755); err != nil { + return "", fmt.Errorf("creating src directory: %w", err) + } + + // Create the manifest file path + manifestPath := filepath.Join(srcDir, "agent.yaml") + + // Marshal the manifest to YAML + content, err := yaml.Marshal(manifest) + if err != nil { + return "", fmt.Errorf("marshaling manifest to YAML: %w", err) + } + + // Write to the file + if err := os.WriteFile(manifestPath, content, 0644); err != nil { + return "", fmt.Errorf("writing manifest to file: %w", err) + } + + return manifestPath, nil +} + func (a *InitAction) downloadAgentYaml( ctx context.Context, manifestPointer string, targetDir string) (*agent_yaml.AgentManifest, string, error) { if manifestPointer == "" { From d99fa3b58d6d446071f44d1067dd46536956a8ab Mon Sep 17 00:00:00 2001 From: trangevi Date: Tue, 10 Feb 2026 08:45:51 -0800 Subject: [PATCH 02/34] Code comment Signed-off-by: trangevi --- cli/azd/extensions/azure.ai.agents/internal/cmd/init.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 5d6f7deefcd..172dbabafb5 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -239,7 +239,7 @@ func (a *InitAction) Run(ctx context.Context) error { color.Green("\nAI agent definition added to your azd project successfully!") } else { // No manifest pointer provided - process local agent code - // Create a manifest from the local agent.yaml file + // Create a manifest based on user prompts localManifest, err := a.createManifestFromLocalAgent(ctx) if err != nil { return fmt.Errorf("failed to create manifest from local agent: %w", err) From 3bf9416e0c1f011ade04a17bc50b04f95a325e6c Mon Sep 17 00:00:00 2001 From: trangevi Date: Tue, 10 Feb 2026 11:46:36 -0800 Subject: [PATCH 03/34] Addressing some comments Signed-off-by: trangevi --- .../azure.ai.agents/internal/cmd/init.go | 118 +++++++++++------- 1 file changed, 75 insertions(+), 43 deletions(-) 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 172dbabafb5..3b846dc2a1e 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -246,6 +246,9 @@ func (a *InitAction) Run(ctx context.Context) error { } if localManifest != nil { + // Enable no-prompt mode since we've already collected all the input we need + a.flags.NoPrompt = true + // Write the manifest to a file in the src directory manifestPath, err := a.writeManifestToSrcDir(localManifest, a.flags.src) if err != nil { @@ -504,7 +507,8 @@ func ensureAzureContext( if azureContext.Scope.SubscriptionId == "" { fmt.Print() - fmt.Println("It looks like we first need to connect to your Azure subscription.") + 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.") subscriptionResponse, err := azdClient.Prompt().PromptSubscription(ctx, &azdext.PromptSubscriptionRequest{}) if err != nil { @@ -538,7 +542,7 @@ func ensureAzureContext( 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 infrastructure.", + "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{ @@ -908,51 +912,73 @@ func (a *InitAction) isRegistryUrl(manifestPointer string) (bool, *RegistryManif // createManifestFromLocalAgent creates an AgentManifest for local agent code // This is used when no manifest pointer is provided and we need to scaffold a new agent func (a *InitAction) createManifestFromLocalAgent(ctx context.Context) (*agent_yaml.AgentManifest, error) { - // Prompt user for agent name - promptResp, err := a.azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ - Options: &azdext.PromptOptions{ - Message: "Enter a name for your agent:", - DefaultValue: "my-agent", - }, - }) + // Use the current working directory name as the agent name, with punctuation removed and lowercased + cwd, err := os.Getwd() if err != nil { - return nil, fmt.Errorf("failed to prompt for agent name: %w", err) + return nil, fmt.Errorf("failed to get current working directory: %w", err) } - agentName := promptResp.Value + dirName := filepath.Base(cwd) + agentName := toCleanName(dirName) // TODO: Prompt user for agent kind agentKind := agent_yaml.AgentKindHosted - // Prompt user to select a model from the catalog - modelCatalog, err := a.modelCatalogService.ListAllModels(ctx, a.azureContext.Scope.SubscriptionId, a.azureContext.Scope.Location) + // Ask if user wants to select a model + var selectedModel string + wantsModel, err := a.azdClient.Prompt().Confirm(ctx, &azdext.ConfirmRequest{ + Options: &azdext.ConfirmOptions{ + Message: "Would you like to select a model for your agent?", + DefaultValue: to.Ptr(true), + }, + }) if err != nil { - return nil, fmt.Errorf("failed to list models from catalog: %w", err) + return nil, fmt.Errorf("failed to prompt for model selection choice: %w", err) } - // Build model choices from the catalog - var modelChoices []*azdext.SelectChoice - var modelNames []string - for modelName := range modelCatalog { - modelNames = append(modelNames, modelName) - } - slices.Sort(modelNames) - for _, modelName := range modelNames { - modelChoices = append(modelChoices, &azdext.SelectChoice{ - Label: modelName, - Value: modelName, + if wantsModel.Value != nil && *wantsModel.Value { + // Prompt user to select a model from the catalog + modelCatalog, err := a.modelCatalogService.ListAllModels(ctx, a.azureContext.Scope.SubscriptionId, a.azureContext.Scope.Location) + if err != nil { + return nil, fmt.Errorf("failed to list models from catalog: %w", err) + } + + // Build model choices from the catalog + var modelChoices []*azdext.SelectChoice + var modelNames []string + for modelName := range modelCatalog { + modelNames = append(modelNames, modelName) + } + slices.Sort(modelNames) + for _, modelName := range modelNames { + modelChoices = append(modelChoices, &azdext.SelectChoice{ + Label: modelName, + Value: modelName, + }) + } + + modelResp, err := a.azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: "Select a model for your agent:", + Choices: modelChoices, + }, }) + if err != nil { + return nil, fmt.Errorf("failed to prompt for model selection: %w", err) + } + selectedModel = modelNames[*modelResp.Value] } - modelResp, err := a.azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ - Options: &azdext.SelectOptions{ - Message: "Select a model for your agent:", - Choices: modelChoices, - }, - }) - if err != nil { - return nil, fmt.Errorf("failed to prompt for model selection: %w", err) + // Build resources list + var resources []any + if selectedModel != "" { + resources = append(resources, agent_yaml.ModelResource{ + Resource: agent_yaml.Resource{ + Name: selectedModel, + Kind: agent_yaml.ResourceKindModel, + }, + Id: selectedModel, + }) } - selectedModel := modelNames[*modelResp.Value] // Create a minimal AgentManifest with the Template as a ContainerAgent manifest := &agent_yaml.AgentManifest{ @@ -969,20 +995,26 @@ func (a *InitAction) createManifestFromLocalAgent(ctx context.Context) (*agent_y }, }, }, - Resources: []any{ - agent_yaml.ModelResource{ - Resource: agent_yaml.Resource{ - Name: selectedModel, - Kind: agent_yaml.ResourceKindModel, - }, - Id: selectedModel, - }, - }, + Resources: resources, } return manifest, nil } +// toCleanName cleans a name by keeping alphanumeric and dashes, replacing underscores/spaces with dashes, and lowercasing +func toCleanName(s string) string { + // Keep alphanumeric characters and dashes; replace underscores and spaces with dashes + var result strings.Builder + for _, r := range s { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' { + result.WriteRune(r) + } else if r == '_' || r == ' ' { + result.WriteRune('-') + } + } + return strings.ToLower(result.String()) +} + // writeManifestToSrcDir writes an AgentManifest to a YAML file in the src directory and returns the path func (a *InitAction) writeManifestToSrcDir(manifest *agent_yaml.AgentManifest, srcDir string) (string, error) { // Ensure the src directory exists From abc96b015bded2e0879d7d8fe3dd65a01f56efee Mon Sep 17 00:00:00 2001 From: trangevi Date: Tue, 10 Feb 2026 13:07:15 -0800 Subject: [PATCH 04/34] make sure src is provided for local code story for now Signed-off-by: trangevi --- cli/azd/extensions/azure.ai.agents/internal/cmd/init.go | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) 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 3b846dc2a1e..b29e1790aa9 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -237,7 +237,7 @@ func (a *InitAction) Run(ctx context.Context) error { } color.Green("\nAI agent definition added to your azd project successfully!") - } else { + } else if a.flags.src != "" { // No manifest pointer provided - process local agent code // Create a manifest based on user prompts localManifest, err := a.createManifestFromLocalAgent(ctx) @@ -1017,11 +1017,6 @@ func toCleanName(s string) string { // writeManifestToSrcDir writes an AgentManifest to a YAML file in the src directory and returns the path func (a *InitAction) writeManifestToSrcDir(manifest *agent_yaml.AgentManifest, srcDir string) (string, error) { - // Ensure the src directory exists - if err := os.MkdirAll(srcDir, 0755); err != nil { - return "", fmt.Errorf("creating src directory: %w", err) - } - // Create the manifest file path manifestPath := filepath.Join(srcDir, "agent.yaml") From f819843aba1e20313f31020cea01cc2e079512f7 Mon Sep 17 00:00:00 2001 From: trangevi Date: Tue, 10 Feb 2026 14:35:21 -0800 Subject: [PATCH 05/34] some fixes Signed-off-by: trangevi --- .../extensions/azure.ai.agents/internal/cmd/init.go | 10 ++++++++++ .../azure.ai.agents/internal/project/config.go | 8 ++++---- 2 files changed, 14 insertions(+), 4 deletions(-) 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 b29e1790aa9..2bc5f0a791b 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -994,6 +994,16 @@ func (a *InitAction) createManifestFromLocalAgent(ctx context.Context) (*agent_y Version: "v1", }, }, + EnvironmentVariables: &[]agent_yaml.EnvironmentVariable{ + { + Name: "AZURE_OPENAI_ENDPOINT", + Value: "${AZURE_OPENAI_ENDPOINT}", + }, + { + Name: "AZURE_AI_MODEL_DEPLOYMENT_NAME", + Value: "{{" + selectedModel + "}}", + }, + }, }, Resources: resources, } diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/config.go b/cli/azd/extensions/azure.ai.agents/internal/project/config.go index 3c69a184f5e..d3c09ee2797 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/config.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/config.go @@ -13,10 +13,10 @@ import ( // Default container settings constants const ( - DefaultMemory = "2Gi" - DefaultCpu = "1" - DefaultMinReplicas = 1 - DefaultMaxReplicas = 3 + DefaultMemory = "0.5Gi" + DefaultCpu = "0.25" + DefaultMinReplicas = 0 + DefaultMaxReplicas = 1 ) // ServiceTargetAgentConfig provides custom configuration for the Azure AI Service target From 0bb11ef92e3b206e8c3de476907214cc8f347d16 Mon Sep 17 00:00:00 2001 From: trangevi Date: Wed, 11 Feb 2026 08:38:38 -0800 Subject: [PATCH 06/34] Add manual defaulting Signed-off-by: trangevi --- .../azure.ai.agents/internal/cmd/init.go | 44 ++++++++++++++----- 1 file changed, 32 insertions(+), 12 deletions(-) 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 2bc5f0a791b..40275c8098c 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -1484,6 +1484,20 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa } func (a *InitAction) populateContainerSettings(ctx context.Context) (*project.ContainerSettings, error) { + if a.flags.NoPrompt { + fmt.Printf("No prompt mode enabled, using default container settings\n") + return &project.ContainerSettings{ + Resources: &project.ResourceSettings{ + Memory: project.DefaultMemory, + Cpu: project.DefaultCpu, + }, + Scale: &project.ScaleSettings{ + MinReplicas: project.DefaultMinReplicas, + MaxReplicas: project.DefaultMaxReplicas, + }, + }, nil + } + // Default values defaultMemory := project.DefaultMemory defaultCpu := project.DefaultCpu @@ -2283,20 +2297,26 @@ func (a *InitAction) getModelDeploymentDetails(ctx context.Context, model agent_ return nil, fmt.Errorf("failed to get model details: %w", err) } - message := fmt.Sprintf("Enter model deployment name for model '%s' (defaults to model name)", modelDetails.Name) + modelDeployment := modelDetails.Name - modelDeploymentInput, err := a.azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ - Options: &azdext.PromptOptions{ - Message: message, - IgnoreHintKeys: true, - DefaultValue: modelDetails.Name, - }, - }) - if err != nil { - return nil, fmt.Errorf("failed to prompt for text value: %w", err) - } + if !a.flags.NoPrompt { + message := fmt.Sprintf("Enter model deployment name for model '%s' (defaults to model name)", modelDetails.Name) - modelDeployment := modelDeploymentInput.Value + modelDeploymentInput, err := a.azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ + Options: &azdext.PromptOptions{ + Message: message, + IgnoreHintKeys: true, + DefaultValue: modelDetails.Name, + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to prompt for text value: %w", err) + } + + modelDeployment = modelDeploymentInput.Value + } else { + fmt.Printf("No prompt mode enabled, using model name as deployment name: %s\n", modelDeployment) + } return &project.Deployment{ Name: modelDeployment, From 51f5df81198fcdacad9b8e24c52b87ccdbedba48 Mon Sep 17 00:00:00 2001 From: trangevi Date: Thu, 12 Feb 2026 15:43:23 -0800 Subject: [PATCH 07/34] Refactoring a bunch based on expected experience Signed-off-by: trangevi --- .../azure.ai.agents/internal/cmd/init.go | 289 +--- .../internal/cmd/init_from_code.go | 1214 +++++++++++++++++ 2 files changed, 1276 insertions(+), 227 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go index 40275c8098c..e747d663448 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -110,47 +110,58 @@ func newInitCommand(rootFlags rootFlagsDefinition) *cobra.Command { return fmt.Errorf("failed waiting for debugger: %w", err) } - azureContext, projectConfig, environment, err := ensureAzureContext(ctx, flags, azdClient) - if err != nil { - return fmt.Errorf("failed to ground into a project context: %w", err) - } + if flags.manifestPointer != "" { + azureContext, projectConfig, environment, err := ensureAzureContext(ctx, flags, azdClient) + if err != nil { + return fmt.Errorf("failed to ground into a project context: %w", err) + } - credential, err := azidentity.NewAzureDeveloperCLICredential(&azidentity.AzureDeveloperCLICredentialOptions{ - TenantID: azureContext.Scope.TenantId, - AdditionallyAllowedTenants: []string{"*"}, - }) - if err != nil { - return fmt.Errorf("failed to create azure credential: %w", err) - } + credential, err := azidentity.NewAzureDeveloperCLICredential(&azidentity.AzureDeveloperCLICredentialOptions{ + TenantID: azureContext.Scope.TenantId, + AdditionallyAllowedTenants: []string{"*"}, + }) + if err != nil { + return fmt.Errorf("failed to create azure credential: %w", err) + } - 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, - modelCatalogService: ai.NewModelCatalogService(credential), - projectConfig: projectConfig, - environment: environment, - flags: flags, - } + 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, + modelCatalogService: ai.NewModelCatalogService(credential), + projectConfig: projectConfig, + environment: environment, + flags: flags, + } + + if err := action.Run(ctx); err != nil { + return fmt.Errorf("failed to run start action: %w", err) + } + } else { + action := &InitFromCodeAction{ + azdClient: azdClient, + flags: flags, + } - if err := action.Run(ctx); err != nil { - return fmt.Errorf("failed to run start action: %w", err) + if err := action.Run(ctx); err != nil { + return fmt.Errorf("failed to run init from code action: %w", err) + } } return nil @@ -192,21 +203,21 @@ func (a *InitAction) Run(ctx context.Context) error { a.flags.src = relPath } - // 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.") - } - // 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 @@ -237,49 +248,8 @@ func (a *InitAction) Run(ctx context.Context) error { } color.Green("\nAI agent definition added to your azd project successfully!") - } else if a.flags.src != "" { - // No manifest pointer provided - process local agent code - // Create a manifest based on user prompts - localManifest, err := a.createManifestFromLocalAgent(ctx) - if err != nil { - return fmt.Errorf("failed to create manifest from local agent: %w", err) - } - - if localManifest != nil { - // Enable no-prompt mode since we've already collected all the input we need - a.flags.NoPrompt = true - - // Write the manifest to a file in the src directory - manifestPath, err := a.writeManifestToSrcDir(localManifest, a.flags.src) - if err != nil { - return fmt.Errorf("failed to write manifest to src directory: %w", err) - } - - // Use the manifest file path to download/process the agent - agentManifest, targetDir, err := a.downloadAgentYaml(ctx, manifestPath, a.flags.src) - if err != nil { - return fmt.Errorf("downloading agent.yaml from local manifest: %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) - } - - color.Green("\nLocal AI agent definition added to your azd project successfully!") - } } - // // Validate command flags - // if err := a.validateFlags(flags); err != nil { - // return err - // } - - // // Prompt for any missing input values - // if err := a.promptForMissingValues(ctx, a.azdClient, flags); err != nil { - // return fmt.Errorf("collecting required information: %w", err) - // } - return nil } @@ -909,141 +879,6 @@ func (a *InitAction) isRegistryUrl(manifestPointer string) (bool, *RegistryManif } } -// createManifestFromLocalAgent creates an AgentManifest for local agent code -// This is used when no manifest pointer is provided and we need to scaffold a new agent -func (a *InitAction) createManifestFromLocalAgent(ctx context.Context) (*agent_yaml.AgentManifest, error) { - // Use the current working directory name as the agent name, with punctuation removed and lowercased - cwd, err := os.Getwd() - if err != nil { - return nil, fmt.Errorf("failed to get current working directory: %w", err) - } - dirName := filepath.Base(cwd) - agentName := toCleanName(dirName) - - // TODO: Prompt user for agent kind - agentKind := agent_yaml.AgentKindHosted - - // Ask if user wants to select a model - var selectedModel string - wantsModel, err := a.azdClient.Prompt().Confirm(ctx, &azdext.ConfirmRequest{ - Options: &azdext.ConfirmOptions{ - Message: "Would you like to select a model for your agent?", - DefaultValue: to.Ptr(true), - }, - }) - if err != nil { - return nil, fmt.Errorf("failed to prompt for model selection choice: %w", err) - } - - if wantsModel.Value != nil && *wantsModel.Value { - // Prompt user to select a model from the catalog - modelCatalog, err := a.modelCatalogService.ListAllModels(ctx, a.azureContext.Scope.SubscriptionId, a.azureContext.Scope.Location) - if err != nil { - return nil, fmt.Errorf("failed to list models from catalog: %w", err) - } - - // Build model choices from the catalog - var modelChoices []*azdext.SelectChoice - var modelNames []string - for modelName := range modelCatalog { - modelNames = append(modelNames, modelName) - } - slices.Sort(modelNames) - for _, modelName := range modelNames { - modelChoices = append(modelChoices, &azdext.SelectChoice{ - Label: modelName, - Value: modelName, - }) - } - - modelResp, err := a.azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ - Options: &azdext.SelectOptions{ - Message: "Select a model for your agent:", - Choices: modelChoices, - }, - }) - if err != nil { - return nil, fmt.Errorf("failed to prompt for model selection: %w", err) - } - selectedModel = modelNames[*modelResp.Value] - } - - // Build resources list - var resources []any - if selectedModel != "" { - resources = append(resources, agent_yaml.ModelResource{ - Resource: agent_yaml.Resource{ - Name: selectedModel, - Kind: agent_yaml.ResourceKindModel, - }, - Id: selectedModel, - }) - } - - // Create a minimal AgentManifest with the Template as a ContainerAgent - manifest := &agent_yaml.AgentManifest{ - Name: agentName, - Template: agent_yaml.ContainerAgent{ - AgentDefinition: agent_yaml.AgentDefinition{ - Name: agentName, - Kind: agentKind, - }, - Protocols: []agent_yaml.ProtocolVersionRecord{ - { - Protocol: "responses", - Version: "v1", - }, - }, - EnvironmentVariables: &[]agent_yaml.EnvironmentVariable{ - { - Name: "AZURE_OPENAI_ENDPOINT", - Value: "${AZURE_OPENAI_ENDPOINT}", - }, - { - Name: "AZURE_AI_MODEL_DEPLOYMENT_NAME", - Value: "{{" + selectedModel + "}}", - }, - }, - }, - Resources: resources, - } - - return manifest, nil -} - -// toCleanName cleans a name by keeping alphanumeric and dashes, replacing underscores/spaces with dashes, and lowercasing -func toCleanName(s string) string { - // Keep alphanumeric characters and dashes; replace underscores and spaces with dashes - var result strings.Builder - for _, r := range s { - if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' { - result.WriteRune(r) - } else if r == '_' || r == ' ' { - result.WriteRune('-') - } - } - return strings.ToLower(result.String()) -} - -// writeManifestToSrcDir writes an AgentManifest to a YAML file in the src directory and returns the path -func (a *InitAction) writeManifestToSrcDir(manifest *agent_yaml.AgentManifest, srcDir string) (string, error) { - // Create the manifest file path - manifestPath := filepath.Join(srcDir, "agent.yaml") - - // Marshal the manifest to YAML - content, err := yaml.Marshal(manifest) - if err != nil { - return "", fmt.Errorf("marshaling manifest to YAML: %w", err) - } - - // Write to the file - if err := os.WriteFile(manifestPath, content, 0644); err != nil { - return "", fmt.Errorf("writing manifest to file: %w", err) - } - - return manifestPath, nil -} - func (a *InitAction) downloadAgentYaml( ctx context.Context, manifestPointer string, targetDir string) (*agent_yaml.AgentManifest, string, error) { if manifestPointer == "" { 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 new file mode 100644 index 00000000000..21a2ced83eb --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go @@ -0,0 +1,1214 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "azureaiagent/internal/pkg/agents/agent_yaml" + "azureaiagent/internal/pkg/azure" + "azureaiagent/internal/pkg/azure/ai" + "azureaiagent/internal/project" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "slices" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/ux" + "github.com/fatih/color" + "google.golang.org/protobuf/types/known/structpb" + "gopkg.in/yaml.v3" +) + +type InitFromCodeAction struct { + azdClient *azdext.AzdClient + flags *initFlags + projectConfig *azdext.ProjectConfig + azureContext *azdext.AzureContext + environment *azdext.Environment + credential azcore.TokenCredential + modelCatalog map[string]*ai.AiModel + modelCatalogService *ai.ModelCatalogService + deploymentDetails []project.Deployment +} + +// templateFileInfo represents a file from the GitHub template repository. +type templateFileInfo struct { + Path string // Relative path in the repo + URL string // Download URL for the file content + Collides bool // Whether the file already exists locally +} + +func (a *InitFromCodeAction) Run(ctx context.Context) error { + var err error + a.projectConfig, err = a.ensureProject(ctx) + if err != nil { + return fmt.Errorf("failed to ensure project: %w", err) + } + + a.azureContext = &azdext.AzureContext{ + Scope: &azdext.AzureScope{}, + Resources: []string{}, + } + + // If src path is absolute, convert it to relative path compared to the azd project path + if a.flags.src != "" && filepath.IsAbs(a.flags.src) { + projectResponse, err := a.azdClient.Project().Get(ctx, &azdext.EmptyRequest{}) + if err != nil { + return fmt.Errorf("failed to get project path: %w", err) + } + + relPath, err := filepath.Rel(projectResponse.Project.Path, a.flags.src) + if err != nil { + return fmt.Errorf("failed to convert src path to relative path: %w", err) + } + a.flags.src = relPath + } + + // No manifest pointer provided - process local agent code + // Create a definition based on user prompts + localDefinition, err := a.createDefinitionFromLocalAgent(ctx) + if err != nil { + return fmt.Errorf("failed to create definition from local agent: %w", err) + } + + if localDefinition != nil { + // Default src to current directory when not specified + srcDir := a.flags.src + if srcDir == "" { + srcDir = "." + } + + // Write the definition to a file in the src directory + _, err := a.writeDefinitionToSrcDir(localDefinition, srcDir) + if err != nil { + return fmt.Errorf("failed to write definition to src directory: %w", err) + } + + // Add the agent to the azd project (azure.yaml) services + if err := a.addToProject(ctx, srcDir, localDefinition.Name, a.flags.host); err != nil { + return fmt.Errorf("failed to add agent to azure.yaml: %w", err) + } + + if srcDir == "." { + fmt.Printf(" %s %s\n", color.GreenString("+"), color.GreenString("agent.yaml")) + } else { + fmt.Printf(" %s %s\n", color.GreenString("+"), color.GreenString("%s/agent.yaml", srcDir)) + } + + 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")) + } + + return nil +} + +func (a *InitFromCodeAction) ensureProject(ctx context.Context) (*azdext.ProjectConfig, error) { + projectResponse, err := a.azdClient.Project().Get(ctx, &azdext.EmptyRequest{}) + if err != nil { + fmt.Println("Lets get your project initialized.") + + if err := a.scaffoldTemplate(ctx, a.azdClient, "therealjohn/azd-ai-starter-basic", "main"); err != nil { + return nil, fmt.Errorf("failed to scaffold template: %w", err) + } + + projectResponse, err = a.azdClient.Project().Get(ctx, &azdext.EmptyRequest{}) + if err != nil { + return nil, fmt.Errorf("failed to get project: %w", err) + } + + fmt.Println() + } + + if projectResponse.Project == nil { + return nil, fmt.Errorf("project not found") + } + + return projectResponse.Project, nil +} + +// scaffoldTemplate downloads a GitHub template repo into the current directory, +// checking for file collisions before writing. Files that don't collide are shown +// in green; colliding files are shown in yellow and the user is prompted for how +// to handle them. +func (a *InitFromCodeAction) scaffoldTemplate(ctx context.Context, azdClient *azdext.AzdClient, repoSlug string, branch string) error { + // 1. Fetch the recursive file tree from GitHub + apiUrl := fmt.Sprintf("https://api.github.com/repos/%s/git/trees/%s?recursive=1", repoSlug, branch) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiUrl, nil) + if err != nil { + return fmt.Errorf("creating tree request: %w", err) + } + req.Header.Set("Accept", "application/vnd.github.v3+json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("fetching repo tree: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("fetching repo tree: status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("reading tree response: %w", err) + } + + var treeResp struct { + Tree []struct { + Path string `json:"path"` + Type string `json:"type"` // "blob" or "tree" + } `json:"tree"` + } + if err := json.Unmarshal(body, &treeResp); err != nil { + return fmt.Errorf("parsing tree response: %w", err) + } + + // Collect only files (blobs) + var files []templateFileInfo + for _, entry := range treeResp.Tree { + if entry.Type != "blob" { + continue + } + downloadURL := fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s", repoSlug, branch, entry.Path) + collides := false + if _, statErr := os.Stat(entry.Path); statErr == nil { + collides = true + } + files = append(files, templateFileInfo{ + Path: entry.Path, + URL: downloadURL, + Collides: collides, + }) + } + + if len(files) == 0 { + return fmt.Errorf("template repository %s has no files", repoSlug) + } + + // Sort by path for consistent display + slices.SortFunc(files, func(a, b templateFileInfo) int { + return strings.Compare(a.Path, b.Path) + }) + + // 2. Classify into new and colliding + var newFiles, collidingFiles []templateFileInfo + for _, f := range files { + if f.Collides { + collidingFiles = append(collidingFiles, f) + } else { + newFiles = append(newFiles, f) + } + } + + // 3. Display the file list + fmt.Print("\nThe following files will be created from the starter template:\n\n") + for _, f := range files { + if f.Collides { + fmt.Printf(" %s %s\n", color.YellowString("!"), color.YellowString(f.Path)) + } else { + fmt.Printf(" %s %s\n", color.GreenString("+"), color.GreenString(f.Path)) + } + } + fmt.Println() + + // 4. If there are collisions, show warning and prompt for resolution + overwriteCollisions := false + if len(collidingFiles) > 0 { + fmt.Printf("%s %d file(s) already exist and would be overwritten.\n\n", + color.YellowString("Warning:"), len(collidingFiles)) + + conflictChoices := []*azdext.SelectChoice{ + {Label: "Overwrite existing files", Value: "overwrite"}, + {Label: "Skip existing files (keep my versions)", Value: "skip"}, + {Label: "Cancel", Value: "cancel"}, + } + + conflictResp, err := azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: "How would you like to handle existing files?", + Choices: conflictChoices, + }, + }) + if err != nil { + return fmt.Errorf("prompting for conflict resolution: %w", err) + } + + selectedValue := conflictChoices[*conflictResp.Value].Value + switch selectedValue { + case "overwrite": + overwriteCollisions = true + case "skip": + overwriteCollisions = false + case "cancel": + return fmt.Errorf("operation cancelled by user") + } + } else { + // No collisions - confirm to proceed + confirmResp, err := azdClient.Prompt().Confirm(ctx, &azdext.ConfirmRequest{ + Options: &azdext.ConfirmOptions{ + Message: "Initialize the starter template?", + DefaultValue: to.Ptr(true), + }, + }) + if err != nil { + return fmt.Errorf("prompting for confirmation: %w", err) + } + if !*confirmResp.Value { + return fmt.Errorf("operation cancelled by user") + } + } + + // 5. Download and write files + filesToWrite := newFiles + if overwriteCollisions { + filesToWrite = files + } + + spinner := ux.NewSpinner(&ux.SpinnerOptions{ + Text: fmt.Sprintf("Downloading template (%d files)...", len(filesToWrite)), + ClearOnStop: true, + }) + if err := spinner.Start(ctx); err != nil { + return fmt.Errorf("starting spinner: %w", err) + } + + for _, f := range filesToWrite { + // Create parent directories + dir := filepath.Dir(f.Path) + if dir != "." { + if err := os.MkdirAll(dir, 0755); err != nil { + _ = spinner.Stop(ctx) + return fmt.Errorf("creating directory %s: %w", dir, err) + } + } + + // Download file content + fileReq, err := http.NewRequestWithContext(ctx, http.MethodGet, f.URL, nil) + if err != nil { + _ = spinner.Stop(ctx) + return fmt.Errorf("creating request for %s: %w", f.Path, err) + } + + fileResp, err := http.DefaultClient.Do(fileReq) + if err != nil { + _ = spinner.Stop(ctx) + return fmt.Errorf("downloading %s: %w", f.Path, err) + } + + content, err := io.ReadAll(fileResp.Body) + fileResp.Body.Close() + if err != nil { + _ = spinner.Stop(ctx) + return fmt.Errorf("reading %s: %w", f.Path, err) + } + + if fileResp.StatusCode != http.StatusOK { + _ = spinner.Stop(ctx) + return fmt.Errorf("downloading %s: status %d", f.Path, fileResp.StatusCode) + } + + if err := os.WriteFile(f.Path, content, 0644); err != nil { + _ = spinner.Stop(ctx) + return fmt.Errorf("writing %s: %w", f.Path, err) + } + } + + if err := spinner.Stop(ctx); err != nil { + return fmt.Errorf("stopping spinner: %w", err) + } + + skipped := len(files) - len(filesToWrite) + if skipped > 0 { + fmt.Printf(" Template initialized: %d file(s) written, %d file(s) skipped.\n", len(filesToWrite), skipped) + } else { + fmt.Printf(" Template initialized: %d file(s) written.\n", len(filesToWrite)) + } + + return nil +} + +// createDefinitionFromLocalAgent creates a ContainerAgent for local agent code +// This is used when no manifest pointer is provided and we need to scaffold a new agent +func (a *InitFromCodeAction) createDefinitionFromLocalAgent(ctx context.Context) (*agent_yaml.ContainerAgent, error) { + // Default agent name to sanitized cwd + defaultName := "my-agent" + if cwd, err := os.Getwd(); err == nil { + defaultName = sanitizeAgentName(filepath.Base(cwd)) + } + + // Prompt user for agent name + promptResp, err := a.azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ + Options: &azdext.PromptOptions{ + Message: "Enter a name for your agent:", + DefaultValue: defaultName, + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to prompt for agent name: %w", err) + } + agentName := promptResp.Value + + // Create the azd environment now that we have the agent name + if a.environment == nil { + if err := a.createEnvironment(ctx, agentName+"-dev"); err != nil { + return nil, fmt.Errorf("failed to create azd environment: %w", err) + } + } + + // TODO: Prompt user for agent kind + agentKind := agent_yaml.AgentKindHosted + + // Ask user how they want to configure a model + modelConfigChoices := []*azdext.SelectChoice{ + {Label: "Deploy a new model from the catalog", Value: "new"}, + {Label: "Select an existing model deployment from a Foundry project", Value: "existing"}, + {Label: "Skip model configuration", Value: "skip"}, + } + + modelConfigResp, err := a.azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: "How would you like to configure a model for your agent?", + Choices: modelConfigChoices, + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to prompt for model configuration choice: %w", err) + } + modelConfigChoice := modelConfigChoices[*modelConfigResp.Value].Value + + var selectedModel string + var existingDeployment *FoundryDeploymentInfo + + switch modelConfigChoice { + case "new": + // Path A: Deploy a new model from the catalog + // Need subscription + location for model catalog + if err := a.ensureSubscriptionAndLocation(ctx); err != nil { + return nil, err + } + + selectedModel, err = a.selectNewModel(ctx) + if err != nil { + return nil, fmt.Errorf("failed to select new model: %w", err) + } + + 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 { + return nil, err + } + + spinner := ux.NewSpinner(&ux.SpinnerOptions{ + Text: "Searching for Foundry projects in your subscription...", + 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 + } + if err != nil { + return nil, fmt.Errorf("failed to list Foundry projects: %w", 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 + } + + selectedModel, err = a.selectNewModel(ctx) + if err != nil { + return nil, fmt.Errorf("failed to select new model: %w", err) + } + } else { + // 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 + if selectedIdx < int32(len(projects)) { + // User selected an existing Foundry project + selectedProject := projects[*projectResp.Value] + + // Set the Foundry project context + a.azureContext.Scope.Location = selectedProject.Location + if err := a.setEnvVar(ctx, "AZURE_AI_PROJECT_ID", selectedProject.ResourceId); err != nil { + return nil, err + } + if err := a.setEnvVar(ctx, "AZURE_RESOURCE_GROUP", selectedProject.ResourceGroupName); err != nil { + return nil, err + } + if err := a.setEnvVar(ctx, "AZURE_AI_ACCOUNT_NAME", selectedProject.AccountName); err != nil { + return nil, err + } + if err := a.setEnvVar(ctx, "AZURE_AI_PROJECT_NAME", selectedProject.ProjectName); err != nil { + return nil, err + } + if err := a.setEnvVar(ctx, "AZURE_LOCATION", selectedProject.Location); err != nil { + return nil, err + } + + // Set the Microsoft Foundry endpoint URL + aiFoundryEndpoint := fmt.Sprintf("https://%s.services.ai.azure.com/api/projects/%s", selectedProject.AccountName, selectedProject.ProjectName) + if err := a.setEnvVar(ctx, "AZURE_AI_PROJECT_ENDPOINT", aiFoundryEndpoint); err != nil { + return nil, err + } + + aoaiEndpoint := fmt.Sprintf("https://%s.openai.azure.com/", selectedProject.AccountName) + if err := a.setEnvVar(ctx, "AZURE_OPENAI_ENDPOINT", aoaiEndpoint); err != nil { + return nil, 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 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.") + } + + // 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 { + return nil, fmt.Errorf("failed to prompt for deployment selection: %w", err) + } + + selectedIdx := *deployResp.Value + if selectedIdx < int32(len(deployments)) { + // User selected an existing deployment + d := deployments[selectedIdx] + existingDeployment = &d + selectedModel = d.ModelName + 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) + } + } + } else { + // User wants a new Foundry project + if err := a.ensureLocation(ctx); err != nil { + return nil, err + } + + selectedModel, err = a.selectNewModel(ctx) + if err != nil { + return nil, fmt.Errorf("failed to select new model: %w", err) + } + } + + } + + case "skip": + // Path C: Skip model configuration entirely + } + + // Create a minimal Agent Definition + definition := &agent_yaml.ContainerAgent{ + AgentDefinition: agent_yaml.AgentDefinition{ + Name: agentName, + Kind: agentKind, + }, + Protocols: []agent_yaml.ProtocolVersionRecord{ + { + Protocol: "responses", + Version: "v1", + }, + }, + EnvironmentVariables: &[]agent_yaml.EnvironmentVariable{ + { + Name: "AZURE_OPENAI_ENDPOINT", + Value: "${AZURE_OPENAI_ENDPOINT}", + }, + }, + } + + // Add model resource if a model was selected + if selectedModel != "" { + if existingDeployment == nil { + modelDetails, err := a.getModelDeploymentDetails(ctx, selectedModel) + if err != nil { + return nil, fmt.Errorf("failed to get model deployment details: %w", err) + } + + a.deploymentDetails = append(a.deploymentDetails, project.Deployment{ + Name: modelDetails.Name, + Model: project.DeploymentModel{ + Name: modelDetails.Name, + Format: modelDetails.Format, + Version: modelDetails.Version, + }, + Sku: project.DeploymentSku{ + Name: modelDetails.Sku.Name, + Capacity: int(modelDetails.Sku.Capacity), + }, + }) + + *definition.EnvironmentVariables = append(*definition.EnvironmentVariables, agent_yaml.EnvironmentVariable{ + Name: "AZURE_AI_MODEL_DEPLOYMENT_NAME", + Value: modelDetails.Name, + }) + } else { + // For existing deployments, store the deployment details directly + a.deploymentDetails = append(a.deploymentDetails, project.Deployment{ + Name: existingDeployment.Name, + Model: project.DeploymentModel{ + Name: existingDeployment.ModelName, + Format: existingDeployment.ModelFormat, + Version: existingDeployment.Version, + }, + Sku: project.DeploymentSku{ + Name: existingDeployment.SkuName, + Capacity: existingDeployment.SkuCapacity, + }, + }) + + *definition.EnvironmentVariables = append(*definition.EnvironmentVariables, agent_yaml.EnvironmentVariable{ + Name: "AZURE_AI_MODEL_DEPLOYMENT_NAME", + Value: existingDeployment.Name, + }) + } + } + + return definition, nil +} + +// sanitizeAgentName converts a string into a valid agent name: +// lowercase, replace non-alphanumeric with hyphens, collapse consecutive hyphens, +// strip leading/trailing hyphens, truncate to 63 chars. +func sanitizeAgentName(name string) string { + name = strings.ToLower(name) + // Replace any character that isn't a-z, 0-9, or hyphen with a hyphen + re := regexp.MustCompile(`[^a-z0-9-]+`) + name = re.ReplaceAllString(name, "-") + // Collapse consecutive hyphens + re = regexp.MustCompile(`-{2,}`) + name = re.ReplaceAllString(name, "-") + // Strip leading/trailing hyphens + name = strings.Trim(name, "-") + // Truncate to 63 chars + if len(name) > 63 { + name = name[:63] + name = strings.TrimRight(name, "-") + } + if name == "" { + name = "my-agent" + } + 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 { + return fmt.Errorf("failed to prompt for subscription: %w", err) + } + + a.azureContext.Scope.SubscriptionId = subscriptionResponse.Subscription.Id + a.azureContext.Scope.TenantId = subscriptionResponse.Subscription.TenantId + + // Persist to environment + _, 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) + } + + _, 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) + } + + // Refresh credential with the tenant + credential, err := azidentity.NewAzureDeveloperCLICredential(&azidentity.AzureDeveloperCLICredentialOptions{ + TenantID: a.azureContext.Scope.TenantId, + AdditionallyAllowedTenants: []string{"*"}, + }) + if err != nil { + return fmt.Errorf("failed to create azure credential: %w", err) + } + a.credential = credential + a.modelCatalogService = ai.NewModelCatalogService(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 { + return fmt.Errorf("failed to prompt for location: %w", err) + } + + 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) (string, error) { + var err error + if a.modelCatalog == nil { + spinner := ux.NewSpinner(&ux.SpinnerOptions{ + Text: "Loading model catalog...", + ClearOnStop: true, + }) + if err := spinner.Start(ctx); err != nil { + return "", fmt.Errorf("failed to start spinner: %w", err) + } + + a.modelCatalog, err = a.modelCatalogService.ListAllModels(ctx, a.azureContext.Scope.SubscriptionId, a.azureContext.Scope.Location) + if err != nil { + return "", fmt.Errorf("failed to list models from catalog: %w", err) + } + } + + var modelNames []string + for modelName := range a.modelCatalog { + modelNames = append(modelNames, modelName) + } + + selectedModel, err := a.promptForModelWithSearch(ctx, modelNames) + if err != nil { + return "", err + } + + return selectedModel, nil +} + +// promptForModelWithSearch prompts the user with a text search field, then shows a filtered Select list. +// Returns the selected model name. +func (a *InitFromCodeAction) promptForModelWithSearch(ctx context.Context, modelNames []string) (string, error) { + for { + // Prompt user for a search term + searchResp, err := a.azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ + Options: &azdext.PromptOptions{ + Message: "Search for a model (e.g., gpt-4o) or press Enter to see all models:", + }, + }) + if err != nil { + return "", fmt.Errorf("failed to prompt for model search: %w", err) + } + + filtered := fuzzyFilterModels(modelNames, searchResp.Value) + if len(filtered) == 0 { + fmt.Printf("No models matching '%s'. Please try again.\n", searchResp.Value) + continue + } + + slices.Sort(filtered) + + defaultIndex := findDefaultModelIndex(filtered) + + choices := make([]*azdext.SelectChoice, len(filtered)) + for i, name := range filtered { + choices[i] = &azdext.SelectChoice{ + Label: name, + Value: name, + } + } + + modelResp, err := a.azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: "Select a model:", + Choices: choices, + SelectedIndex: &defaultIndex, + }, + }) + if err != nil { + return "", fmt.Errorf("failed to prompt for model selection: %w", err) + } + + return filtered[*modelResp.Value], nil + } +} + +// normalizeForFuzzyMatch strips common separator characters (hyphens, dots, spaces, underscores) +// and lowercases the string for fuzzy comparison. +func normalizeForFuzzyMatch(s string) string { + s = strings.ToLower(s) + re := regexp.MustCompile(`[-.\s_]+`) + return re.ReplaceAllString(s, "") +} + +// fuzzyFilterModels filters model names by a search term using normalized comparison. +// The search term and model names both have separators stripped before matching. +func fuzzyFilterModels(modelNames []string, searchTerm string) []string { + if searchTerm == "" { + return modelNames + } + normalizedSearch := normalizeForFuzzyMatch(searchTerm) + if normalizedSearch == "" { + return modelNames + } + + // Build a regex pattern from the normalized search term + pattern, err := regexp.Compile("(?i)" + regexp.QuoteMeta(normalizedSearch)) + if err != nil { + // Fallback to simple contains if regex fails + var matches []string + for _, name := range modelNames { + if strings.Contains(normalizeForFuzzyMatch(name), normalizedSearch) { + matches = append(matches, name) + } + } + return matches + } + + var matches []string + for _, name := range modelNames { + if pattern.MatchString(normalizeForFuzzyMatch(name)) { + matches = append(matches, name) + } + } + return matches +} + +// findDefaultModelIndex finds the index of gpt-4o in a sorted model list, +// falling back to the first gpt-4 match, or 0. +func findDefaultModelIndex(modelNames []string) int32 { + // Look for exact gpt-4o first + for i, name := range modelNames { + if name == "gpt-4o" { + return int32(i) + } + } + // Fall back to first gpt-4 match + for i, name := range modelNames { + if strings.HasPrefix(name, "gpt-4") { + return int32(i) + } + } + 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 +} + +// 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 { + projName = *proj.Name + } + 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) + } + + fmt.Printf("Set environment variable: %s=%s\n", key, value) + return nil +} + +// 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 + if err := os.MkdirAll(srcDir, 0755); err != nil { + return "", fmt.Errorf("creating src directory: %w", err) + } + + // Create the definition file path + definitionPath := filepath.Join(srcDir, "agent.yaml") + + // Marshal the definition to YAML + content, err := yaml.Marshal(definition) + if err != nil { + return "", fmt.Errorf("marshaling definition to YAML: %w", err) + } + + // Write to the file + if err := os.WriteFile(definitionPath, content, 0644); err != nil { + return "", fmt.Errorf("writing definition to file: %w", err) + } + + return definitionPath, nil +} + +func (a *InitFromCodeAction) addToProject(ctx context.Context, targetDir string, agentName string, host string) error { + var agentConfig = project.ServiceTargetAgentConfig{} + + agentConfig.Container = &project.ContainerSettings{ + Resources: &project.ResourceSettings{ + Memory: project.DefaultMemory, + Cpu: project.DefaultCpu, + }, + Scale: &project.ScaleSettings{ + MinReplicas: project.DefaultMinReplicas, + MaxReplicas: project.DefaultMaxReplicas, + }, + } + + agentConfig.Deployments = a.deploymentDetails + + var agentConfigStruct *structpb.Struct + var err error + if agentConfigStruct, err = project.MarshalStruct(&agentConfig); err != nil { + return fmt.Errorf("failed to marshal agent config: %w", err) + } + + serviceConfig := &azdext.ServiceConfig{ + Name: strings.ReplaceAll(agentName, " ", ""), + RelativePath: targetDir, + Host: AiAgentHost, + Language: "docker", + Config: agentConfigStruct, + } + + // For hosted (container-based) agents, set remoteBuild to true by default + serviceConfig.Docker = &azdext.DockerProjectOptions{ + RemoteBuild: true, + } + + req := &azdext.AddServiceRequest{Service: serviceConfig} + + if _, err := a.azdClient.Project().AddService(ctx, req); err != nil { + return fmt.Errorf("adding agent service to project: %w", err) + } + + 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) getModelDeploymentDetails(ctx context.Context, modelName string) (*ai.AiModelDeployment, error) { + var model *ai.AiModel + model, _ = a.modelCatalog[modelName] + + _, defaultVersion, err := a.modelCatalogService.ListModelVersions(ctx, model, a.azureContext.Scope.Location) + if err != nil { + return nil, fmt.Errorf("listing versions for model '%s': %w", model.Name, err) + } + + availableSkus, err := a.modelCatalogService.ListModelSkus(ctx, model, a.azureContext.Scope.Location, defaultVersion) + if err != nil { + return nil, fmt.Errorf("listing SKUs for model '%s': %w", model.Name, err) + } + + // Determine default SKU based on priority list + defaultSku := "" + for _, sku := range defaultSkuPriority { + if slices.Contains(availableSkus, sku) { + defaultSku = sku + break + } + } + + deploymentOptions := ai.AiModelDeploymentOptions{ + Versions: []string{defaultVersion}, + Skus: []string{defaultSku}, + } + + modelDeployment, err := a.modelCatalogService.GetModelDeployment(ctx, model, &deploymentOptions) + if err != nil { + return nil, fmt.Errorf("failed to get model deployment: %w", err) + } + + if modelDeployment.Sku.Capacity == -1 { + modelDeployment.Sku.Capacity = 10 + } + + return modelDeployment, nil +} From fc1d695bd5a5e29c1e85a1d7bd0ccf3ec440533e Mon Sep 17 00:00:00 2001 From: trangevi Date: Fri, 13 Feb 2026 08:29:12 -0800 Subject: [PATCH 08/34] broken spinner Signed-off-by: trangevi --- .../azure.ai.agents/internal/cmd/init_from_code.go | 6 ++++++ 1 file changed, 6 insertions(+) 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 21a2ced83eb..e9bf96459c0 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 @@ -506,6 +506,9 @@ func (a *InitFromCodeAction) createDefinitionFromLocalAgent(ctx context.Context) // 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) } @@ -798,6 +801,9 @@ func (a *InitFromCodeAction) selectNewModel(ctx context.Context) (string, error) } a.modelCatalog, err = a.modelCatalogService.ListAllModels(ctx, a.azureContext.Scope.SubscriptionId, a.azureContext.Scope.Location) + if stopErr := spinner.Stop(ctx); stopErr != nil { + return "", stopErr + } if err != nil { return "", fmt.Errorf("failed to list models from catalog: %w", err) } From c6581fa2d6d6871f35ebfc7ed3b030dc65804921 Mon Sep 17 00:00:00 2001 From: trangevi Date: Fri, 13 Feb 2026 08:50:26 -0800 Subject: [PATCH 09/34] Remove env var print --- .../extensions/azure.ai.agents/internal/cmd/init_from_code.go | 1 - 1 file changed, 1 deletion(-) 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 e9bf96459c0..d24d255499f 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 @@ -1101,7 +1101,6 @@ func (a *InitFromCodeAction) setEnvVar(ctx context.Context, key, value string) e 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 } From e96d6b1a2e78701ee32fd3f5accff4b86a59c775 Mon Sep 17 00:00:00 2001 From: trangevi Date: Fri, 13 Feb 2026 11:50:59 -0800 Subject: [PATCH 10/34] Add support for --project-id Signed-off-by: trangevi --- .../internal/cmd/init_from_code.go | 386 ++++++++++++++---- 1 file changed, 305 insertions(+), 81 deletions(-) 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 d24d255499f..09420192751 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 @@ -24,6 +24,7 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices" "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/protobuf/types/known/structpb" @@ -377,16 +378,24 @@ func (a *InitFromCodeAction) createDefinitionFromLocalAgent(ctx context.Context) {Label: "Skip model configuration", Value: "skip"}, } - modelConfigResp, err := a.azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ - Options: &azdext.SelectOptions{ - Message: "How would you like to configure a model for your agent?", - Choices: modelConfigChoices, - }, - }) - if err != nil { - return nil, fmt.Errorf("failed to prompt for model configuration choice: %w", err) + var modelConfigChoice string + if a.flags.projectResourceId == "" { + modelConfigResp, err := a.azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: "How would you like to configure a model for your agent?", + Choices: modelConfigChoices, + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to prompt for model configuration choice: %w", err) + } + modelConfigChoice = modelConfigChoices[*modelConfigResp.Value].Value + } else { + // If projectResourceId is provided, skip the prompt and default to existing deployment selection + modelConfigChoice = "existing" + + a.azureContext.Scope.SubscriptionId = extractSubscriptionId(a.flags.projectResourceId) } - modelConfigChoice := modelConfigChoices[*modelConfigResp.Value].Value var selectedModel string var existingDeployment *FoundryDeploymentInfo @@ -411,8 +420,13 @@ func (a *InitFromCodeAction) createDefinitionFromLocalAgent(ctx context.Context) return nil, err } + 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: "Searching for Foundry projects in your subscription...", + Text: spinnerText, ClearOnStop: true, }) if err := spinner.Start(ctx); err != nil { @@ -439,61 +453,56 @@ func (a *InitFromCodeAction) createDefinitionFromLocalAgent(ctx context.Context) return nil, fmt.Errorf("failed to select new model: %w", err) } } else { - // 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), + 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__", - }) + 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) + 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") + } } - selectedIdx := *projectResp.Value if selectedIdx < int32(len(projects)) { // User selected an existing Foundry project - selectedProject := projects[*projectResp.Value] + selectedProject := projects[selectedIdx] // Set the Foundry project context a.azureContext.Scope.Location = selectedProject.Location - if err := a.setEnvVar(ctx, "AZURE_AI_PROJECT_ID", selectedProject.ResourceId); err != nil { - return nil, err - } - if err := a.setEnvVar(ctx, "AZURE_RESOURCE_GROUP", selectedProject.ResourceGroupName); err != nil { - return nil, err - } - if err := a.setEnvVar(ctx, "AZURE_AI_ACCOUNT_NAME", selectedProject.AccountName); err != nil { - return nil, err - } - if err := a.setEnvVar(ctx, "AZURE_AI_PROJECT_NAME", selectedProject.ProjectName); err != nil { - return nil, err - } - if err := a.setEnvVar(ctx, "AZURE_LOCATION", selectedProject.Location); err != nil { - return nil, err - } - - // Set the Microsoft Foundry endpoint URL - aiFoundryEndpoint := fmt.Sprintf("https://%s.services.ai.azure.com/api/projects/%s", selectedProject.AccountName, selectedProject.ProjectName) - if err := a.setEnvVar(ctx, "AZURE_AI_PROJECT_ENDPOINT", aiFoundryEndpoint); err != nil { - return nil, err - } - aoaiEndpoint := fmt.Sprintf("https://%s.openai.azure.com/", selectedProject.AccountName) - if err := a.setEnvVar(ctx, "AZURE_OPENAI_ENDPOINT", aoaiEndpoint); err != nil { - return nil, 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{ @@ -730,37 +739,45 @@ func (a *InitFromCodeAction) ensureSubscription(ctx context.Context) error { a.azureContext.Scope.SubscriptionId = subscriptionResponse.Subscription.Id a.azureContext.Scope.TenantId = subscriptionResponse.Subscription.TenantId - - // Persist to environment - _, err = a.azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ - EnvName: a.environment.Name, - Key: "AZURE_TENANT_ID", - Value: a.azureContext.Scope.TenantId, + } else { + tenantResponse, err := a.azdClient.Account().LookupTenant(ctx, &azdext.LookupTenantRequest{ + SubscriptionId: a.azureContext.Scope.SubscriptionId, }) if err != nil { - return fmt.Errorf("failed to set AZURE_TENANT_ID in environment: %w", err) + return fmt.Errorf("failed to lookup tenant: %w", err) } + a.azureContext.Scope.TenantId = tenantResponse.TenantId + } - _, 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) - } + // 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) + } - // Refresh credential with the tenant - credential, err := azidentity.NewAzureDeveloperCLICredential(&azidentity.AzureDeveloperCLICredentialOptions{ - TenantID: a.azureContext.Scope.TenantId, - AdditionallyAllowedTenants: []string{"*"}, - }) - if err != nil { - return fmt.Errorf("failed to create azure credential: %w", err) - } - a.credential = credential - a.modelCatalogService = ai.NewModelCatalogService(credential) + _, 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 fmt.Errorf("failed to create azure credential: %w", err) } + a.credential = credential + a.modelCatalogService = ai.NewModelCatalogService(credential) return nil } @@ -938,6 +955,17 @@ type FoundryProjectInfo struct { 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, "/") @@ -1011,7 +1039,14 @@ func (a *InitFromCodeAction) listFoundryProjects(ctx context.Context, subscripti for _, proj := range projectPage.Value { projName := "" if proj.Name != nil { - projName = *proj.Name + // 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 { @@ -1217,3 +1252,192 @@ func (a *InitFromCodeAction) getModelDeploymentDetails(ctx context.Context, mode return modelDeployment, nil } + +func (a *InitFromCodeAction) processExistingFoundryProject(ctx context.Context, foundryProject FoundryProjectInfo) error { + + 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.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 := azure.NewFoundryProjectsClient(foundryProject.AccountName, foundryProject.ProjectName, a.credential) + 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(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 + } + } + } + } + + fmt.Printf("Successfully parsed and set environment variables from Microsoft Foundry project ID\n") + return nil +} From 099f4a193b4478d6362aca551267e12cb8c57660 Mon Sep 17 00:00:00 2001 From: trangevi Date: Tue, 17 Feb 2026 10:22:51 -0800 Subject: [PATCH 11/34] Missing env var Signed-off-by: trangevi --- .../extensions/azure.ai.agents/internal/cmd/init_from_code.go | 1 + 1 file changed, 1 insertion(+) 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 09420192751..62c90b3ebbc 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 @@ -499,6 +499,7 @@ func (a *InitFromCodeAction) createDefinitionFromLocalAgent(ctx context.Context) // Set the Foundry project context a.azureContext.Scope.Location = selectedProject.Location + a.setEnvVar(ctx, "AZURE_LOCATION", selectedProject.Location) err := a.processExistingFoundryProject(ctx, selectedProject) if err != nil { From e5729a91ceaefc4dcb0207ab89748a92967585da Mon Sep 17 00:00:00 2001 From: trangevi Date: Wed, 18 Feb 2026 08:33:55 -0800 Subject: [PATCH 12/34] Address feedback Signed-off-by: trangevi --- .../internal/cmd/init_from_code.go | 46 +++++++++++-------- 1 file changed, 27 insertions(+), 19 deletions(-) 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 62c90b3ebbc..d25ba884c4c 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 @@ -24,7 +24,6 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices" "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/protobuf/types/known/structpb" @@ -253,7 +252,7 @@ func (a *InitFromCodeAction) scaffoldTemplate(ctx context.Context, azdClient *az case "skip": overwriteCollisions = false case "cancel": - return fmt.Errorf("operation cancelled by user") + return fmt.Errorf("operation cancelled, no changes were made") } } else { // No collisions - confirm to proceed @@ -267,7 +266,7 @@ func (a *InitFromCodeAction) scaffoldTemplate(ctx context.Context, azdClient *az return fmt.Errorf("prompting for confirmation: %w", err) } if !*confirmResp.Value { - return fmt.Errorf("operation cancelled by user") + return fmt.Errorf("operation cancelled, no changes were made") } } @@ -1256,7 +1255,7 @@ func (a *InitFromCodeAction) getModelDeploymentDetails(ctx context.Context, mode func (a *InitFromCodeAction) processExistingFoundryProject(ctx context.Context, foundryProject FoundryProjectInfo) error { - if err := a.setEnvVar(ctx, "AZURE_AI_PROJECT_ID", a.flags.projectResourceId); err != nil { + if err := a.setEnvVar(ctx, "AZURE_AI_PROJECT_ID", foundryProject.ResourceId); err != nil { return err } @@ -1312,16 +1311,21 @@ func (a *InitFromCodeAction) processExistingFoundryProject(ctx context.Context, } 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.")) + fmt.Println(` + An Azure Container Registry (ACR) is required + + Foundry Hosted Agents need an Azure Container Registry to store container images before deployment. + + You can: + * Use an existing ACR + * Or create a new one from the template during 'azd up' + + Learn more: aka.ms/azdaiagent/docs + `) 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.", + Message: "Enter your ACR login server (e.g., myregistry.azurecr.io), or leave blank to create a new one", IgnoreHintKeys: true, }, }) @@ -1375,16 +1379,21 @@ func (a *InitFromCodeAction) processExistingFoundryProject(ctx context.Context, // 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.")) + fmt.Println(` + Application Insights (optional) + + Enable telemetry to collect logs, traces, and diagnostics for this agent. + + You can: + • Use an existing Application Insights resource + • Or create a new one during 'azd up' + + Docs: aka.ms/azdaiagent/docs + `) 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.", + Message: "Enter your Application Insights connection string, or leave blank to create a new one", IgnoreHintKeys: true, }, }) @@ -1439,6 +1448,5 @@ func (a *InitFromCodeAction) processExistingFoundryProject(ctx context.Context, } } - fmt.Printf("Successfully parsed and set environment variables from Microsoft Foundry project ID\n") return nil } From 6c75c7e83001d73de953f6303a268c23005bd3f2 Mon Sep 17 00:00:00 2001 From: trangevi Date: Wed, 18 Feb 2026 10:58:28 -0800 Subject: [PATCH 13/34] Switch to public repo Signed-off-by: trangevi --- .../internal/cmd/init_from_code.go | 44 +++++++++---------- 1 file changed, 20 insertions(+), 24 deletions(-) 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 d25ba884c4c..04b3aae67be 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 @@ -118,7 +118,7 @@ func (a *InitFromCodeAction) ensureProject(ctx context.Context) (*azdext.Project if err != nil { fmt.Println("Lets get your project initialized.") - if err := a.scaffoldTemplate(ctx, a.azdClient, "therealjohn/azd-ai-starter-basic", "main"); err != nil { + if err := a.scaffoldTemplate(ctx, a.azdClient, "Azure-Samples/azd-ai-starter-basic", "main"); err != nil { return nil, fmt.Errorf("failed to scaffold template: %w", err) } @@ -175,12 +175,16 @@ func (a *InitFromCodeAction) scaffoldTemplate(ctx context.Context, azdClient *az return fmt.Errorf("parsing tree response: %w", err) } - // Collect only files (blobs) + // Collect only files (blobs) from the infra folder and azure.yaml var files []templateFileInfo for _, entry := range treeResp.Tree { if entry.Type != "blob" { continue } + // Only include files in the infra folder or the azure.yaml file + if !strings.HasPrefix(entry.Path, "infra/") && entry.Path != "azure.yaml" { + continue + } downloadURL := fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s", repoSlug, branch, entry.Path) collides := false if _, statErr := os.Stat(entry.Path); statErr == nil { @@ -1311,17 +1315,13 @@ func (a *InitFromCodeAction) processExistingFoundryProject(ctx context.Context, } if len(acrConnections) == 0 { - fmt.Println(` - An Azure Container Registry (ACR) is required - - Foundry Hosted Agents need an Azure Container Registry to store container images before deployment. - - You can: - * Use an existing ACR - * Or create a new one from the template during 'azd up' - - Learn more: aka.ms/azdaiagent/docs - `) + 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{ @@ -1379,17 +1379,13 @@ func (a *InitFromCodeAction) processExistingFoundryProject(ctx context.Context, // Handle App Insights connections if len(appInsightsConnections) == 0 { - fmt.Println(` - Application Insights (optional) - - Enable telemetry to collect logs, traces, and diagnostics for this agent. - - You can: - • Use an existing Application Insights resource - • Or create a new one during 'azd up' - - Docs: aka.ms/azdaiagent/docs - `) + 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") resp, err := a.azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ Options: &azdext.PromptOptions{ From b01af47e5e2f09b3b021118f61d7f30689ee6247 Mon Sep 17 00:00:00 2001 From: trangevi Date: Wed, 18 Feb 2026 17:07:37 -0800 Subject: [PATCH 14/34] Changes for existing ACR Signed-off-by: trangevi --- cli/azd/extensions/azure.ai.agents/go.mod | 1 + cli/azd/extensions/azure.ai.agents/go.sum | 2 + .../internal/cmd/init_from_code.go | 39 +++++++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/cli/azd/extensions/azure.ai.agents/go.mod b/cli/azd/extensions/azure.ai.agents/go.mod index 667288ea1d3..e8c87b45ad0 100644 --- a/cli/azd/extensions/azure.ai.agents/go.mod +++ b/cli/azd/extensions/azure.ai.agents/go.mod @@ -8,6 +8,7 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appcontainers/armappcontainers v1.1.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v3 v3.0.0-beta.2 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices v1.8.0 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerregistry/armcontainerregistry v1.2.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0 github.com/azure/azure-dev/cli/azd v0.0.0-20260122173819-89795b295491 diff --git a/cli/azd/extensions/azure.ai.agents/go.sum b/cli/azd/extensions/azure.ai.agents/go.sum index 7f43fb64537..b048b366dea 100644 --- a/cli/azd/extensions/azure.ai.agents/go.sum +++ b/cli/azd/extensions/azure.ai.agents/go.sum @@ -21,6 +21,8 @@ github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthoriza github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v3 v3.0.0-beta.2/go.mod h1:jVRrRDLCOuif95HDYC23ADTMlvahB7tMdl519m9Iyjc= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices v1.8.0 h1:ZMGAqCZov8+7iFUPWKVcTaLgNXUeTlz20sIuWkQWNfg= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices v1.8.0/go.mod h1:BElPQ/GZtrdQ2i5uDZw3OKLE1we75W0AEWyeBR1TWQA= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerregistry/armcontainerregistry v1.2.0 h1:DWlwvVV5r/Wy1561nZ3wrpI1/vDIBRY/Wd1HWaRBZWA= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerregistry/armcontainerregistry v1.2.0/go.mod h1:E7ltexgRDmeJ0fJWv0D/HLwY2xbDdN+uv+X2uZtOx3w= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0 h1:PTFGRSlMKCQelWwxUyYVEUqseBJVemLyqWJjvMyt0do= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0/go.mod h1:LRr2FzBTQlONPPa5HREE5+RjSCTXl7BwOvYOaWTqCaI= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/managementgroups/armmanagementgroups v1.0.0 h1:pPvTJ1dY0sA35JOeFq6TsY2xj6Z85Yo23Pj4wCCvu4o= 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 04b3aae67be..d0cb0baf80e 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 @@ -23,6 +23,7 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices" + "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" @@ -1143,6 +1144,35 @@ func (a *InitFromCodeAction) setEnvVar(ctx context.Context, key, value string) e 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") + registryName := strings.Split(loginServer, ".")[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 @@ -1334,9 +1364,18 @@ func (a *InitFromCodeAction) processExistingFoundryProject(ctx context.Context, } 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 From 44476cab5722e8029e0fa011e1ec875864fea4e3 Mon Sep 17 00:00:00 2001 From: trangevi Date: Thu, 19 Feb 2026 15:44:46 -0800 Subject: [PATCH 15/34] More for existing ACR support Signed-off-by: trangevi --- .../extensions/azure.ai.agents/internal/cmd/init_from_code.go | 4 ++++ 1 file changed, 4 insertions(+) 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 d0cb0baf80e..8033b343506 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 @@ -1411,6 +1411,10 @@ func (a *InitFromCodeAction) processExistingFoundryProject(ctx context.Context, } } + 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 } From 319113c698b8236de5b26e1ef9d434cff7a36cfc Mon Sep 17 00:00:00 2001 From: trangevi Date: Fri, 20 Feb 2026 09:53:26 -0800 Subject: [PATCH 16/34] app insights changes Signed-off-by: trangevi --- .../internal/cmd/init_from_code.go | 33 ++++++++++++++++--- 1 file changed, 28 insertions(+), 5 deletions(-) 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 8033b343506..7d504f6f7bc 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 @@ -1430,21 +1430,40 @@ func (a *InitFromCodeAction) processExistingFoundryProject(ctx context.Context, " • Or create a new one during 'azd up'\n\n" + "Docs: aka.ms/azdaiagent/docs") - resp, err := a.azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ + // First prompt for resource ID + resourceIdResp, err := a.azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ Options: &azdext.PromptOptions{ - Message: "Enter your Application Insights connection string, or leave blank to create a new one", + 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 connection string: %w", err) + return fmt.Errorf("prompting for Application Insights resource ID: %w", err) } - if resp.Value != "" { - if err := a.setEnvVar(ctx, "APPLICATIONINSIGHTS_CONNECTION_STRING", resp.Value); err != nil { + 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 @@ -1480,6 +1499,10 @@ func (a *InitFromCodeAction) processExistingFoundryProject(ctx context.Context, } 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 } From 870ae3d97880a1a5eadbe774eeeda4c722872bfe Mon Sep 17 00:00:00 2001 From: trangevi Date: Fri, 20 Feb 2026 10:17:43 -0800 Subject: [PATCH 17/34] Point to branch for now Signed-off-by: trangevi --- .../extensions/azure.ai.agents/internal/cmd/init_from_code.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 7d504f6f7bc..4831d0d6a35 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 @@ -119,7 +119,7 @@ func (a *InitFromCodeAction) ensureProject(ctx context.Context) (*azdext.Project if err != nil { fmt.Println("Lets get your project initialized.") - if err := a.scaffoldTemplate(ctx, a.azdClient, "Azure-Samples/azd-ai-starter-basic", "main"); err != nil { + if err := a.scaffoldTemplate(ctx, a.azdClient, "Azure-Samples/azd-ai-starter-basic", "trangevi/existing-acr"); err != nil { return nil, fmt.Errorf("failed to scaffold template: %w", err) } From 16e3b1fe2f8c860100484ca1681f78ec239aab39 Mon Sep 17 00:00:00 2001 From: trangevi Date: Fri, 20 Feb 2026 10:29:01 -0800 Subject: [PATCH 18/34] tests Signed-off-by: trangevi --- .../internal/cmd/init_from_code_test.go | 590 ++++++++++++++++++ 1 file changed, 590 insertions(+) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_test.go diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_test.go new file mode 100644 index 00000000000..a4aa4feb560 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_test.go @@ -0,0 +1,590 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "azureaiagent/internal/pkg/agents/agent_yaml" + "os" + "path/filepath" + "testing" +) + +func TestSanitizeAgentName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + expected string + }{ + { + name: "simple lowercase name", + input: "my-agent", + expected: "my-agent", + }, + { + name: "uppercase converted to lowercase", + input: "My-Agent", + expected: "my-agent", + }, + { + name: "spaces replaced with hyphens", + input: "my agent name", + expected: "my-agent-name", + }, + { + name: "special characters replaced with hyphens", + input: "my_agent@name!", + expected: "my-agent-name", + }, + { + name: "consecutive hyphens collapsed", + input: "my---agent", + expected: "my-agent", + }, + { + name: "leading and trailing hyphens stripped", + input: "-my-agent-", + expected: "my-agent", + }, + { + name: "mixed special chars become single hyphen", + input: "My Agent!!Name", + expected: "my-agent-name", + }, + { + name: "empty string returns default", + input: "", + expected: "my-agent", + }, + { + name: "all special characters returns default", + input: "!!!@@@", + expected: "my-agent", + }, + { + name: "numeric name preserved", + input: "agent123", + expected: "agent123", + }, + { + name: "truncate to 63 chars", + input: "abcdefghijklmnopqrstuvwxyz-abcdefghijklmnopqrstuvwxyz-abcdefghijklmnopqrstuvwxyz", + expected: "abcdefghijklmnopqrstuvwxyz-abcdefghijklmnopqrstuvwxyz-abcdefghi", + }, + { + name: "truncate strips trailing hyphen", + input: "abcdefghijklmnopqrstuvwxyz-abcdefghijklmnopqrstuvwxyz-abcdefgh-extra-long-stuff", + expected: "abcdefghijklmnopqrstuvwxyz-abcdefghijklmnopqrstuvwxyz-abcdefgh", + }, + { + name: "dots replaced with hyphens", + input: "my.agent.name", + expected: "my-agent-name", + }, + { + name: "underscores replaced with hyphens", + input: "my_agent_name", + expected: "my-agent-name", + }, + { + name: "only hyphens returns default", + input: "---", + expected: "my-agent", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := sanitizeAgentName(tt.input) + if result != tt.expected { + t.Errorf("sanitizeAgentName(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestNormalizeForFuzzyMatch(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + expected string + }{ + { + name: "lowercase passthrough", + input: "gpt4o", + expected: "gpt4o", + }, + { + name: "uppercase converted to lowercase", + input: "GPT4O", + expected: "gpt4o", + }, + { + name: "hyphens removed", + input: "gpt-4o", + expected: "gpt4o", + }, + { + name: "dots removed", + input: "gpt.4o", + expected: "gpt4o", + }, + { + name: "underscores removed", + input: "gpt_4o", + expected: "gpt4o", + }, + { + name: "spaces removed", + input: "gpt 4o", + expected: "gpt4o", + }, + { + name: "multiple separators removed", + input: "gpt-4.o_mini", + expected: "gpt4omini", + }, + { + name: "empty string", + input: "", + expected: "", + }, + { + name: "only separators", + input: "---...__", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := normalizeForFuzzyMatch(tt.input) + if result != tt.expected { + t.Errorf("normalizeForFuzzyMatch(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestFuzzyFilterModels(t *testing.T) { + t.Parallel() + + models := []string{ + "gpt-4o", + "gpt-4o-mini", + "gpt-4", + "gpt-3.5-turbo", + "claude-3-opus", + "llama-3-70b", + "text-embedding-ada-002", + } + + tests := []struct { + name string + searchTerm string + expected []string + }{ + { + name: "empty search returns all", + searchTerm: "", + expected: models, + }, + { + name: "exact match", + searchTerm: "gpt-4o", + expected: []string{"gpt-4o", "gpt-4o-mini"}, + }, + { + name: "case insensitive search", + searchTerm: "GPT-4O", + expected: []string{"gpt-4o", "gpt-4o-mini"}, + }, + { + name: "fuzzy without separators", + searchTerm: "gpt4o", + expected: []string{"gpt-4o", "gpt-4o-mini"}, + }, + { + name: "partial match", + searchTerm: "llama", + expected: []string{"llama-3-70b"}, + }, + { + name: "no match", + searchTerm: "nonexistent", + expected: nil, + }, + { + name: "match across separator boundaries", + searchTerm: "embedding", + expected: []string{"text-embedding-ada-002"}, + }, + { + name: "match with dot separator in search", + searchTerm: "3.5", + expected: []string{"gpt-3.5-turbo"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := fuzzyFilterModels(models, tt.searchTerm) + if !stringSlicesEqual(result, tt.expected) { + t.Errorf("fuzzyFilterModels(%v, %q) = %v, want %v", models, tt.searchTerm, result, tt.expected) + } + }) + } +} + +func TestFindDefaultModelIndex(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + modelNames []string + expected int32 + }{ + { + name: "gpt-4o present", + modelNames: []string{"claude-3", "gpt-4o", "llama-3"}, + expected: 1, + }, + { + name: "gpt-4o first", + modelNames: []string{"gpt-4o", "gpt-4o-mini", "gpt-4"}, + expected: 0, + }, + { + name: "no gpt-4o but gpt-4 prefix present", + modelNames: []string{"claude-3", "gpt-4", "llama-3"}, + expected: 1, + }, + { + name: "no gpt-4o but gpt-4-turbo present", + modelNames: []string{"claude-3", "gpt-4-turbo", "llama-3"}, + expected: 1, + }, + { + name: "no gpt models returns 0", + modelNames: []string{"claude-3", "llama-3", "phi-3"}, + expected: 0, + }, + { + name: "empty list returns 0", + modelNames: []string{}, + expected: 0, + }, + { + name: "gpt-4o preferred over gpt-4", + modelNames: []string{"gpt-4", "gpt-4o", "gpt-4-turbo"}, + expected: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := findDefaultModelIndex(tt.modelNames) + if result != tt.expected { + t.Errorf("findDefaultModelIndex(%v) = %d, want %d", tt.modelNames, result, tt.expected) + } + }) + } +} + +func TestExtractSubscriptionId(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + resourceId string + expected string + }{ + { + name: "standard resource id", + resourceId: "/subscriptions/12345-abcde/resourceGroups/myRg/providers/Microsoft.CognitiveServices/accounts/myAccount", + expected: "12345-abcde", + }, + { + name: "project resource id", + resourceId: "/subscriptions/sub-id-123/resourceGroups/rg/providers/Microsoft.CognitiveServices/accounts/acct/projects/proj", + expected: "sub-id-123", + }, + { + name: "case insensitive subscriptions", + resourceId: "/Subscriptions/CASE-ID/resourceGroups/rg/providers/Microsoft.CognitiveServices/accounts/acct", + expected: "CASE-ID", + }, + { + name: "empty string", + resourceId: "", + expected: "", + }, + { + name: "no subscriptions segment", + resourceId: "/resourceGroups/myRg/providers/Microsoft.CognitiveServices/accounts/myAccount", + expected: "", + }, + { + name: "subscriptions at end with no value", + resourceId: "/subscriptions", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractSubscriptionId(tt.resourceId) + if result != tt.expected { + t.Errorf("extractSubscriptionId(%q) = %q, want %q", tt.resourceId, result, tt.expected) + } + }) + } +} + +func TestExtractResourceGroup(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + resourceId string + expected string + }{ + { + name: "standard resource id", + resourceId: "/subscriptions/sub-123/resourceGroups/myResourceGroup/providers/Microsoft.CognitiveServices/accounts/myAccount", + expected: "myResourceGroup", + }, + { + name: "case insensitive resourceGroups", + resourceId: "/subscriptions/sub-123/ResourceGroups/MyRG/providers/Microsoft.CognitiveServices/accounts/acct", + expected: "MyRG", + }, + { + name: "empty string", + resourceId: "", + expected: "", + }, + { + name: "no resourceGroups segment", + resourceId: "/subscriptions/sub-123/providers/Microsoft.CognitiveServices/accounts/acct", + expected: "", + }, + { + name: "resourceGroups at end with no value", + resourceId: "/subscriptions/sub-123/resourceGroups", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractResourceGroup(tt.resourceId) + if result != tt.expected { + t.Errorf("extractResourceGroup(%q) = %q, want %q", tt.resourceId, result, tt.expected) + } + }) + } +} + +func TestWriteDefinitionToSrcDir(t *testing.T) { + t.Parallel() + + t.Run("writes agent.yaml to directory", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + srcDir := filepath.Join(dir, "src") + + definition := &agent_yaml.ContainerAgent{ + AgentDefinition: agent_yaml.AgentDefinition{ + Name: "test-agent", + Kind: agent_yaml.AgentKindHosted, + }, + Protocols: []agent_yaml.ProtocolVersionRecord{ + {Protocol: "responses", Version: "v1"}, + }, + EnvironmentVariables: &[]agent_yaml.EnvironmentVariable{ + {Name: "AZURE_OPENAI_ENDPOINT", Value: "${AZURE_OPENAI_ENDPOINT}"}, + }, + } + + action := &InitFromCodeAction{} + resultPath, err := action.writeDefinitionToSrcDir(definition, srcDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expectedPath := filepath.Join(srcDir, "agent.yaml") + if resultPath != expectedPath { + t.Errorf("path = %q, want %q", resultPath, expectedPath) + } + + content, err := os.ReadFile(resultPath) + if err != nil { + t.Fatalf("failed to read written file: %v", err) + } + + contentStr := string(content) + // Verify key content is present in the YAML + if !containsAll(contentStr, "name: test-agent", "kind: hosted", "responses", "AZURE_OPENAI_ENDPOINT") { + t.Errorf("written content missing expected fields:\n%s", contentStr) + } + }) + + t.Run("creates nested directories", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + srcDir := filepath.Join(dir, "deep", "nested", "path") + + definition := &agent_yaml.ContainerAgent{ + AgentDefinition: agent_yaml.AgentDefinition{ + Name: "nested-agent", + Kind: agent_yaml.AgentKindHosted, + }, + } + + action := &InitFromCodeAction{} + _, err := action.writeDefinitionToSrcDir(definition, srcDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if _, err := os.Stat(filepath.Join(srcDir, "agent.yaml")); err != nil { + t.Fatalf("expected file to exist: %v", err) + } + }) + + t.Run("overwrites existing file", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + existingFile := filepath.Join(dir, "agent.yaml") + if err := os.WriteFile(existingFile, []byte("old content"), 0644); err != nil { + t.Fatalf("write existing file: %v", err) + } + + definition := &agent_yaml.ContainerAgent{ + AgentDefinition: agent_yaml.AgentDefinition{ + Name: "new-agent", + Kind: agent_yaml.AgentKindHosted, + }, + } + + action := &InitFromCodeAction{} + _, err := action.writeDefinitionToSrcDir(definition, dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + content, err := os.ReadFile(existingFile) + if err != nil { + t.Fatalf("failed to read file: %v", err) + } + + if string(content) == "old content" { + t.Error("expected file to be overwritten, but old content remains") + } + if !containsAll(string(content), "name: new-agent") { + t.Errorf("written content missing expected fields:\n%s", string(content)) + } + }) +} + +func TestFoundryDeploymentInfo(t *testing.T) { + t.Parallel() + + t.Run("zero value", func(t *testing.T) { + info := FoundryDeploymentInfo{} + if info.Name != "" || info.ModelName != "" || info.SkuCapacity != 0 { + t.Error("expected zero values for uninitialized struct") + } + }) + + t.Run("populated values", func(t *testing.T) { + info := FoundryDeploymentInfo{ + Name: "my-deployment", + ModelName: "gpt-4o", + ModelFormat: "OpenAI", + Version: "2024-05-13", + SkuName: "GlobalStandard", + SkuCapacity: 10, + } + if info.Name != "my-deployment" { + t.Errorf("Name = %q, want %q", info.Name, "my-deployment") + } + if info.ModelName != "gpt-4o" { + t.Errorf("ModelName = %q, want %q", info.ModelName, "gpt-4o") + } + if info.SkuCapacity != 10 { + t.Errorf("SkuCapacity = %d, want %d", info.SkuCapacity, 10) + } + }) +} + +func TestFoundryProjectInfo(t *testing.T) { + t.Parallel() + + info := FoundryProjectInfo{ + SubscriptionId: "sub-123", + ResourceGroupName: "my-rg", + AccountName: "my-account", + ProjectName: "my-project", + Location: "eastus", + ResourceId: "/subscriptions/sub-123/resourceGroups/my-rg/providers/Microsoft.CognitiveServices/accounts/my-account/projects/my-project", + } + + if info.SubscriptionId != "sub-123" { + t.Errorf("SubscriptionId = %q, want %q", info.SubscriptionId, "sub-123") + } + if info.ResourceGroupName != "my-rg" { + t.Errorf("ResourceGroupName = %q, want %q", info.ResourceGroupName, "my-rg") + } + if info.Location != "eastus" { + t.Errorf("Location = %q, want %q", info.Location, "eastus") + } +} + +// stringSlicesEqual compares two string slices for equality. +func stringSlicesEqual(a, b []string) bool { + if len(a) == 0 && len(b) == 0 { + return true + } + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// containsAll checks that s contains all the given substrings. +func containsAll(s string, substrings ...string) bool { + for _, sub := range substrings { + if !containsString(s, sub) { + return false + } + } + return true +} + +// containsString checks if s contains substr. +func containsString(s, substr string) bool { + return len(s) >= len(substr) && searchString(s, substr) +} + +func searchString(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} From 8bacb519c4aee139d8bf10b47a9980da8046d0e4 Mon Sep 17 00:00:00 2001 From: trangevi Date: Fri, 20 Feb 2026 12:08:51 -0800 Subject: [PATCH 19/34] Integrate with azd extension model handling Signed-off-by: trangevi --- .../azure.ai.agents/internal/cmd/init.go | 24 ++- .../internal/cmd/init_from_code.go | 146 ++++++++++-------- .../internal/cmd/init_from_code_models.go | 117 ++++++++++++++ .../internal/cmd/init_models.go | 14 -- 4 files changed, 217 insertions(+), 84 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_models.go diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go index 878ddceffd6..a073b4b1833 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -162,11 +162,11 @@ func newInitCommand(rootFlags *rootFlagsDefinition) *cobra.Command { // azureClient: azure.NewAzureClient(credential), azureContext: azureContext, // composedResources: getComposedResourcesResponse.Resources, - console: console, - credential: credential, - projectConfig: projectConfig, - environment: environment, - flags: flags, + console: console, + credential: credential, + projectConfig: projectConfig, + environment: environment, + flags: flags, } if err := action.Run(ctx); err != nil { @@ -1758,3 +1758,17 @@ 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_from_code.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go index 4831d0d6a35..b998b0748bb 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/pkg/agents/agent_yaml" "azureaiagent/internal/pkg/azure" - "azureaiagent/internal/pkg/azure/ai" "azureaiagent/internal/project" "context" "encoding/json" @@ -32,15 +31,14 @@ import ( ) type InitFromCodeAction struct { - azdClient *azdext.AzdClient - flags *initFlags - projectConfig *azdext.ProjectConfig - azureContext *azdext.AzureContext - environment *azdext.Environment - credential azcore.TokenCredential - modelCatalog map[string]*ai.AiModel - modelCatalogService *ai.ModelCatalogService - deploymentDetails []project.Deployment + azdClient *azdext.AzdClient + flags *initFlags + projectConfig *azdext.ProjectConfig + azureContext *azdext.AzureContext + environment *azdext.Environment + credential azcore.TokenCredential + modelCatalog map[string]*azdext.AiModel + deploymentDetails []project.Deployment } // templateFileInfo represents a file from the GitHub template repository. @@ -401,7 +399,7 @@ func (a *InitFromCodeAction) createDefinitionFromLocalAgent(ctx context.Context) a.azureContext.Scope.SubscriptionId = extractSubscriptionId(a.flags.projectResourceId) } - var selectedModel string + var selectedModel *azdext.AiModel var existingDeployment *FoundryDeploymentInfo switch modelConfigChoice { @@ -560,7 +558,6 @@ func (a *InitFromCodeAction) createDefinitionFromLocalAgent(ctx context.Context) // User selected an existing deployment d := deployments[selectedIdx] existingDeployment = &d - selectedModel = d.ModelName fmt.Printf("Model deployment name: %s\n", d.Name) } else { // User wants to create a new deployment — region locked to the project's location @@ -608,29 +605,29 @@ func (a *InitFromCodeAction) createDefinitionFromLocalAgent(ctx context.Context) } // Add model resource if a model was selected - if selectedModel != "" { + if selectedModel != nil { if existingDeployment == nil { - modelDetails, err := a.getModelDeploymentDetails(ctx, selectedModel) + modelDetails, err := a.resolveModelDeploymentNoPrompt(ctx, selectedModel, a.azureContext.Scope.Location) if err != nil { return nil, fmt.Errorf("failed to get model deployment details: %w", err) } a.deploymentDetails = append(a.deploymentDetails, project.Deployment{ - Name: modelDetails.Name, + Name: modelDetails.ModelName, Model: project.DeploymentModel{ - Name: modelDetails.Name, + Name: modelDetails.ModelName, Format: modelDetails.Format, Version: modelDetails.Version, }, Sku: project.DeploymentSku{ Name: modelDetails.Sku.Name, - Capacity: int(modelDetails.Sku.Capacity), + Capacity: int(modelDetails.Capacity), }, }) *definition.EnvironmentVariables = append(*definition.EnvironmentVariables, agent_yaml.EnvironmentVariable{ Name: "AZURE_AI_MODEL_DEPLOYMENT_NAME", - Value: modelDetails.Name, + Value: modelDetails.ModelName, }) } else { // For existing deployments, store the deployment details directly @@ -782,7 +779,6 @@ func (a *InitFromCodeAction) ensureSubscription(ctx context.Context) error { return fmt.Errorf("failed to create azure credential: %w", err) } a.credential = credential - a.modelCatalogService = ai.NewModelCatalogService(credential) return nil } @@ -811,7 +807,7 @@ func (a *InitFromCodeAction) ensureLocation(ctx context.Context) error { return nil } -func (a *InitFromCodeAction) selectNewModel(ctx context.Context) (string, error) { +func (a *InitFromCodeAction) selectNewModel(ctx context.Context) (*azdext.AiModel, error) { var err error if a.modelCatalog == nil { spinner := ux.NewSpinner(&ux.SpinnerOptions{ @@ -819,15 +815,15 @@ func (a *InitFromCodeAction) selectNewModel(ctx context.Context) (string, error) ClearOnStop: true, }) if err := spinner.Start(ctx); err != nil { - return "", fmt.Errorf("failed to start spinner: %w", err) + return nil, fmt.Errorf("failed to start spinner: %w", err) } - a.modelCatalog, err = a.modelCatalogService.ListAllModels(ctx, a.azureContext.Scope.SubscriptionId, a.azureContext.Scope.Location) + a.loadAiCatalog(ctx) if stopErr := spinner.Stop(ctx); stopErr != nil { - return "", stopErr + return nil, stopErr } if err != nil { - return "", fmt.Errorf("failed to list models from catalog: %w", err) + return nil, fmt.Errorf("failed to list models from catalog: %w", err) } } @@ -836,11 +832,31 @@ func (a *InitFromCodeAction) selectNewModel(ctx context.Context) (string, error) modelNames = append(modelNames, modelName) } - selectedModel, err := a.promptForModelWithSearch(ctx, modelNames) + // selectedModel, err := a.promptForModelWithSearch(ctx, modelNames) + // if err != nil { + // return "", err + // } + + 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}, + }, + } + + modelResp, err := a.azdClient.Prompt().PromptAiModel(ctx, promptReq) if err != nil { - return "", err + return nil, fmt.Errorf("failed to prompt for model selection: %w", err) } + selectedModel := modelResp.Model + return selectedModel, nil } @@ -1247,45 +1263,45 @@ func (a *InitFromCodeAction) addToProject(ctx context.Context, targetDir string, return nil } -func (a *InitFromCodeAction) getModelDeploymentDetails(ctx context.Context, modelName string) (*ai.AiModelDeployment, error) { - var model *ai.AiModel - model, _ = a.modelCatalog[modelName] - - _, defaultVersion, err := a.modelCatalogService.ListModelVersions(ctx, model, a.azureContext.Scope.Location) - if err != nil { - return nil, fmt.Errorf("listing versions for model '%s': %w", model.Name, err) - } - - availableSkus, err := a.modelCatalogService.ListModelSkus(ctx, model, a.azureContext.Scope.Location, defaultVersion) - if err != nil { - return nil, fmt.Errorf("listing SKUs for model '%s': %w", model.Name, err) - } - - // Determine default SKU based on priority list - defaultSku := "" - for _, sku := range defaultSkuPriority { - if slices.Contains(availableSkus, sku) { - defaultSku = sku - break - } - } - - deploymentOptions := ai.AiModelDeploymentOptions{ - Versions: []string{defaultVersion}, - Skus: []string{defaultSku}, - } - - modelDeployment, err := a.modelCatalogService.GetModelDeployment(ctx, model, &deploymentOptions) - if err != nil { - return nil, fmt.Errorf("failed to get model deployment: %w", err) - } - - if modelDeployment.Sku.Capacity == -1 { - modelDeployment.Sku.Capacity = 10 - } - - return modelDeployment, nil -} +// func (a *InitFromCodeAction) getModelDeploymentDetails(ctx context.Context, modelName string) (*ai.AiModelDeployment, error) { +// var model *ai.AiModel +// model, _ = a.modelCatalog[modelName] + +// _, defaultVersion, err := a.modelCatalogService.ListModelVersions(ctx, model, a.azureContext.Scope.Location) +// if err != nil { +// return nil, fmt.Errorf("listing versions for model '%s': %w", model.Name, err) +// } + +// availableSkus, err := a.modelCatalogService.ListModelSkus(ctx, model, a.azureContext.Scope.Location, defaultVersion) +// if err != nil { +// return nil, fmt.Errorf("listing SKUs for model '%s': %w", model.Name, err) +// } + +// // Determine default SKU based on priority list +// defaultSku := "" +// for _, sku := range defaultSkuPriority { +// if slices.Contains(availableSkus, sku) { +// defaultSku = sku +// break +// } +// } + +// deploymentOptions := ai.AiModelDeploymentOptions{ +// Versions: []string{defaultVersion}, +// Skus: []string{defaultSku}, +// } + +// modelDeployment, err := a.modelCatalogService.GetModelDeployment(ctx, model, &deploymentOptions) +// if err != nil { +// return nil, fmt.Errorf("failed to get model deployment: %w", err) +// } + +// if modelDeployment.Sku.Capacity == -1 { +// modelDeployment.Sku.Capacity = 10 +// } + +// return modelDeployment, nil +// } func (a *InitFromCodeAction) processExistingFoundryProject(ctx context.Context, foundryProject FoundryProjectInfo) error { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_models.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_models.go new file mode 100644 index 00000000000..3e014953296 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_models.go @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + "slices" + "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/ux" +) + +func (a *InitFromCodeAction) loadAiCatalog(ctx context.Context) error { + if a.modelCatalog != nil { + return nil + } + + spinner := ux.NewSpinner(&ux.SpinnerOptions{ + Text: "Loading the model catalog", + ClearOnStop: true, + }) + + if err := spinner.Start(ctx); err != nil { + return fmt.Errorf("failed to start spinner: %w", err) + } + + modelResp, err := a.azdClient.Ai().ListModels(ctx, &azdext.ListModelsRequest{ + AzureContext: a.azureContext, + }) + stopErr := spinner.Stop(ctx) + if err != nil { + return fmt.Errorf("failed to load the model catalog: %w", err) + } + if stopErr != nil { + return stopErr + } + + a.modelCatalog = mapModelsByName(modelResp.Models) + + 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, fmt.Errorf("failed to resolve model deployment: %w", err) + } + + if len(resolveResp.Deployments) == 0 { + return nil, fmt.Errorf("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_models.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_models.go index e6382c3e5c5..584fdeea4fc 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 @@ -141,20 +141,6 @@ func (a *InitAction) selectFromList( return options[*resp.Value], 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 -} - func (a *InitAction) getModelDeploymentDetails(ctx context.Context, model agent_yaml.Model) (*project.Deployment, error) { resp, err := a.azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ EnvName: a.environment.Name, From f8c821ce9bbdaf9f52c5247d6f2211c27f23e8d0 Mon Sep 17 00:00:00 2001 From: trangevi Date: Fri, 20 Feb 2026 13:25:12 -0800 Subject: [PATCH 20/34] Move model handling Signed-off-by: trangevi --- .../internal/cmd/init_from_code.go | 103 +++++++++++++++ .../internal/cmd/init_from_code_models.go | 117 ------------------ 2 files changed, 103 insertions(+), 117 deletions(-) delete mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_models.go 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 b998b0748bb..fbb0e757aa6 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 @@ -1528,3 +1528,106 @@ func (a *InitFromCodeAction) processExistingFoundryProject(ctx context.Context, return nil } + +func (a *InitFromCodeAction) loadAiCatalog(ctx context.Context) error { + if a.modelCatalog != nil { + return nil + } + + spinner := ux.NewSpinner(&ux.SpinnerOptions{ + Text: "Loading the model catalog", + ClearOnStop: true, + }) + + if err := spinner.Start(ctx); err != nil { + return fmt.Errorf("failed to start spinner: %w", err) + } + + modelResp, err := a.azdClient.Ai().ListModels(ctx, &azdext.ListModelsRequest{ + AzureContext: a.azureContext, + }) + stopErr := spinner.Stop(ctx) + if err != nil { + return fmt.Errorf("failed to load the model catalog: %w", err) + } + if stopErr != nil { + return stopErr + } + + a.modelCatalog = mapModelsByName(modelResp.Models) + + 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, fmt.Errorf("failed to resolve model deployment: %w", err) + } + + if len(resolveResp.Deployments) == 0 { + return nil, fmt.Errorf("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_code_models.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_models.go deleted file mode 100644 index 3e014953296..00000000000 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_models.go +++ /dev/null @@ -1,117 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package cmd - -import ( - "context" - "fmt" - "slices" - "strings" - - "github.com/azure/azure-dev/cli/azd/pkg/azdext" - "github.com/azure/azure-dev/cli/azd/pkg/ux" -) - -func (a *InitFromCodeAction) loadAiCatalog(ctx context.Context) error { - if a.modelCatalog != nil { - return nil - } - - spinner := ux.NewSpinner(&ux.SpinnerOptions{ - Text: "Loading the model catalog", - ClearOnStop: true, - }) - - if err := spinner.Start(ctx); err != nil { - return fmt.Errorf("failed to start spinner: %w", err) - } - - modelResp, err := a.azdClient.Ai().ListModels(ctx, &azdext.ListModelsRequest{ - AzureContext: a.azureContext, - }) - stopErr := spinner.Stop(ctx) - if err != nil { - return fmt.Errorf("failed to load the model catalog: %w", err) - } - if stopErr != nil { - return stopErr - } - - a.modelCatalog = mapModelsByName(modelResp.Models) - - 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, fmt.Errorf("failed to resolve model deployment: %w", err) - } - - if len(resolveResp.Deployments) == 0 { - return nil, fmt.Errorf("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) -} From 3f04250dc0cddd6fd688f1dc9c20f8dde900b558 Mon Sep 17 00:00:00 2001 From: Travis Angevine Date: Fri, 20 Feb 2026 13:46:45 -0800 Subject: [PATCH 21/34] Update cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../extensions/azure.ai.agents/internal/cmd/init_from_code.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 fbb0e757aa6..063a0a31ce6 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 @@ -501,7 +501,9 @@ func (a *InitFromCodeAction) createDefinitionFromLocalAgent(ctx context.Context) // Set the Foundry project context a.azureContext.Scope.Location = selectedProject.Location - a.setEnvVar(ctx, "AZURE_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 { From 90c136381e45c84df2e60060a34f06b76dddcb31 Mon Sep 17 00:00:00 2001 From: Travis Angevine Date: Fri, 20 Feb 2026 13:49:36 -0800 Subject: [PATCH 22/34] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- cli/azd/extensions/azure.ai.agents/internal/cmd/init.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 a073b4b1833..dc40ccad594 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -275,7 +275,7 @@ func (a *InitAction) Run(ctx context.Context) error { func ensureProject(ctx context.Context, flags *initFlags, azdClient *azdext.AzdClient) (*azdext.ProjectConfig, error) { projectResponse, err := azdClient.Project().Get(ctx, &azdext.EmptyRequest{}) if err != nil { - fmt.Println("Lets get your project initialized.") + fmt.Println("Let's get your project initialized.") // Environment creation is handled separately in ensureEnvironment initArgs := []string{"init", "-t", "Azure-Samples/azd-ai-starter-basic"} From 6db93f7dbe00732d7d3f49ae91161d8c4d320ea5 Mon Sep 17 00:00:00 2001 From: Travis Angevine Date: Fri, 20 Feb 2026 13:55:44 -0800 Subject: [PATCH 23/34] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../internal/cmd/init_from_code.go | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) 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 063a0a31ce6..8526d6fec47 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 @@ -115,7 +115,7 @@ func (a *InitFromCodeAction) Run(ctx context.Context) error { func (a *InitFromCodeAction) ensureProject(ctx context.Context) (*azdext.ProjectConfig, error) { projectResponse, err := a.azdClient.Project().Get(ctx, &azdext.EmptyRequest{}) if err != nil { - fmt.Println("Lets get your project initialized.") + fmt.Println("Let's get your project initialized.") if err := a.scaffoldTemplate(ctx, a.azdClient, "Azure-Samples/azd-ai-starter-basic", "trangevi/existing-acr"); err != nil { return nil, fmt.Errorf("failed to scaffold template: %w", err) @@ -184,6 +184,10 @@ func (a *InitFromCodeAction) scaffoldTemplate(ctx context.Context, azdClient *az if !strings.HasPrefix(entry.Path, "infra/") && entry.Path != "azure.yaml" { continue } + // Guard against path traversal or unexpected absolute paths + if strings.Contains(entry.Path, "..") || filepath.IsAbs(entry.Path) { + return fmt.Errorf("invalid path in repository tree: %s", entry.Path) + } downloadURL := fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s", repoSlug, branch, entry.Path) collides := false if _, statErr := os.Stat(entry.Path); statErr == nil { @@ -309,19 +313,18 @@ func (a *InitFromCodeAction) scaffoldTemplate(ctx context.Context, azdClient *az _ = spinner.Stop(ctx) return fmt.Errorf("downloading %s: %w", f.Path, err) } - - content, err := io.ReadAll(fileResp.Body) - fileResp.Body.Close() - if err != nil { - _ = spinner.Stop(ctx) - return fmt.Errorf("reading %s: %w", f.Path, err) - } + defer fileResp.Body.Close() if fileResp.StatusCode != http.StatusOK { _ = spinner.Stop(ctx) return fmt.Errorf("downloading %s: status %d", f.Path, fileResp.StatusCode) } + content, err := io.ReadAll(fileResp.Body) + if err != nil { + _ = spinner.Stop(ctx) + return fmt.Errorf("reading %s: %w", f.Path, err) + } if err := os.WriteFile(f.Path, content, 0644); err != nil { _ = spinner.Stop(ctx) return fmt.Errorf("writing %s: %w", f.Path, err) @@ -555,10 +558,10 @@ func (a *InitFromCodeAction) createDefinitionFromLocalAgent(ctx context.Context) return nil, fmt.Errorf("failed to prompt for deployment selection: %w", err) } - selectedIdx := *deployResp.Value - if selectedIdx < int32(len(deployments)) { + deploymentIdx := *deployResp.Value + if deploymentIdx < int32(len(deployments)) { // User selected an existing deployment - d := deployments[selectedIdx] + d := deployments[deploymentIdx] existingDeployment = &d fmt.Printf("Model deployment name: %s\n", d.Name) } else { @@ -1165,7 +1168,11 @@ func (a *InitFromCodeAction) setEnvVar(ctx context.Context, key, value string) e // 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") - registryName := strings.Split(loginServer, ".")[0] + 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 { From 8ea106c55a814d46f4852dc62ea4ad497f0e565e Mon Sep 17 00:00:00 2001 From: trangevi Date: Fri, 20 Feb 2026 14:18:47 -0800 Subject: [PATCH 24/34] Proper http client handling Signed-off-by: trangevi --- .../azure.ai.agents/internal/cmd/init.go | 28 ++++++++++++------- .../internal/cmd/init_from_code.go | 5 ++-- 2 files changed, 21 insertions(+), 12 deletions(-) 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 a073b4b1833..1989819bd33 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -17,6 +17,7 @@ import ( "regexp" "strconv" "strings" + "time" "azureaiagent/internal/pkg/agents/agent_yaml" "azureaiagent/internal/pkg/agents/registry_api" @@ -68,6 +69,7 @@ type InitAction struct { environment *azdext.Environment flags *initFlags deploymentDetails []project.Deployment + httpClient *http.Client } // GitHubUrlInfo holds parsed information from a GitHub URL @@ -130,6 +132,10 @@ func newInitCommand(rootFlags *rootFlagsDefinition) *cobra.Command { return fmt.Errorf("failed waiting for debugger: %w", err) } + var httpClient = &http.Client{ + Timeout: 30 * time.Second, + } + if flags.manifestPointer != "" { azureContext, projectConfig, environment, err := ensureAzureContext(ctx, flags, azdClient) if err != nil { @@ -167,6 +173,7 @@ func newInitCommand(rootFlags *rootFlagsDefinition) *cobra.Command { projectConfig: projectConfig, environment: environment, flags: flags, + httpClient: httpClient, } if err := action.Run(ctx); err != nil { @@ -174,8 +181,9 @@ func newInitCommand(rootFlags *rootFlagsDefinition) *cobra.Command { } } else { action := &InitFromCodeAction{ - azdClient: azdClient, - flags: flags, + azdClient: azdClient, + flags: flags, + httpClient: httpClient, } if err := action.Run(ctx); err != nil { @@ -1015,7 +1023,7 @@ func (a *InitAction) downloadAgentYaml( req, err := http.NewRequestWithContext(ctx, http.MethodGet, fileApiUrl, nil) if err == nil { req.Header.Set("Accept", "application/vnd.github.v3.raw") - resp, err := http.DefaultClient.Do(req) + resp, err := a.httpClient.Do(req) if err == nil { defer resp.Body.Close() if resp.StatusCode == http.StatusOK { @@ -1192,7 +1200,7 @@ func (a *InitAction) downloadAgentYaml( if isHostedContainer { // For container agents, download the entire parent directory fmt.Println("Downloading full directory for container agent") - err := downloadParentDirectory(ctx, urlInfo, targetDir, ghCli, console, useGhCli) + err := downloadParentDirectory(ctx, urlInfo, targetDir, ghCli, console, useGhCli, a.httpClient) if err != nil { return nil, "", fmt.Errorf("downloading parent directory: %w", err) } @@ -1556,7 +1564,7 @@ func (a *InitAction) parseGitHubUrl(ctx context.Context, manifestPointer string) } func downloadParentDirectory( - ctx context.Context, urlInfo *GitHubUrlInfo, targetDir string, ghCli *github.Cli, console input.Console, useGhCli bool) error { + ctx context.Context, urlInfo *GitHubUrlInfo, targetDir string, ghCli *github.Cli, console input.Console, useGhCli bool, httpClient *http.Client) error { // Get parent directory by removing the filename from the file path pathParts := strings.Split(urlInfo.FilePath, "/") @@ -1574,7 +1582,7 @@ func downloadParentDirectory( return fmt.Errorf("failed to download directory contents with GH CLI: %w", err) } } else { - if err := downloadDirectoryContentsWithoutGhCli(ctx, urlInfo.RepoSlug, parentDirPath, urlInfo.Branch, targetDir); err != nil { + if err := downloadDirectoryContentsWithoutGhCli(ctx, urlInfo.RepoSlug, parentDirPath, urlInfo.Branch, targetDir, httpClient); err != nil { return fmt.Errorf("failed to download directory contents without GH CLI: %w", err) } } @@ -1654,7 +1662,7 @@ func downloadDirectoryContents( } func downloadDirectoryContentsWithoutGhCli( - ctx context.Context, repoSlug string, dirPath string, branch string, localPath string) error { + ctx context.Context, repoSlug string, dirPath string, branch string, localPath string, httpClient *http.Client) error { // Get directory contents using GitHub API directly apiUrl := fmt.Sprintf("https://api.github.com/repos/%s/contents/%s", repoSlug, dirPath) @@ -1668,7 +1676,7 @@ func downloadDirectoryContentsWithoutGhCli( } req.Header.Set("Accept", "application/vnd.github.v3+json") - resp, err := http.DefaultClient.Do(req) + resp, err := httpClient.Do(req) if err != nil { return fmt.Errorf("failed to get directory contents: %w", err) } @@ -1724,7 +1732,7 @@ func downloadDirectoryContentsWithoutGhCli( } fileReq.Header.Set("Accept", "application/vnd.github.v3.raw") - fileResp, err := http.DefaultClient.Do(fileReq) + fileResp, err := httpClient.Do(fileReq) if err != nil { return fmt.Errorf("failed to download file %s: %w", itemPath, err) } @@ -1750,7 +1758,7 @@ func downloadDirectoryContentsWithoutGhCli( } // Recursively download directory contents - if err := downloadDirectoryContentsWithoutGhCli(ctx, repoSlug, itemPath, branch, itemLocalPath); err != nil { + if err := downloadDirectoryContentsWithoutGhCli(ctx, repoSlug, itemPath, branch, itemLocalPath, httpClient); err != nil { return fmt.Errorf("failed to download subdirectory %s: %w", itemPath, err) } } 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 fbb0e757aa6..0292edcdfc8 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 @@ -39,6 +39,7 @@ type InitFromCodeAction struct { credential azcore.TokenCredential modelCatalog map[string]*azdext.AiModel deploymentDetails []project.Deployment + httpClient *http.Client } // templateFileInfo represents a file from the GitHub template repository. @@ -149,7 +150,7 @@ func (a *InitFromCodeAction) scaffoldTemplate(ctx context.Context, azdClient *az } req.Header.Set("Accept", "application/vnd.github.v3+json") - resp, err := http.DefaultClient.Do(req) + resp, err := a.httpClient.Do(req) if err != nil { return fmt.Errorf("fetching repo tree: %w", err) } @@ -304,7 +305,7 @@ func (a *InitFromCodeAction) scaffoldTemplate(ctx context.Context, azdClient *az return fmt.Errorf("creating request for %s: %w", f.Path, err) } - fileResp, err := http.DefaultClient.Do(fileReq) + fileResp, err := a.httpClient.Do(fileReq) if err != nil { _ = spinner.Stop(ctx) return fmt.Errorf("downloading %s: %w", f.Path, err) From 8d78814553b1ed07734eecede877ded7fd987e7f Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Feb 2026 14:35:34 -0800 Subject: [PATCH 25/34] Remove custom string matching helpers in favor of strings.Contains (#6829) * Initial plan * Replace containsString/searchString with strings.Contains in test file Co-authored-by: trangevi <26490000+trangevi@users.noreply.github.com> * Revert unrelated go.mod/go.sum changes Co-authored-by: trangevi <26490000+trangevi@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: trangevi <26490000+trangevi@users.noreply.github.com> --- .../internal/cmd/init_from_code_test.go | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_test.go index a4aa4feb560..60158d15dd7 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_test.go @@ -7,6 +7,7 @@ import ( "azureaiagent/internal/pkg/agents/agent_yaml" "os" "path/filepath" + "strings" "testing" ) @@ -568,23 +569,10 @@ func stringSlicesEqual(a, b []string) bool { // containsAll checks that s contains all the given substrings. func containsAll(s string, substrings ...string) bool { for _, sub := range substrings { - if !containsString(s, sub) { + if !strings.Contains(s, sub) { return false } } return true } -// containsString checks if s contains substr. -func containsString(s, substr string) bool { - return len(s) >= len(substr) && searchString(s, substr) -} - -func searchString(s, substr string) bool { - for i := 0; i <= len(s)-len(substr); i++ { - if s[i:i+len(substr)] == substr { - return true - } - } - return false -} From 820824a770348f201f27897fa38241008ca5e607 Mon Sep 17 00:00:00 2001 From: trangevi Date: Fri, 20 Feb 2026 14:45:26 -0800 Subject: [PATCH 26/34] cspell Signed-off-by: trangevi --- cli/azd/.vscode/cspell.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cli/azd/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index 59d28532634..8bd61b6506c 100644 --- a/cli/azd/.vscode/cspell.yaml +++ b/cli/azd/.vscode/cspell.yaml @@ -205,6 +205,12 @@ overrides: words: - aoai - azdaiagent + - filename: extensions/azure.ai.agents/internal/cmd/init_from_code.go + words: + - aoai + - aiservices + - azdaiagent + - myregistry - filename: extensions/azure.ai.agents/internal/cmd/listen.go words: - hostedagent From f6a8301efcad939063178a1345ee762dfe880f0a Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Feb 2026 15:01:21 -0800 Subject: [PATCH 27/34] Fix path traversal vulnerability in init_from_code template file download (#6830) * Initial plan * Use filepath.Clean() for path validation to prevent path traversal attacks Co-authored-by: trangevi <26490000+trangevi@users.noreply.github.com> * Revert unrelated go.mod/go.sum changes Co-authored-by: trangevi <26490000+trangevi@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: trangevi <26490000+trangevi@users.noreply.github.com> --- .../azure.ai.agents/internal/cmd/init_from_code.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 8d7da5e6228..d2351b69e4e 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 @@ -186,16 +186,17 @@ func (a *InitFromCodeAction) scaffoldTemplate(ctx context.Context, azdClient *az continue } // Guard against path traversal or unexpected absolute paths - if strings.Contains(entry.Path, "..") || filepath.IsAbs(entry.Path) { + cleanPath := filepath.Clean(entry.Path) + if filepath.IsAbs(cleanPath) || strings.HasPrefix(cleanPath, "..") { return fmt.Errorf("invalid path in repository tree: %s", entry.Path) } - downloadURL := fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s", repoSlug, branch, entry.Path) + downloadURL := fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s", repoSlug, branch, cleanPath) collides := false - if _, statErr := os.Stat(entry.Path); statErr == nil { + if _, statErr := os.Stat(cleanPath); statErr == nil { collides = true } files = append(files, templateFileInfo{ - Path: entry.Path, + Path: cleanPath, URL: downloadURL, Collides: collides, }) From aaab5051fdaa5ee111a523ce316b31687e7ebada Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Feb 2026 15:11:55 -0800 Subject: [PATCH 28/34] fix: capture error from loadAiCatalog in selectNewModel (#6831) * Initial plan * fix: capture error from loadAiCatalog in selectNewModel Co-authored-by: trangevi <26490000+trangevi@users.noreply.github.com> * fix: revert go.mod/go.sum changes and add user-facing error message for loadAiCatalog failure Co-authored-by: trangevi <26490000+trangevi@users.noreply.github.com> * fix: remove fmt.Printf from loadAiCatalog error path Co-authored-by: trangevi <26490000+trangevi@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: trangevi <26490000+trangevi@users.noreply.github.com> --- .../extensions/azure.ai.agents/internal/cmd/init_from_code.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 d2351b69e4e..608e7103ff0 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 @@ -825,7 +825,7 @@ func (a *InitFromCodeAction) selectNewModel(ctx context.Context) (*azdext.AiMode return nil, fmt.Errorf("failed to start spinner: %w", err) } - a.loadAiCatalog(ctx) + err = a.loadAiCatalog(ctx) if stopErr := spinner.Stop(ctx); stopErr != nil { return nil, stopErr } From 59ffa2db5a888bf7dd90e9d39aac169a6646f026 Mon Sep 17 00:00:00 2001 From: trangevi Date: Mon, 23 Feb 2026 10:38:17 -0800 Subject: [PATCH 29/34] Code review Signed-off-by: trangevi --- .../azure.ai.agents/internal/cmd/init.go | 10 +++---- .../internal/cmd/init_from_code.go | 29 ++++++------------- 2 files changed, 14 insertions(+), 25 deletions(-) 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 5f65d043feb..67821c2c317 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -1736,17 +1736,17 @@ func downloadDirectoryContentsWithoutGhCli( if err != nil { return fmt.Errorf("failed to download file %s: %w", itemPath, err) } - defer fileResp.Body.Close() - - if fileResp.StatusCode != http.StatusOK { - return fmt.Errorf("failed to download file %s: status %d", itemPath, fileResp.StatusCode) - } fileContent, err := io.ReadAll(fileResp.Body) + fileResp.Body.Close() if err != nil { return fmt.Errorf("failed to read file content %s: %w", itemPath, err) } + if fileResp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to download file %s: status %d", itemPath, fileResp.StatusCode) + } + if err := os.WriteFile(itemLocalPath, fileContent, 0644); err != nil { return fmt.Errorf("failed to write file %s: %w", itemLocalPath, err) } 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 8d7da5e6228..24be944b3eb 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 @@ -314,18 +314,19 @@ func (a *InitFromCodeAction) scaffoldTemplate(ctx context.Context, azdClient *az _ = spinner.Stop(ctx) return fmt.Errorf("downloading %s: %w", f.Path, err) } - defer fileResp.Body.Close() - - if fileResp.StatusCode != http.StatusOK { - _ = spinner.Stop(ctx) - return fmt.Errorf("downloading %s: status %d", f.Path, fileResp.StatusCode) - } content, err := io.ReadAll(fileResp.Body) + fileResp.Body.Close() if err != nil { _ = spinner.Stop(ctx) return fmt.Errorf("reading %s: %w", f.Path, err) } + + if fileResp.StatusCode != http.StatusOK { + _ = spinner.Stop(ctx) + return fmt.Errorf("downloading %s: status %d", f.Path, fileResp.StatusCode) + } + if err := os.WriteFile(f.Path, content, 0644); err != nil { _ = spinner.Stop(ctx) return fmt.Errorf("writing %s: %w", f.Path, err) @@ -1431,7 +1432,7 @@ func (a *InitFromCodeAction) processExistingFoundryProject(ctx context.Context, }, }) if err != nil { - fmt.Printf("failed to prompt for connection selection: %v\n", err) + return fmt.Errorf("failed to prompt for connection selection: %w", err) } else { selectedConnection = &acrConnections[int(*selectResp.Value)] } @@ -1544,25 +1545,13 @@ func (a *InitFromCodeAction) loadAiCatalog(ctx context.Context) error { return nil } - spinner := ux.NewSpinner(&ux.SpinnerOptions{ - Text: "Loading the model catalog", - ClearOnStop: true, - }) - - if err := spinner.Start(ctx); err != nil { - return fmt.Errorf("failed to start spinner: %w", err) - } - modelResp, err := a.azdClient.Ai().ListModels(ctx, &azdext.ListModelsRequest{ AzureContext: a.azureContext, }) - stopErr := spinner.Stop(ctx) + if err != nil { return fmt.Errorf("failed to load the model catalog: %w", err) } - if stopErr != nil { - return stopErr - } a.modelCatalog = mapModelsByName(modelResp.Models) From 34da3d6e82b5c7b3589581355d62dd3ae3877645 Mon Sep 17 00:00:00 2001 From: trangevi Date: Mon, 23 Feb 2026 10:57:42 -0800 Subject: [PATCH 30/34] Put the status check before attempting to use the response Signed-off-by: trangevi --- .../extensions/azure.ai.agents/internal/cmd/init.go | 8 ++++---- .../azure.ai.agents/internal/cmd/init_from_code.go | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) 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 67821c2c317..f43b0ba63ea 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -1737,16 +1737,16 @@ func downloadDirectoryContentsWithoutGhCli( return fmt.Errorf("failed to download file %s: %w", itemPath, err) } + if fileResp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to download file %s: status %d", itemPath, fileResp.StatusCode) + } + fileContent, err := io.ReadAll(fileResp.Body) fileResp.Body.Close() if err != nil { return fmt.Errorf("failed to read file content %s: %w", itemPath, err) } - if fileResp.StatusCode != http.StatusOK { - return fmt.Errorf("failed to download file %s: status %d", itemPath, fileResp.StatusCode) - } - if err := os.WriteFile(itemLocalPath, fileContent, 0644); err != nil { return fmt.Errorf("failed to write file %s: %w", itemLocalPath, err) } 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 24be944b3eb..b303062deb8 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 @@ -315,6 +315,11 @@ func (a *InitFromCodeAction) scaffoldTemplate(ctx context.Context, azdClient *az return fmt.Errorf("downloading %s: %w", f.Path, err) } + if fileResp.StatusCode != http.StatusOK { + _ = spinner.Stop(ctx) + return fmt.Errorf("downloading %s: status %d", f.Path, fileResp.StatusCode) + } + content, err := io.ReadAll(fileResp.Body) fileResp.Body.Close() if err != nil { @@ -322,11 +327,6 @@ func (a *InitFromCodeAction) scaffoldTemplate(ctx context.Context, azdClient *az return fmt.Errorf("reading %s: %w", f.Path, err) } - if fileResp.StatusCode != http.StatusOK { - _ = spinner.Stop(ctx) - return fmt.Errorf("downloading %s: status %d", f.Path, fileResp.StatusCode) - } - if err := os.WriteFile(f.Path, content, 0644); err != nil { _ = spinner.Stop(ctx) return fmt.Errorf("writing %s: %w", f.Path, err) From 293409b31497cf96ebcecc1086cd168b1efe129a Mon Sep 17 00:00:00 2001 From: trangevi Date: Mon, 23 Feb 2026 11:59:35 -0800 Subject: [PATCH 31/34] Fix path handling Signed-off-by: trangevi --- .../internal/cmd/init_from_code.go | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) 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 ad20689b0c6..f6d6f22aecd 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 @@ -13,6 +13,7 @@ import ( "io" "net/http" "os" + posixpath "path" "path/filepath" "regexp" "slices" @@ -185,14 +186,15 @@ func (a *InitFromCodeAction) scaffoldTemplate(ctx context.Context, azdClient *az if !strings.HasPrefix(entry.Path, "infra/") && entry.Path != "azure.yaml" { continue } - // Guard against path traversal or unexpected absolute paths - cleanPath := filepath.Clean(entry.Path) - if filepath.IsAbs(cleanPath) || strings.HasPrefix(cleanPath, "..") { + // Guard against path traversal or unexpected absolute paths. + // Use posixpath (path) for URL-safe cleaning since GitHub returns forward-slash paths. + cleanPath := posixpath.Clean(entry.Path) + if posixpath.IsAbs(cleanPath) || strings.HasPrefix(cleanPath, "..") { return fmt.Errorf("invalid path in repository tree: %s", entry.Path) } downloadURL := fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s", repoSlug, branch, cleanPath) collides := false - if _, statErr := os.Stat(cleanPath); statErr == nil { + if _, statErr := os.Stat(filepath.FromSlash(cleanPath)); statErr == nil { collides = true } files = append(files, templateFileInfo{ @@ -294,8 +296,10 @@ func (a *InitFromCodeAction) scaffoldTemplate(ctx context.Context, azdClient *az } for _, f := range filesToWrite { + localPath := filepath.FromSlash(f.Path) + // Create parent directories - dir := filepath.Dir(f.Path) + dir := filepath.Dir(localPath) if dir != "." { if err := os.MkdirAll(dir, 0755); err != nil { _ = spinner.Stop(ctx) @@ -328,9 +332,9 @@ func (a *InitFromCodeAction) scaffoldTemplate(ctx context.Context, azdClient *az return fmt.Errorf("reading %s: %w", f.Path, err) } - if err := os.WriteFile(f.Path, content, 0644); err != nil { + if err := os.WriteFile(localPath, content, 0644); err != nil { _ = spinner.Stop(ctx) - return fmt.Errorf("writing %s: %w", f.Path, err) + return fmt.Errorf("writing %s: %w", localPath, err) } } From f8505ac1f9e77e89ab4c111bf89a78b8e05e33c4 Mon Sep 17 00:00:00 2001 From: trangevi Date: Mon, 23 Feb 2026 14:56:09 -0800 Subject: [PATCH 32/34] Remove unused code Signed-off-by: trangevi --- .../internal/cmd/init_from_code.go | 141 +----------------- 1 file changed, 3 insertions(+), 138 deletions(-) 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 f6d6f22aecd..ea609dfe46e 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 @@ -38,7 +38,6 @@ type InitFromCodeAction struct { azureContext *azdext.AzureContext environment *azdext.Environment credential azcore.TokenCredential - modelCatalog map[string]*azdext.AiModel deploymentDetails []project.Deployment httpClient *http.Client } @@ -97,7 +96,7 @@ func (a *InitFromCodeAction) Run(ctx context.Context) error { } // Add the agent to the azd project (azure.yaml) services - if err := a.addToProject(ctx, srcDir, localDefinition.Name, a.flags.host); err != nil { + if err := a.addToProject(ctx, srcDir, localDefinition.Name); err != nil { return fmt.Errorf("failed to add agent to azure.yaml: %w", err) } @@ -119,7 +118,7 @@ func (a *InitFromCodeAction) ensureProject(ctx context.Context) (*azdext.Project if err != nil { fmt.Println("Let's get your project initialized.") - if err := a.scaffoldTemplate(ctx, a.azdClient, "Azure-Samples/azd-ai-starter-basic", "trangevi/existing-acr"); err != nil { + if err := a.scaffoldTemplate(ctx, a.azdClient, "Azure-Samples/azd-ai-starter-basic", "main"); err != nil { return nil, fmt.Errorf("failed to scaffold template: %w", err) } @@ -820,35 +819,6 @@ func (a *InitFromCodeAction) ensureLocation(ctx context.Context) error { } func (a *InitFromCodeAction) selectNewModel(ctx context.Context) (*azdext.AiModel, error) { - var err error - if a.modelCatalog == nil { - spinner := ux.NewSpinner(&ux.SpinnerOptions{ - Text: "Loading model catalog...", - ClearOnStop: true, - }) - if err := spinner.Start(ctx); err != nil { - return nil, fmt.Errorf("failed to start spinner: %w", err) - } - - err = a.loadAiCatalog(ctx) - if stopErr := spinner.Stop(ctx); stopErr != nil { - return nil, stopErr - } - if err != nil { - return nil, fmt.Errorf("failed to list models from catalog: %w", err) - } - } - - var modelNames []string - for modelName := range a.modelCatalog { - modelNames = append(modelNames, modelName) - } - - // selectedModel, err := a.promptForModelWithSearch(ctx, modelNames) - // if err != nil { - // return "", err - // } - promptReq := &azdext.PromptAiModelRequest{ AzureContext: a.azureContext, SelectOptions: &azdext.SelectOptions{ @@ -872,53 +842,6 @@ func (a *InitFromCodeAction) selectNewModel(ctx context.Context) (*azdext.AiMode return selectedModel, nil } -// promptForModelWithSearch prompts the user with a text search field, then shows a filtered Select list. -// Returns the selected model name. -func (a *InitFromCodeAction) promptForModelWithSearch(ctx context.Context, modelNames []string) (string, error) { - for { - // Prompt user for a search term - searchResp, err := a.azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ - Options: &azdext.PromptOptions{ - Message: "Search for a model (e.g., gpt-4o) or press Enter to see all models:", - }, - }) - if err != nil { - return "", fmt.Errorf("failed to prompt for model search: %w", err) - } - - filtered := fuzzyFilterModels(modelNames, searchResp.Value) - if len(filtered) == 0 { - fmt.Printf("No models matching '%s'. Please try again.\n", searchResp.Value) - continue - } - - slices.Sort(filtered) - - defaultIndex := findDefaultModelIndex(filtered) - - choices := make([]*azdext.SelectChoice, len(filtered)) - for i, name := range filtered { - choices[i] = &azdext.SelectChoice{ - Label: name, - Value: name, - } - } - - modelResp, err := a.azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ - Options: &azdext.SelectOptions{ - Message: "Select a model:", - Choices: choices, - SelectedIndex: &defaultIndex, - }, - }) - if err != nil { - return "", fmt.Errorf("failed to prompt for model selection: %w", err) - } - - return filtered[*modelResp.Value], nil - } -} - // normalizeForFuzzyMatch strips common separator characters (hyphens, dots, spaces, underscores) // and lowercases the string for fuzzy comparison. func normalizeForFuzzyMatch(s string) string { @@ -1229,7 +1152,7 @@ func (a *InitFromCodeAction) writeDefinitionToSrcDir(definition *agent_yaml.Cont return definitionPath, nil } -func (a *InitFromCodeAction) addToProject(ctx context.Context, targetDir string, agentName string, host string) error { +func (a *InitFromCodeAction) addToProject(ctx context.Context, targetDir string, agentName string) error { var agentConfig = project.ServiceTargetAgentConfig{} agentConfig.Container = &project.ContainerSettings{ @@ -1279,46 +1202,6 @@ func (a *InitFromCodeAction) addToProject(ctx context.Context, targetDir string, return nil } -// func (a *InitFromCodeAction) getModelDeploymentDetails(ctx context.Context, modelName string) (*ai.AiModelDeployment, error) { -// var model *ai.AiModel -// model, _ = a.modelCatalog[modelName] - -// _, defaultVersion, err := a.modelCatalogService.ListModelVersions(ctx, model, a.azureContext.Scope.Location) -// if err != nil { -// return nil, fmt.Errorf("listing versions for model '%s': %w", model.Name, err) -// } - -// availableSkus, err := a.modelCatalogService.ListModelSkus(ctx, model, a.azureContext.Scope.Location, defaultVersion) -// if err != nil { -// return nil, fmt.Errorf("listing SKUs for model '%s': %w", model.Name, err) -// } - -// // Determine default SKU based on priority list -// defaultSku := "" -// for _, sku := range defaultSkuPriority { -// if slices.Contains(availableSkus, sku) { -// defaultSku = sku -// break -// } -// } - -// deploymentOptions := ai.AiModelDeploymentOptions{ -// Versions: []string{defaultVersion}, -// Skus: []string{defaultSku}, -// } - -// modelDeployment, err := a.modelCatalogService.GetModelDeployment(ctx, model, &deploymentOptions) -// if err != nil { -// return nil, fmt.Errorf("failed to get model deployment: %w", err) -// } - -// if modelDeployment.Sku.Capacity == -1 { -// modelDeployment.Sku.Capacity = 10 -// } - -// return modelDeployment, nil -// } - func (a *InitFromCodeAction) processExistingFoundryProject(ctx context.Context, foundryProject FoundryProjectInfo) error { if err := a.setEnvVar(ctx, "AZURE_AI_PROJECT_ID", foundryProject.ResourceId); err != nil { @@ -1545,24 +1428,6 @@ func (a *InitFromCodeAction) processExistingFoundryProject(ctx context.Context, return nil } -func (a *InitFromCodeAction) loadAiCatalog(ctx context.Context) error { - if a.modelCatalog != nil { - return nil - } - - modelResp, err := a.azdClient.Ai().ListModels(ctx, &azdext.ListModelsRequest{ - AzureContext: a.azureContext, - }) - - if err != nil { - return fmt.Errorf("failed to load the model catalog: %w", err) - } - - a.modelCatalog = mapModelsByName(modelResp.Models) - - return nil -} - func (a *InitFromCodeAction) resolveModelDeploymentNoPrompt( ctx context.Context, model *azdext.AiModel, From b112af1f56e185df56b74b5643a170c2c9b3c76c Mon Sep 17 00:00:00 2001 From: trangevi Date: Mon, 23 Feb 2026 14:58:27 -0800 Subject: [PATCH 33/34] cspell Signed-off-by: trangevi --- cli/azd/.vscode/cspell.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/cli/azd/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index 8bd61b6506c..4ebd4ceb02f 100644 --- a/cli/azd/.vscode/cspell.yaml +++ b/cli/azd/.vscode/cspell.yaml @@ -211,6 +211,7 @@ overrides: - aiservices - azdaiagent - myregistry + - posixpath - filename: extensions/azure.ai.agents/internal/cmd/listen.go words: - hostedagent From d47ac2a3a3654aaf785b439f6fd7d750152c8896 Mon Sep 17 00:00:00 2001 From: trangevi Date: Tue, 24 Feb 2026 09:31:31 -0800 Subject: [PATCH 34/34] Address PR comment Signed-off-by: trangevi --- .../internal/cmd/init_from_code.go | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) 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 ea609dfe46e..fa379b32863 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 @@ -137,18 +137,38 @@ func (a *InitFromCodeAction) ensureProject(ctx context.Context) (*azdext.Project return projectResponse.Project, nil } +// gitHubToken returns a GitHub personal access token from the environment, if available. +// It checks GITHUB_TOKEN first, then GH_TOKEN. +func gitHubToken() string { + if token := os.Getenv("GITHUB_TOKEN"); token != "" { + return token + } + return os.Getenv("GH_TOKEN") +} + +// setGitHubAuthHeader adds an Authorization header to the request if a GitHub token +// is available in the environment. This raises the rate limit from 60 to 5,000 requests/hour. +func setGitHubAuthHeader(req *http.Request, token string) { + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } +} + // scaffoldTemplate downloads a GitHub template repo into the current directory, // checking for file collisions before writing. Files that don't collide are shown // in green; colliding files are shown in yellow and the user is prompted for how // to handle them. func (a *InitFromCodeAction) scaffoldTemplate(ctx context.Context, azdClient *azdext.AzdClient, repoSlug string, branch string) error { // 1. Fetch the recursive file tree from GitHub + ghToken := gitHubToken() + apiUrl := fmt.Sprintf("https://api.github.com/repos/%s/git/trees/%s?recursive=1", repoSlug, branch) req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiUrl, nil) if err != nil { return fmt.Errorf("creating tree request: %w", err) } req.Header.Set("Accept", "application/vnd.github.v3+json") + setGitHubAuthHeader(req, ghToken) resp, err := a.httpClient.Do(req) if err != nil { @@ -157,6 +177,13 @@ func (a *InitFromCodeAction) scaffoldTemplate(ctx context.Context, azdClient *az defer resp.Body.Close() if resp.StatusCode != http.StatusOK { + if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests { + return fmt.Errorf( + "fetching repo tree: status %d (GitHub API rate limit may have been exceeded; "+ + "set GITHUB_TOKEN or GH_TOKEN environment variable to increase the limit)", + resp.StatusCode, + ) + } return fmt.Errorf("fetching repo tree: status %d", resp.StatusCode) } @@ -312,6 +339,7 @@ func (a *InitFromCodeAction) scaffoldTemplate(ctx context.Context, azdClient *az _ = spinner.Stop(ctx) return fmt.Errorf("creating request for %s: %w", f.Path, err) } + setGitHubAuthHeader(fileReq, ghToken) fileResp, err := a.httpClient.Do(fileReq) if err != nil {