diff --git a/cli/azd/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index 59d28532634..4ebd4ceb02f 100644 --- a/cli/azd/.vscode/cspell.yaml +++ b/cli/azd/.vscode/cspell.yaml @@ -205,6 +205,13 @@ overrides: words: - aoai - azdaiagent + - filename: extensions/azure.ai.agents/internal/cmd/init_from_code.go + words: + - aoai + - aiservices + - azdaiagent + - myregistry + - posixpath - filename: extensions/azure.ai.agents/internal/cmd/listen.go words: - hostedagent diff --git a/cli/azd/extensions/azure.ai.agents/go.mod b/cli/azd/extensions/azure.ai.agents/go.mod index a1c79f24d61..5f22c8e9398 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-20260210235925-af2fc5ae4ef3 diff --git a/cli/azd/extensions/azure.ai.agents/go.sum b/cli/azd/extensions/azure.ai.agents/go.sum index bcb74bdf033..0113f12fbf8 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.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go index edb9f65deaa..f43b0ba63ea 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,46 +132,63 @@ 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) + var httpClient = &http.Client{ + Timeout: 30 * time.Second, } - 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) - } + 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) + } - console := input.NewConsole( - false, // noPrompt - true, // isTerminal - input.Writers{Output: os.Stdout}, - input.ConsoleHandles{ - Stderr: os.Stderr, - Stdin: os.Stdin, - Stdout: os.Stdout, - }, - nil, // formatter - nil, // externalPromptCfg - ) - - action := &InitAction{ - azdClient: azdClient, - // azureClient: azure.NewAzureClient(credential), - azureContext: azureContext, - // composedResources: getComposedResourcesResponse.Resources, - console: console, - credential: credential, - projectConfig: projectConfig, - environment: environment, - flags: flags, - } + 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, + projectConfig: projectConfig, + environment: environment, + flags: flags, + httpClient: httpClient, + } + + if err := action.Run(ctx); err != nil { + return fmt.Errorf("failed to run start action: %w", err) + } + } else { + action := &InitFromCodeAction{ + azdClient: azdClient, + flags: flags, + httpClient: httpClient, + } - 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 @@ -197,21 +216,35 @@ func (a *InitAction) Run(ctx context.Context) error { color.Green("Initializing AI agent project...") fmt.Println() - // 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) + // 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) } - color.Green("\nYour azd environment has been initialized to use your existing Microsoft Foundry project.") + 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 --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 @@ -244,26 +277,16 @@ func (a *InitAction) Run(ctx context.Context) error { color.Green("\nAI 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 } 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", "--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 @@ -481,7 +504,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 { @@ -515,7 +539,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{ @@ -999,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 { @@ -1176,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) } @@ -1322,6 +1346,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 @@ -1526,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, "/") @@ -1544,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) } } @@ -1624,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) @@ -1638,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) } @@ -1694,17 +1732,17 @@ 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) } - 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) } @@ -1720,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) } } @@ -1728,3 +1766,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 new file mode 100644 index 00000000000..fa379b32863 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go @@ -0,0 +1,1530 @@ +// 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/project" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + posixpath "path" + "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-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" + "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 + deploymentDetails []project.Deployment + httpClient *http.Client +} + +// 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); 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("Let's get your project initialized.") + + 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) + } + + 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 +} + +// 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 { + return fmt.Errorf("fetching repo tree: %w", err) + } + 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) + } + + 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) 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 + } + // 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(filepath.FromSlash(cleanPath)); statErr == nil { + collides = true + } + files = append(files, templateFileInfo{ + Path: cleanPath, + 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, no changes were made") + } + } 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, no changes were made") + } + } + + // 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 { + localPath := filepath.FromSlash(f.Path) + + // Create parent directories + dir := filepath.Dir(localPath) + 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) + } + setGitHubAuthHeader(fileReq, ghToken) + + fileResp, err := a.httpClient.Do(fileReq) + if err != nil { + _ = spinner.Stop(ctx) + 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 { + _ = spinner.Stop(ctx) + return fmt.Errorf("reading %s: %w", f.Path, err) + } + + if err := os.WriteFile(localPath, content, 0644); err != nil { + _ = spinner.Stop(ctx) + return fmt.Errorf("writing %s: %w", localPath, 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"}, + } + + 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) + } + + var selectedModel *azdext.AiModel + 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 + } + + spinnerText := "Searching for Foundry projects in your subscription..." + if a.flags.projectResourceId != "" { + spinnerText = "Getting details on the provided Foundry project..." + } + + spinner := ux.NewSpinner(&ux.SpinnerOptions{ + Text: spinnerText, + ClearOnStop: true, + }) + if err := spinner.Start(ctx); err != nil { + return nil, fmt.Errorf("failed to start spinner: %w", err) + } + + projects, err := a.listFoundryProjects(ctx, a.azureContext.Scope.SubscriptionId) + if stopErr := spinner.Stop(ctx); stopErr != nil { + return nil, stopErr + } + 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 { + var selectedIdx int32 + if a.flags.projectResourceId == "" { + // Let user pick a Foundry project + projectChoices := make([]*azdext.SelectChoice, len(projects)+1) + for i, p := range projects { + projectChoices[i] = &azdext.SelectChoice{ + Label: fmt.Sprintf("%s / %s (%s)", p.AccountName, p.ProjectName, p.Location), + Value: fmt.Sprintf("%d", i), + } + } + projectChoices = append(projectChoices, &azdext.SelectChoice{ + Label: "Create a new Foundry project", + Value: "__create_new__", + }) + + projectResp, err := a.azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: "Select a Foundry project:", + Choices: projectChoices, + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to prompt for project selection: %w", err) + } + + selectedIdx = *projectResp.Value + } else { + // If projectResourceId is provided, find the matching project and set selectedIdx accordingly + selectedIdx = -1 + for i, p := range projects { + if p.ResourceId == a.flags.projectResourceId { + selectedIdx = int32(i) + break + } + } + if selectedIdx == -1 { + return nil, fmt.Errorf("provided projectResourceId does not match any Foundry projects in the subscription") + } + } + + if selectedIdx < int32(len(projects)) { + // User selected an existing Foundry project + selectedProject := projects[selectedIdx] + + // Set the Foundry project context + a.azureContext.Scope.Location = selectedProject.Location + if err := a.setEnvVar(ctx, "AZURE_LOCATION", selectedProject.Location); err != nil { + return nil, fmt.Errorf("failed to set AZURE_LOCATION environment variable: %w", err) + } + + err := a.processExistingFoundryProject(ctx, selectedProject) + if err != nil { + return nil, fmt.Errorf("failed to set Foundry project context: %w", err) + } + + spinner := ux.NewSpinner(&ux.SpinnerOptions{ + Text: "Searching for model deployments in your Foundry Project...", + ClearOnStop: true, + }) + if err := spinner.Start(ctx); err != nil { + return nil, fmt.Errorf("failed to start spinner: %w", err) + } + + // List deployments in selected project + deployments, err := a.listProjectDeployments(ctx, selectedProject.SubscriptionId, selectedProject.ResourceGroupName, selectedProject.AccountName) + if stopErr := spinner.Stop(ctx); stopErr != nil { + return nil, stopErr + } + if err != nil { + return nil, fmt.Errorf("failed to list deployments: %w", err) + } + + if len(deployments) == 0 { + fmt.Println("No existing deployments found. You can create a new model deployment.") + } + + // 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) + } + + deploymentIdx := *deployResp.Value + if deploymentIdx < int32(len(deployments)) { + // User selected an existing deployment + d := deployments[deploymentIdx] + existingDeployment = &d + fmt.Printf("Model deployment name: %s\n", d.Name) + } else { + // User wants to create a new deployment — region locked to the project's location + selectedModel, err = a.selectNewModel(ctx) + if err != nil { + return nil, fmt.Errorf("failed to select new model: %w", err) + } + } + } 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 != nil { + if existingDeployment == nil { + 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.ModelName, + Model: project.DeploymentModel{ + Name: modelDetails.ModelName, + Format: modelDetails.Format, + Version: modelDetails.Version, + }, + Sku: project.DeploymentSku{ + Name: modelDetails.Sku.Name, + Capacity: int(modelDetails.Capacity), + }, + }) + + *definition.EnvironmentVariables = append(*definition.EnvironmentVariables, agent_yaml.EnvironmentVariable{ + Name: "AZURE_AI_MODEL_DEPLOYMENT_NAME", + Value: modelDetails.ModelName, + }) + } 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 + } else { + tenantResponse, err := a.azdClient.Account().LookupTenant(ctx, &azdext.LookupTenantRequest{ + SubscriptionId: a.azureContext.Scope.SubscriptionId, + }) + if err != nil { + return fmt.Errorf("failed to lookup tenant: %w", err) + } + a.azureContext.Scope.TenantId = tenantResponse.TenantId + } + + // Persist to environment + _, err := a.azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ + EnvName: a.environment.Name, + Key: "AZURE_SUBSCRIPTION_ID", + Value: a.azureContext.Scope.SubscriptionId, + }) + if err != nil { + return fmt.Errorf("failed to set AZURE_SUBSCRIPTION_ID in environment: %w", err) + } + + _, err = a.azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ + EnvName: a.environment.Name, + Key: "AZURE_TENANT_ID", + Value: a.azureContext.Scope.TenantId, + }) + if err != nil { + return fmt.Errorf("failed to set AZURE_TENANT_ID in environment: %w", err) + } + + // Refresh credential with the tenant + credential, err := azidentity.NewAzureDeveloperCLICredential(&azidentity.AzureDeveloperCLICredentialOptions{ + TenantID: a.azureContext.Scope.TenantId, + AdditionallyAllowedTenants: []string{"*"}, + }) + if err != nil { + return fmt.Errorf("failed to create azure credential: %w", err) + } + a.credential = credential + + return nil +} + +func (a *InitFromCodeAction) ensureLocation(ctx context.Context) error { + fmt.Println("Select an Azure location. This determines which models are available and where your Foundry project resources will be deployed.") + + locationResponse, err := a.azdClient.Prompt().PromptLocation(ctx, &azdext.PromptLocationRequest{ + AzureContext: a.azureContext, + }) + if err != nil { + 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) (*azdext.AiModel, error) { + 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 nil, fmt.Errorf("failed to prompt for model selection: %w", err) + } + + selectedModel := modelResp.Model + + return selectedModel, nil +} + +// normalizeForFuzzyMatch strips common separator characters (hyphens, dots, spaces, underscores) +// and lowercases the string for fuzzy comparison. +func normalizeForFuzzyMatch(s string) string { + 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 +} + +// extractSubscriptionId extracts the subscription ID from an Azure resource ID. +func extractSubscriptionId(resourceId string) string { + parts := strings.Split(resourceId, "/") + for i, part := range parts { + if strings.EqualFold(part, "subscriptions") && i+1 < len(parts) { + return parts[i+1] + } + } + return "" +} + +// extractResourceGroup extracts the resource group name from an Azure resource ID. +func extractResourceGroup(resourceId string) string { + parts := strings.Split(resourceId, "/") + for i, part := range parts { + if strings.EqualFold(part, "resourceGroups") && i+1 < len(parts) { + return parts[i+1] + } + } + return "" +} + +// listFoundryProjects enumerates all Foundry projects in a subscription by listing +// CognitiveServices accounts and their projects. +func (a *InitFromCodeAction) listFoundryProjects(ctx context.Context, subscriptionId string) ([]FoundryProjectInfo, error) { + accountsClient, err := armcognitiveservices.NewAccountsClient(subscriptionId, a.credential, azure.NewArmClientOptions()) + if err != nil { + return nil, fmt.Errorf("failed to create accounts client: %w", err) + } + + projectsClient, err := armcognitiveservices.NewProjectsClient(subscriptionId, a.credential, azure.NewArmClientOptions()) + if err != nil { + return nil, fmt.Errorf("failed to create projects client: %w", err) + } + + var results []FoundryProjectInfo + + // List all CognitiveServices accounts + accountPager := accountsClient.NewListPager(nil) + for accountPager.More() { + page, err := accountPager.NextPage(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list accounts: %w", err) + } + + for _, account := range page.Value { + if account.Kind == nil { + continue + } + // Only include Foundry-compatible account types + kind := strings.ToLower(*account.Kind) + if kind != "aiservices" && kind != "openai" { + continue + } + + // Extract resource group from the account's ID + accountId := "" + if account.ID != nil { + accountId = *account.ID + } + rgName := extractResourceGroup(accountId) + if rgName == "" { + continue + } + accountName := "" + if account.Name != nil { + accountName = *account.Name + } + accountLocation := "" + if account.Location != nil { + accountLocation = *account.Location + } + + // List projects under this account + projectPager := projectsClient.NewListPager(rgName, accountName, nil) + for projectPager.More() { + projectPage, err := projectPager.NextPage(ctx) + if err != nil { + // Skip accounts we can't list projects for (permissions, etc.) + break + } + for _, proj := range projectPage.Value { + projName := "" + if proj.Name != nil { + // ARM returns nested resource names like "accountName/projectName" + // Extract just the project name (last segment) + fullName := *proj.Name + if idx := strings.LastIndex(fullName, "/"); idx != -1 { + projName = fullName[idx+1:] + } else { + projName = fullName + } + } + projLocation := accountLocation + if proj.Location != nil { + projLocation = *proj.Location + } + resourceId := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.CognitiveServices/accounts/%s/projects/%s", + subscriptionId, rgName, accountName, projName) + + results = append(results, FoundryProjectInfo{ + SubscriptionId: subscriptionId, + ResourceGroupName: rgName, + AccountName: accountName, + ProjectName: projName, + Location: projLocation, + ResourceId: resourceId, + }) + } + } + } + } + + return results, nil +} + +// FoundryDeploymentInfo holds information about an existing model deployment in a Foundry project. +type FoundryDeploymentInfo struct { + Name string + ModelName string + ModelFormat string + Version string + SkuName string + SkuCapacity int +} + +// listProjectDeployments lists all model deployments in a Foundry project (account). +func (a *InitFromCodeAction) listProjectDeployments(ctx context.Context, subscriptionId, resourceGroup, accountName string) ([]FoundryDeploymentInfo, error) { + deploymentsClient, err := armcognitiveservices.NewDeploymentsClient(subscriptionId, a.credential, azure.NewArmClientOptions()) + if err != nil { + return nil, fmt.Errorf("failed to create deployments client: %w", err) + } + + pager := deploymentsClient.NewListPager(resourceGroup, accountName, nil) + var results []FoundryDeploymentInfo + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list deployments: %w", err) + } + for _, deployment := range page.Value { + info := FoundryDeploymentInfo{} + if deployment.Name != nil { + info.Name = *deployment.Name + } + if deployment.Properties != nil && deployment.Properties.Model != nil { + m := deployment.Properties.Model + if m.Name != nil { + info.ModelName = *m.Name + } + if m.Format != nil { + info.ModelFormat = *m.Format + } + if m.Version != nil { + info.Version = *m.Version + } + } + if deployment.SKU != nil { + if deployment.SKU.Name != nil { + info.SkuName = *deployment.SKU.Name + } + if deployment.SKU.Capacity != nil { + info.SkuCapacity = int(*deployment.SKU.Capacity) + } + } + results = append(results, info) + } + } + return results, nil +} + +func (a *InitFromCodeAction) setEnvVar(ctx context.Context, key, value string) error { + _, err := a.azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ + EnvName: a.environment.Name, + Key: key, + Value: value, + }) + if err != nil { + return fmt.Errorf("failed to set environment variable %s=%s: %w", key, value, err) + } + + return nil +} + +// lookupAcrResourceId finds the resource ID for an ACR given its login server endpoint +func (a *InitFromCodeAction) lookupAcrResourceId(ctx context.Context, subscriptionId string, loginServer string) (string, error) { + // Extract registry name from login server (e.g., "myregistry" from "myregistry.azurecr.io") + parts := strings.Split(loginServer, ".") + if len(parts) < 2 || parts[0] == "" { + return "", fmt.Errorf("invalid login server format: %q, expected e.g. %q", loginServer, "registry.azurecr.io") + } + registryName := parts[0] + + client, err := armcontainerregistry.NewRegistriesClient(subscriptionId, a.credential, azure.NewArmClientOptions()) + if err != nil { + return "", fmt.Errorf("failed to create container registry client: %w", err) + } + + // List all registries and find the matching one + pager := client.NewListPager(nil) + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return "", fmt.Errorf("failed to list registries: %w", err) + } + for _, registry := range page.Value { + if registry.Name != nil && strings.EqualFold(*registry.Name, registryName) { + if registry.ID != nil { + return *registry.ID, nil + } + } + } + } + + return "", fmt.Errorf("container registry '%s' not found in subscription", registryName) +} + +// writeDefinitionToSrcDir writes a ContainerAgent to a YAML file in the src directory and returns the path +func (a *InitFromCodeAction) writeDefinitionToSrcDir(definition *agent_yaml.ContainerAgent, srcDir string) (string, error) { + // Ensure the src directory exists + 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) 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) processExistingFoundryProject(ctx context.Context, foundryProject FoundryProjectInfo) error { + + if err := a.setEnvVar(ctx, "AZURE_AI_PROJECT_ID", foundryProject.ResourceId); err != nil { + return err + } + + // Set the extracted values as environment variables + if err := a.setEnvVar(ctx, "AZURE_RESOURCE_GROUP", foundryProject.ResourceGroupName); err != nil { + return err + } + + if err := a.setEnvVar(ctx, "AZURE_AI_ACCOUNT_NAME", foundryProject.AccountName); err != nil { + return err + } + + if err := a.setEnvVar(ctx, "AZURE_AI_PROJECT_NAME", foundryProject.ProjectName); err != nil { + return err + } + + // Set the Microsoft Foundry endpoint URL + aiFoundryEndpoint := fmt.Sprintf("https://%s.services.ai.azure.com/api/projects/%s", foundryProject.AccountName, foundryProject.ProjectName) + if err := a.setEnvVar(ctx, "AZURE_AI_PROJECT_ENDPOINT", aiFoundryEndpoint); err != nil { + return err + } + + aoaiEndpoint := fmt.Sprintf("https://%s.openai.azure.com/", foundryProject.AccountName) + if err := a.setEnvVar(ctx, "AZURE_OPENAI_ENDPOINT", aoaiEndpoint); err != nil { + return err + } + + // Create FoundryProjectsClient and get connections + foundryClient := 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("\n" + + "An Azure Container Registry (ACR) is required\n\n" + + "Foundry Hosted Agents need an Azure Container Registry to store container images before deployment.\n\n" + + "You can:\n" + + " • Use an existing ACR\n" + + " • Or create a new one from the template during 'azd up'\n\n" + + "Learn more: aka.ms/azdaiagent/docs") + + resp, err := a.azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ + Options: &azdext.PromptOptions{ + Message: "Enter your ACR login server (e.g., myregistry.azurecr.io), or leave blank to create a new one", + IgnoreHintKeys: true, + }, + }) + if err != nil { + return fmt.Errorf("prompting for ACR endpoint: %w", err) + } + + if resp.Value != "" { + // Look up the ACR resource ID from the login server + resourceId, err := a.lookupAcrResourceId(ctx, a.azureContext.Scope.SubscriptionId, resp.Value) + if err != nil { + return fmt.Errorf("failed to lookup ACR resource ID: %w", err) + } + + if err := a.setEnvVar(ctx, "AZURE_CONTAINER_REGISTRY_ENDPOINT", resp.Value); err != nil { + return err + } + if err := a.setEnvVar(ctx, "AZURE_CONTAINER_REGISTRY_RESOURCE_ID", resourceId); err != nil { + return err + } + } + } else { + var selectedConnection *azure.Connection + + if len(acrConnections) == 1 { + selectedConnection = &acrConnections[0] + + fmt.Printf("Using container registry connection: %s (%s)\n", selectedConnection.Name, selectedConnection.Target) + } else { + // Multiple connections found, prompt user to select + fmt.Printf("Found %d container registry connections:\n", len(acrConnections)) + + choices := make([]*azdext.SelectChoice, len(acrConnections)) + for i, conn := range acrConnections { + choices[i] = &azdext.SelectChoice{ + Label: conn.Name, + Value: fmt.Sprintf("%d", i), + } + } + + defaultIndex := int32(0) + selectResp, err := a.azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: "Select a container registry connection to use for this agent", + Choices: choices, + SelectedIndex: &defaultIndex, + }, + }) + if err != nil { + return fmt.Errorf("failed to prompt for connection selection: %w", err) + } else { + selectedConnection = &acrConnections[int(*selectResp.Value)] + } + } + + if err := a.setEnvVar(ctx, "AZURE_AI_PROJECT_ACR_CONNECTION_NAME", selectedConnection.Name); err != nil { + return err + } + + if err := a.setEnvVar(ctx, "AZURE_CONTAINER_REGISTRY_ENDPOINT", selectedConnection.Target); err != nil { + return err + } + } + + // Handle App Insights connections + if len(appInsightsConnections) == 0 { + fmt.Println("\n" + + "Application Insights (optional)\n\n" + + "Enable telemetry to collect logs, traces, and diagnostics for this agent.\n\n" + + "You can:\n" + + " • Use an existing Application Insights resource\n" + + " • Or create a new one during 'azd up'\n\n" + + "Docs: aka.ms/azdaiagent/docs") + + // First prompt for resource ID + resourceIdResp, err := a.azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ + Options: &azdext.PromptOptions{ + Message: "Enter your Application Insights resource ID, or leave blank to create a new one", + IgnoreHintKeys: true, + }, + }) + if err != nil { + return fmt.Errorf("prompting for Application Insights resource ID: %w", err) + } + + if resourceIdResp.Value != "" { + if err := a.setEnvVar(ctx, "APPLICATIONINSIGHTS_RESOURCE_ID", resourceIdResp.Value); err != nil { + return err + } + + // If user provided resource ID, also prompt for connection string + connStrResp, err := a.azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ + Options: &azdext.PromptOptions{ + Message: "Enter your Application Insights connection string", + IgnoreHintKeys: true, + }, + }) + if err != nil { + return fmt.Errorf("prompting for Application Insights connection string: %w", err) + } + + if connStrResp.Value != "" { + if err := a.setEnvVar(ctx, "APPLICATIONINSIGHTS_CONNECTION_STRING", connStrResp.Value); err != nil { + return err + } + } + } + + } else { + var selectedConnection *azure.Connection + + if len(appInsightsConnections) == 1 { + selectedConnection = &appInsightsConnections[0] + + fmt.Printf("Using Application Insights connection: %s (%s)\n", selectedConnection.Name, selectedConnection.Target) + } else { + // Multiple connections found, prompt user to select + fmt.Printf("Found %d Application Insights connections:\n", len(appInsightsConnections)) + + choices := make([]*azdext.SelectChoice, len(appInsightsConnections)) + for i, conn := range appInsightsConnections { + choices[i] = &azdext.SelectChoice{ + Label: conn.Name, + Value: fmt.Sprintf("%d", i), + } + } + + defaultIndex := int32(0) + selectResp, err := a.azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: "Select an Application Insights connection to use for this agent", + Choices: choices, + SelectedIndex: &defaultIndex, + }, + }) + if err != nil { + fmt.Printf("failed to prompt for connection selection: %v\n", err) + } else { + selectedConnection = &appInsightsConnections[int(*selectResp.Value)] + } + } + + if selectedConnection != nil && selectedConnection.Credentials.Key != "" { + if err := a.setEnvVar(ctx, "APPLICATIONINSIGHTS_CONNECTION_NAME", selectedConnection.Name); err != nil { + return err + } + + if err := a.setEnvVar(ctx, "APPLICATIONINSIGHTS_CONNECTION_STRING", selectedConnection.Credentials.Key); err != nil { + return err + } + } + } + } + + return nil +} + +func (a *InitFromCodeAction) resolveModelDeploymentNoPrompt( + ctx context.Context, + model *azdext.AiModel, + location string, +) (*azdext.AiModelDeployment, error) { + resolveResp, err := a.azdClient.Ai().ResolveModelDeployments(ctx, &azdext.ResolveModelDeploymentsRequest{ + AzureContext: a.azureContext, + ModelName: model.Name, + Options: &azdext.AiModelDeploymentOptions{ + Locations: []string{location}, + }, + Quota: &azdext.QuotaCheckOptions{ + MinRemainingCapacity: 1, + }, + }) + if err != nil { + return nil, 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_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_test.go new file mode 100644 index 00000000000..60158d15dd7 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_test.go @@ -0,0 +1,578 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "azureaiagent/internal/pkg/agents/agent_yaml" + "os" + "path/filepath" + "strings" + "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 !strings.Contains(s, sub) { + return false + } + } + return true +} + 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, 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