diff --git a/cli/azd/extensions/azure.ai.models/CHANGELOG.md b/cli/azd/extensions/azure.ai.models/CHANGELOG.md index 0cce502766e..cf529ca10ac 100644 --- a/cli/azd/extensions/azure.ai.models/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.models/CHANGELOG.md @@ -3,6 +3,13 @@ ## 0.0.5-preview (2026-03-24) +- Added `deployment create` command to deploy models (custom and base) using the ARM Cognitive Services SDK +- Added `deployment list` command to list all model deployments with table/JSON output +- Added `deployment show` command to view detailed deployment information +- Added `deployment delete` command to remove model deployments with confirmation prompt +- Auto-resolves `--model-source` (project ARM resource ID) for custom model formats +- Layered context resolution: explicit flags → azd environment → interactive prompt +- User-friendly error handling for 403 (RBAC), 409 (conflict), and quota errors - Improved error handling for 403 (Forbidden) during `custom create` upload, with guidance on required roles and links to prerequisites and RBAC documentation (#7278) ## 0.0.4-preview (2026-03-17) diff --git a/cli/azd/extensions/azure.ai.models/extension.yaml b/cli/azd/extensions/azure.ai.models/extension.yaml index 43f17641435..80772d589d5 100644 --- a/cli/azd/extensions/azure.ai.models/extension.yaml +++ b/cli/azd/extensions/azure.ai.models/extension.yaml @@ -24,3 +24,15 @@ examples: - name: delete description: Delete a custom model. usage: azd ai models custom delete --name my-model + - name: deploy + description: Deploy a model to an inference endpoint. + usage: azd ai models deployment create --name my-deploy --model-name my-model --model-version 1 --model-format OpenAI + - name: list-deployments + description: List all model deployments. + usage: azd ai models deployment list + - name: show-deployment + description: Show details of a model deployment. + usage: azd ai models deployment show --name my-deploy + - name: delete-deployment + description: Delete a model deployment. + usage: azd ai models deployment delete --name my-deploy diff --git a/cli/azd/extensions/azure.ai.models/internal/client/deployment_client.go b/cli/azd/extensions/azure.ai.models/internal/client/deployment_client.go new file mode 100644 index 00000000000..2a2a9b0a954 --- /dev/null +++ b/cli/azd/extensions/azure.ai.models/internal/client/deployment_client.go @@ -0,0 +1,261 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package client + +import ( + "context" + "fmt" + + "azure.ai.models/pkg/models" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices" +) + +const deploymentUserAgent = "azd-ai-models-extension" + +// DeploymentClient wraps the ARM Cognitive Services SDK for deployment operations. +type DeploymentClient struct { + deploymentsClient *armcognitiveservices.DeploymentsClient +} + +// NewDeploymentClient creates a new deployment client using the ARM SDK. +func NewDeploymentClient( + subscriptionID string, + credential azcore.TokenCredential, +) (*DeploymentClient, error) { + clientFactory, err := armcognitiveservices.NewClientFactory( + subscriptionID, + credential, + &arm.ClientOptions{ + ClientOptions: policy.ClientOptions{ + Telemetry: policy.TelemetryOptions{ + ApplicationID: deploymentUserAgent, + }, + }, + }, + ) + if err != nil { + return nil, fmt.Errorf("failed to create ARM client factory: %w", err) + } + + return &DeploymentClient{ + deploymentsClient: clientFactory.NewDeploymentsClient(), + }, nil +} + +// CreateDeployment creates or updates a model deployment using the ARM SDK. +func (c *DeploymentClient) CreateDeployment( + ctx context.Context, + config *models.DeploymentConfig, +) (*models.DeploymentResult, error) { + skuCapacity := config.SkuCapacity + + deployment := armcognitiveservices.Deployment{ + Properties: &armcognitiveservices.DeploymentProperties{ + Model: &armcognitiveservices.DeploymentModel{ + Name: &config.ModelName, + Format: &config.ModelFormat, + Version: &config.ModelVersion, + }, + }, + SKU: &armcognitiveservices.SKU{ + Name: &config.SkuName, + Capacity: &skuCapacity, + }, + } + + // Set model source for custom models (points to the project ARM resource ID) + if config.ModelSource != "" { + deployment.Properties.Model.Source = &config.ModelSource + } + + // Set RAI policy if specified + if config.RaiPolicyName != "" { + deployment.Properties.RaiPolicyName = &config.RaiPolicyName + } + + poller, err := c.deploymentsClient.BeginCreateOrUpdate( + ctx, + config.ResourceGroup, + config.AccountName, + config.DeploymentName, + deployment, + nil, + ) + if err != nil { + return nil, fmt.Errorf("failed to start deployment: %w", err) + } + + if !config.WaitForCompletion { + return &models.DeploymentResult{ + Name: config.DeploymentName, + ModelName: config.ModelName, + ProvisioningState: "Accepted", + }, nil + } + + pollResult, err := poller.PollUntilDone(ctx, nil) + if err != nil { + return nil, fmt.Errorf("deployment failed: %w", err) + } + + result := &models.DeploymentResult{ + Name: config.DeploymentName, + ModelName: config.ModelName, + } + + if pollResult.ID != nil { + result.ID = *pollResult.ID + } + if pollResult.Name != nil { + result.Name = *pollResult.Name + } + if pollResult.Properties != nil && pollResult.Properties.ProvisioningState != nil { + result.ProvisioningState = string(*pollResult.Properties.ProvisioningState) + } + if pollResult.Properties != nil && pollResult.Properties.Model != nil && + pollResult.Properties.Model.Name != nil { + result.ModelName = *pollResult.Properties.Model.Name + } + + return result, nil +} + +// ListDeployments lists all deployments for a Cognitive Services account. +func (c *DeploymentClient) ListDeployments( + ctx context.Context, + resourceGroup string, + accountName string, +) ([]models.DeploymentInfo, error) { + pager := c.deploymentsClient.NewListPager(resourceGroup, accountName, nil) + + var deployments []models.DeploymentInfo + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list deployments: %w", err) + } + + for _, d := range page.Value { + info := models.DeploymentInfo{} + if d.Name != nil { + info.Name = *d.Name + } + if d.Properties != nil { + if d.Properties.ProvisioningState != nil { + info.ProvisioningState = string(*d.Properties.ProvisioningState) + } + if d.Properties.Model != nil { + if d.Properties.Model.Name != nil { + info.ModelName = *d.Properties.Model.Name + } + if d.Properties.Model.Format != nil { + info.ModelFormat = *d.Properties.Model.Format + } + if d.Properties.Model.Version != nil { + info.ModelVersion = *d.Properties.Model.Version + } + } + } + if d.SKU != nil { + if d.SKU.Name != nil { + info.SkuName = *d.SKU.Name + } + if d.SKU.Capacity != nil { + info.SkuCapacity = *d.SKU.Capacity + } + } + deployments = append(deployments, info) + } + } + + return deployments, nil +} + +// DeleteDeployment deletes a deployment from a Cognitive Services account. +func (c *DeploymentClient) DeleteDeployment( + ctx context.Context, + resourceGroup string, + accountName string, + deploymentName string, +) error { + poller, err := c.deploymentsClient.BeginDelete(ctx, resourceGroup, accountName, deploymentName, nil) + if err != nil { + return fmt.Errorf("failed to start deployment deletion: %w", err) + } + + _, err = poller.PollUntilDone(ctx, nil) + if err != nil { + return fmt.Errorf("deployment deletion failed: %w", err) + } + + return nil +} + +// GetDeployment retrieves details of a specific deployment. +func (c *DeploymentClient) GetDeployment( + ctx context.Context, + resourceGroup string, + accountName string, + deploymentName string, +) (*models.DeploymentDetail, error) { + resp, err := c.deploymentsClient.Get(ctx, resourceGroup, accountName, deploymentName, nil) + if err != nil { + return nil, fmt.Errorf("failed to get deployment: %w", err) + } + + detail := &models.DeploymentDetail{ + Name: deploymentName, + } + + if resp.ID != nil { + detail.ID = *resp.ID + } + if resp.Name != nil { + detail.Name = *resp.Name + } + if resp.Properties != nil { + if resp.Properties.ProvisioningState != nil { + detail.ProvisioningState = string(*resp.Properties.ProvisioningState) + } + if resp.Properties.RaiPolicyName != nil { + detail.RaiPolicyName = *resp.Properties.RaiPolicyName + } + if resp.Properties.Model != nil { + if resp.Properties.Model.Name != nil { + detail.ModelName = *resp.Properties.Model.Name + } + if resp.Properties.Model.Format != nil { + detail.ModelFormat = *resp.Properties.Model.Format + } + if resp.Properties.Model.Version != nil { + detail.ModelVersion = *resp.Properties.Model.Version + } + if resp.Properties.Model.Source != nil { + detail.ModelSource = *resp.Properties.Model.Source + } + } + } + if resp.SKU != nil { + if resp.SKU.Name != nil { + detail.SkuName = *resp.SKU.Name + } + if resp.SKU.Capacity != nil { + detail.SkuCapacity = *resp.SKU.Capacity + } + } + if resp.SystemData != nil { + if resp.SystemData.CreatedAt != nil { + detail.CreatedAt = resp.SystemData.CreatedAt.String() + } + if resp.SystemData.LastModifiedAt != nil { + detail.LastModifiedAt = resp.SystemData.LastModifiedAt.String() + } + } + + return detail, nil +} diff --git a/cli/azd/extensions/azure.ai.models/internal/cmd/deployment.go b/cli/azd/extensions/azure.ai.models/internal/cmd/deployment.go new file mode 100644 index 00000000000..d45e9c70996 --- /dev/null +++ b/cli/azd/extensions/azure.ai.models/internal/cmd/deployment.go @@ -0,0 +1,187 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +// deploymentFlags holds common flags for all deployment subcommands. +type deploymentFlags struct { + subscriptionId string + projectEndpoint string + resourceGroup string +} + +// newDeploymentCommand creates the "deployment" command group for model deployment operations. +func newDeploymentCommand() *cobra.Command { + flags := &deploymentFlags{} + + deploymentCmd := &cobra.Command{ + Use: "deployment", + Short: "Manage model deployments in Azure AI Foundry", + PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { + ctx := azdext.WithAccessToken(cmd.Context()) + return resolveDeploymentContext(ctx, flags) + }, + } + + deploymentCmd.PersistentFlags().StringVarP(&flags.subscriptionId, "subscription", "s", "", + "Azure subscription ID") + deploymentCmd.PersistentFlags().StringVarP(&flags.projectEndpoint, "project-endpoint", "e", "", + "Azure AI Foundry project endpoint URL") + deploymentCmd.PersistentFlags().StringVarP(&flags.resourceGroup, "resource-group", "g", "", + "Azure resource group name") + + deploymentCmd.AddCommand(newDeploymentCreateCommand(flags)) + deploymentCmd.AddCommand(newDeploymentListCommand(flags)) + deploymentCmd.AddCommand(newDeploymentShowCommand(flags)) + deploymentCmd.AddCommand(newDeploymentDeleteCommand(flags)) + + return deploymentCmd +} + +// resolveDeploymentContext resolves subscription, project endpoint, resource group, and account +// from flags or the azd environment. Priority: +// 1. Explicit flags (highest) +// 2. azd environment variables (from init) +// 3. Interactive prompt (lowest) +func resolveDeploymentContext(ctx context.Context, flags *deploymentFlags) error { + // If all required context is already provided via flags, skip env lookup + if flags.projectEndpoint != "" && flags.resourceGroup != "" && flags.subscriptionId != "" { + return nil + } + + // Try to read from azd environment + azdClient, err := azdext.NewAzdClient() + if err != nil { + if flags.projectEndpoint == "" || flags.resourceGroup == "" { + return fmt.Errorf( + "--project-endpoint (-e) and --resource-group (-g) are required when azd is not available.\n\n" + + "Or run 'azd ai models init' to set up your project first") + } + return nil + } + defer azdClient.Close() + + envMap := loadEnvMap(ctx, azdClient) + + if flags.projectEndpoint == "" { + flags.projectEndpoint = envMap["AZURE_PROJECT_ENDPOINT"] + if flags.projectEndpoint == "" { + account := envMap["AZURE_ACCOUNT_NAME"] + project := envMap["AZURE_PROJECT_NAME"] + if account != "" && project != "" { + flags.projectEndpoint = buildProjectEndpoint(account, project) + } + } + } + + if flags.subscriptionId == "" { + flags.subscriptionId = envMap["AZURE_SUBSCRIPTION_ID"] + } + + if flags.resourceGroup == "" { + flags.resourceGroup = envMap["AZURE_RESOURCE_GROUP_NAME"] + } + + // If still missing critical context, fall back to prompt + if flags.projectEndpoint == "" { + customFlags := &customFlags{ + subscriptionId: flags.subscriptionId, + projectEndpoint: flags.projectEndpoint, + } + if err := promptForProject(ctx, customFlags, azdClient); err != nil { + return err + } + flags.projectEndpoint = customFlags.projectEndpoint + flags.subscriptionId = customFlags.subscriptionId + } + + return nil +} + +// loadEnvMap loads all environment variables from the current azd environment. +func loadEnvMap(ctx context.Context, azdClient *azdext.AzdClient) map[string]string { + envMap := make(map[string]string) + + envResp, err := azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}) + if err != nil || envResp.Environment == nil { + return envMap + } + + valuesResp, err := azdClient.Environment().GetValues(ctx, &azdext.GetEnvironmentRequest{ + Name: envResp.Environment.Name, + }) + if err != nil { + return envMap + } + + for _, kv := range valuesResp.KeyValues { + envMap[kv.Key] = kv.Value + } + + return envMap +} + +// resolveAccountName extracts the account name from the project endpoint URL. +func resolveAccountName(projectEndpoint string) (string, error) { + accountName, _, err := parseProjectEndpoint(projectEndpoint) + if err != nil { + return "", fmt.Errorf("failed to extract account name from endpoint: %w", err) + } + return accountName, nil +} + +// resolveTenantID resolves the tenant ID for the given subscription. +func resolveTenantID(ctx context.Context, subscriptionId string) (string, error) { + azdClient, err := azdext.NewAzdClient() + if err != nil { + return "", fmt.Errorf("failed to create azd client: %w", err) + } + defer azdClient.Close() + + // Try environment first + envMap := loadEnvMap(ctx, azdClient) + if tenantID := envMap["AZURE_TENANT_ID"]; tenantID != "" { + return tenantID, nil + } + + // Fall back to LookupTenant + if subscriptionId != "" { + tenantResp, err := azdClient.Account().LookupTenant(ctx, &azdext.LookupTenantRequest{ + SubscriptionId: subscriptionId, + }) + if err != nil { + return "", fmt.Errorf("failed to get tenant ID: %w", err) + } + return tenantResp.TenantId, nil + } + + return "", nil +} + +// buildProjectResourceID constructs the ARM resource ID for the Foundry project. +func buildProjectResourceID(subscriptionID, resourceGroup, accountName, projectName string) string { + return fmt.Sprintf( + "/subscriptions/%s/resourceGroups/%s/providers/Microsoft.CognitiveServices/accounts/%s/projects/%s", + subscriptionID, resourceGroup, accountName, projectName, + ) +} + +// createCredential creates an Azure Developer CLI credential with the given tenant ID. +func createCredential(tenantID string) (*azidentity.AzureDeveloperCLICredential, error) { + opts := &azidentity.AzureDeveloperCLICredentialOptions{ + AdditionallyAllowedTenants: []string{"*"}, + } + if tenantID != "" { + opts.TenantID = tenantID + } + return azidentity.NewAzureDeveloperCLICredential(opts) +} diff --git a/cli/azd/extensions/azure.ai.models/internal/cmd/deployment_create.go b/cli/azd/extensions/azure.ai.models/internal/cmd/deployment_create.go new file mode 100644 index 00000000000..eeb20a2a8c6 --- /dev/null +++ b/cli/azd/extensions/azure.ai.models/internal/cmd/deployment_create.go @@ -0,0 +1,270 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "errors" + "fmt" + "strings" + + "azure.ai.models/internal/client" + "azure.ai.models/pkg/models" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/ux" + "github.com/fatih/color" + "github.com/spf13/cobra" +) + +type deploymentCreateFlags struct { + Name string + ModelName string + ModelVersion string + ModelFormat string + ModelSource string + SkuName string + SkuCapacity int32 + RaiPolicy string + NoWait bool +} + +func newDeploymentCreateCommand(parentFlags *deploymentFlags) *cobra.Command { + flags := &deploymentCreateFlags{} + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a model deployment in Azure AI Foundry", + Long: `Create a model deployment for inference in Azure AI Foundry. + +This command deploys a registered model (custom or base) to an inference endpoint +using the Azure Cognitive Services ARM API. + +For custom models, --model-source is auto-resolved from the project context. +For base models, specify --model-format (e.g., OpenAI).`, + Example: ` # Deploy a custom Fireworks model + azd ai models deployment create --name my-deploy --model-name qwen3-14b \ + --model-version 1 --model-format FireworksCustom --sku-name GlobalProvisionedManaged --sku-capacity 80 + + # Deploy with minimal flags (uses defaults: Standard SKU, capacity 1) + azd ai models deployment create --name my-deploy --model-name my-model \ + --model-version 1 --model-format OpenAI + + # Deploy with explicit resource group (skip environment lookup) + azd ai models deployment create --name my-deploy --model-name my-model \ + --model-version 1 --model-format OpenAI -g my-resource-group`, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := azdext.WithAccessToken(cmd.Context()) + return runDeploymentCreate(ctx, parentFlags, flags) + }, + } + + cmd.Flags().StringVarP(&flags.Name, "name", "n", "", "Deployment name (required)") + cmd.Flags().StringVar(&flags.ModelName, "model-name", "", "Model name to deploy (required)") + cmd.Flags().StringVar(&flags.ModelVersion, "model-version", "", "Model version (required)") + cmd.Flags().StringVar(&flags.ModelFormat, "model-format", "", + "Model format (required, e.g., OpenAI, FireworksCustom)") + cmd.Flags().StringVar(&flags.ModelSource, "model-source", "", + "Model source ARM resource ID (auto-resolved for custom models if not provided)") + cmd.Flags().StringVar(&flags.SkuName, "sku-name", "Standard", + "SKU name (Standard, GlobalStandard, ProvisionedManaged, GlobalProvisionedManaged)") + cmd.Flags().Int32Var(&flags.SkuCapacity, "sku-capacity", 1, "SKU capacity units") + cmd.Flags().StringVar(&flags.RaiPolicy, "rai-policy", "", + "RAI content filter policy name (e.g., Microsoft.DefaultV2)") + cmd.Flags().BoolVar(&flags.NoWait, "no-wait", false, + "Start deployment and return immediately without waiting for completion") + + _ = cmd.MarkFlagRequired("name") + _ = cmd.MarkFlagRequired("model-name") + _ = cmd.MarkFlagRequired("model-version") + _ = cmd.MarkFlagRequired("model-format") + + return cmd +} + +func runDeploymentCreate(ctx context.Context, parentFlags *deploymentFlags, flags *deploymentCreateFlags) error { + azdClient, err := azdext.NewAzdClient() + if err != nil { + return fmt.Errorf("failed to create azd client: %w", err) + } + defer azdClient.Close() + + if err := azdext.WaitForDebugger(ctx, azdClient); err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, azdext.ErrDebuggerAborted) { + return nil + } + return fmt.Errorf("failed waiting for debugger: %w", err) + } + + // Resolve account name from project endpoint + accountName, err := resolveAccountName(parentFlags.projectEndpoint) + if err != nil { + return fmt.Errorf("failed to resolve account name: %w", err) + } + + // Resolve resource group — flag > env > error + resourceGroup := parentFlags.resourceGroup + if resourceGroup == "" { + envMap := loadEnvMap(ctx, azdClient) + resourceGroup = envMap["AZURE_RESOURCE_GROUP_NAME"] + } + if resourceGroup == "" { + return fmt.Errorf( + "resource group is required for deployment.\n\n" + + "Provide it with --resource-group (-g) or run 'azd ai models init' to configure your project") + } + + // Resolve subscription ID + subscriptionID := parentFlags.subscriptionId + if subscriptionID == "" { + return fmt.Errorf( + "subscription ID is required for deployment.\n\n" + + "Provide it with --subscription (-s) or run 'azd ai models init' to configure your project") + } + + // Resolve tenant ID for credential + tenantID, err := resolveTenantID(ctx, subscriptionID) + if err != nil { + return fmt.Errorf("failed to resolve tenant ID: %w", err) + } + + // Create credential + credential, err := createCredential(tenantID) + if err != nil { + return fmt.Errorf("failed to create Azure credential: %w", err) + } + + // Auto-resolve model source for custom models if not explicitly provided + modelSource := flags.ModelSource + if modelSource == "" && isCustomModelFormat(flags.ModelFormat) { + _, projectName, parseErr := parseProjectEndpoint(parentFlags.projectEndpoint) + if parseErr == nil && projectName != "" { + modelSource = buildProjectResourceID(subscriptionID, resourceGroup, accountName, projectName) + } + } + + // Build deployment config + config := &models.DeploymentConfig{ + DeploymentName: flags.Name, + ModelName: flags.ModelName, + ModelVersion: flags.ModelVersion, + ModelFormat: flags.ModelFormat, + ModelSource: modelSource, + SkuName: flags.SkuName, + SkuCapacity: flags.SkuCapacity, + RaiPolicyName: flags.RaiPolicy, + SubscriptionID: subscriptionID, + ResourceGroup: resourceGroup, + AccountName: accountName, + TenantID: tenantID, + WaitForCompletion: !flags.NoWait, + } + + // Display deployment info + fmt.Printf("Creating deployment: %s\n", flags.Name) + fmt.Printf(" Model: %s (version %s, format %s)\n", flags.ModelName, flags.ModelVersion, flags.ModelFormat) + fmt.Printf(" SKU: %s (capacity %d)\n", flags.SkuName, flags.SkuCapacity) + fmt.Printf(" Account: %s\n", accountName) + fmt.Printf(" Resource: %s\n", resourceGroup) + if modelSource != "" { + fmt.Printf(" Source: %s\n", modelSource) + } + fmt.Println() + + // Create ARM deployment client + deployClient, err := client.NewDeploymentClient(subscriptionID, credential) + if err != nil { + return fmt.Errorf("failed to create deployment client: %w", err) + } + + if flags.NoWait { + result, err := deployClient.CreateDeployment(ctx, config) + if err != nil { + return handleDeploymentError(err) + } + + color.Green("✓ Deployment request accepted") + fmt.Printf(" Name: %s\n", result.Name) + fmt.Printf(" Status: %s\n", result.ProvisioningState) + fmt.Println() + color.Yellow("Use 'azd ai models deployment show --name %s' to check deployment status.", flags.Name) + return nil + } + + // Wait for deployment with spinner + spinner := ux.NewSpinner(&ux.SpinnerOptions{ + Text: "Deploying model (this may take several minutes)...", + }) + if err := spinner.Start(ctx); err != nil { + fmt.Printf("failed to start spinner: %v\n", err) + } + + result, err := deployClient.CreateDeployment(ctx, config) + _ = spinner.Stop(ctx) + fmt.Println() + + if err != nil { + return handleDeploymentError(err) + } + + // Success output + color.Green("✓ Deployment created successfully!") + fmt.Println() + fmt.Println(strings.Repeat("─", 50)) + if result.ID != "" { + fmt.Printf(" ID: %s\n", result.ID) + } + fmt.Printf(" Name: %s\n", result.Name) + fmt.Printf(" Model: %s\n", result.ModelName) + fmt.Printf(" Status: %s\n", result.ProvisioningState) + fmt.Println(strings.Repeat("─", 50)) + + return nil +} + +// handleDeploymentError provides user-friendly error messages for common deployment failures. +func handleDeploymentError(err error) error { + errMsg := err.Error() + + if strings.Contains(errMsg, "403") || strings.Contains(strings.ToLower(errMsg), "forbidden") { + fmt.Println() + color.Red("✗ Permission denied: you do not have the required role to create deployments.") + fmt.Println() + color.Yellow("Ensure you have the appropriate role assigned for this Azure AI Foundry project.") + fmt.Println() + fmt.Println(" Prerequisites:") + fmt.Println(" https://learn.microsoft.com/en-us/azure/foundry/how-to/fireworks/import-custom-models?tabs=rest-api#prerequisites") + fmt.Println() + fmt.Println(" Role-based access control (RBAC) details:") + fmt.Println(" https://learn.microsoft.com/en-us/azure/foundry/concepts/rbac-foundry") + return fmt.Errorf("insufficient permissions (403)") + } + + if strings.Contains(errMsg, "409") || strings.Contains(strings.ToLower(errMsg), "conflict") { + fmt.Println() + color.Red("✗ A deployment with this name already exists.") + fmt.Println() + color.Yellow("Use a different --name or delete the existing deployment first.") + return fmt.Errorf("deployment already exists (409)") + } + + if strings.Contains(strings.ToLower(errMsg), "quota") || + strings.Contains(strings.ToLower(errMsg), "capacity") { + fmt.Println() + color.Red("✗ Insufficient quota or capacity for the requested deployment.") + fmt.Println() + color.Yellow("Try reducing --sku-capacity or using a different SKU/region.") + return fmt.Errorf("insufficient quota: %w", err) + } + + fmt.Println() + color.Red("✗ Deployment failed: %v", err) + return fmt.Errorf("deployment failed: %w", err) +} + +// isCustomModelFormat returns true if the model format indicates a custom model. +func isCustomModelFormat(format string) bool { + lower := strings.ToLower(format) + return strings.Contains(lower, "custom") || strings.Contains(lower, "safetensors") +} diff --git a/cli/azd/extensions/azure.ai.models/internal/cmd/deployment_delete.go b/cli/azd/extensions/azure.ai.models/internal/cmd/deployment_delete.go new file mode 100644 index 00000000000..2b4d54c650e --- /dev/null +++ b/cli/azd/extensions/azure.ai.models/internal/cmd/deployment_delete.go @@ -0,0 +1,135 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "bufio" + "context" + "errors" + "fmt" + "os" + "strings" + + "azure.ai.models/internal/client" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/ux" + "github.com/fatih/color" + "github.com/spf13/cobra" +) + +type deploymentDeleteFlags struct { + Name string + Force bool +} + +func newDeploymentDeleteCommand(parentFlags *deploymentFlags) *cobra.Command { + flags := &deploymentDeleteFlags{} + + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete a model deployment", + Long: "Delete a model deployment from the Azure AI Foundry project's Cognitive Services account.", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := azdext.WithAccessToken(cmd.Context()) + return runDeploymentDelete(ctx, parentFlags, flags) + }, + } + + cmd.Flags().StringVarP(&flags.Name, "name", "n", "", "Deployment name to delete (required)") + cmd.Flags().BoolVarP(&flags.Force, "force", "f", false, "Skip confirmation prompt") + + _ = cmd.MarkFlagRequired("name") + + return cmd +} + +func runDeploymentDelete(ctx context.Context, parentFlags *deploymentFlags, flags *deploymentDeleteFlags) error { + azdClient, err := azdext.NewAzdClient() + if err != nil { + return fmt.Errorf("failed to create azd client: %w", err) + } + defer azdClient.Close() + + if err := azdext.WaitForDebugger(ctx, azdClient); err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, azdext.ErrDebuggerAborted) { + return nil + } + return fmt.Errorf("failed waiting for debugger: %w", err) + } + + // Confirmation prompt unless --force + if !flags.Force && !rootFlags.NoPrompt { + fmt.Printf("Delete deployment '%s'? This action cannot be undone.\n", flags.Name) + fmt.Print("Type the deployment name to confirm: ") + reader := bufio.NewReader(os.Stdin) + input, _ := reader.ReadString('\n') + input = strings.TrimSpace(input) + + if input != flags.Name { + fmt.Println("Deletion cancelled.") + return nil + } + } + + // Resolve context + accountName, err := resolveAccountName(parentFlags.projectEndpoint) + if err != nil { + return fmt.Errorf("failed to resolve account name: %w", err) + } + + resourceGroup := parentFlags.resourceGroup + if resourceGroup == "" { + envMap := loadEnvMap(ctx, azdClient) + resourceGroup = envMap["AZURE_RESOURCE_GROUP_NAME"] + } + if resourceGroup == "" { + return fmt.Errorf( + "resource group is required to delete a deployment.\n\n" + + "Provide it with --resource-group (-g) or run 'azd ai models init' to configure your project") + } + + subscriptionID := parentFlags.subscriptionId + if subscriptionID == "" { + return fmt.Errorf( + "subscription ID is required to delete a deployment.\n\n" + + "Provide it with --subscription (-s) or run 'azd ai models init' to configure your project") + } + + tenantID, err := resolveTenantID(ctx, subscriptionID) + if err != nil { + return fmt.Errorf("failed to resolve tenant ID: %w", err) + } + + credential, err := createCredential(tenantID) + if err != nil { + return fmt.Errorf("failed to create Azure credential: %w", err) + } + + spinner := ux.NewSpinner(&ux.SpinnerOptions{ + Text: fmt.Sprintf("Deleting deployment '%s'...", flags.Name), + }) + if err := spinner.Start(ctx); err != nil { + fmt.Printf("failed to start spinner: %v\n", err) + } + + deployClient, err := client.NewDeploymentClient(subscriptionID, credential) + if err != nil { + _ = spinner.Stop(ctx) + fmt.Println() + return fmt.Errorf("failed to create deployment client: %w", err) + } + + err = deployClient.DeleteDeployment(ctx, resourceGroup, accountName, flags.Name) + _ = spinner.Stop(ctx) + fmt.Println() + + if err != nil { + color.Red("✗ Failed to delete deployment: %v", err) + return fmt.Errorf("failed to delete deployment: %w", err) + } + + color.Green("✓ Deployment '%s' deleted", flags.Name) + return nil +} diff --git a/cli/azd/extensions/azure.ai.models/internal/cmd/deployment_list.go b/cli/azd/extensions/azure.ai.models/internal/cmd/deployment_list.go new file mode 100644 index 00000000000..648254c6ff4 --- /dev/null +++ b/cli/azd/extensions/azure.ai.models/internal/cmd/deployment_list.go @@ -0,0 +1,128 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "errors" + "fmt" + + "azure.ai.models/internal/client" + "azure.ai.models/internal/utils" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/ux" + "github.com/spf13/cobra" +) + +type deploymentListFlags struct { + Output string +} + +func newDeploymentListCommand(parentFlags *deploymentFlags) *cobra.Command { + flags := &deploymentListFlags{} + + cmd := &cobra.Command{ + Use: "list", + Short: "List all model deployments", + Long: "List all model deployments for the Azure AI Foundry project's Cognitive Services account.", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := azdext.WithAccessToken(cmd.Context()) + return runDeploymentList(ctx, parentFlags, flags) + }, + } + + cmd.Flags().StringVarP(&flags.Output, "output", "o", "table", "Output format (table, json)") + + return cmd +} + +func runDeploymentList(ctx context.Context, parentFlags *deploymentFlags, flags *deploymentListFlags) error { + azdClient, err := azdext.NewAzdClient() + if err != nil { + return fmt.Errorf("failed to create azd client: %w", err) + } + defer azdClient.Close() + + if err := azdext.WaitForDebugger(ctx, azdClient); err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, azdext.ErrDebuggerAborted) { + return nil + } + return fmt.Errorf("failed waiting for debugger: %w", err) + } + + // Resolve account name from project endpoint + accountName, err := resolveAccountName(parentFlags.projectEndpoint) + if err != nil { + return fmt.Errorf("failed to resolve account name: %w", err) + } + + // Resolve resource group + resourceGroup := parentFlags.resourceGroup + if resourceGroup == "" { + envMap := loadEnvMap(ctx, azdClient) + resourceGroup = envMap["AZURE_RESOURCE_GROUP_NAME"] + } + if resourceGroup == "" { + return fmt.Errorf( + "resource group is required to list deployments.\n\n" + + "Provide it with --resource-group (-g) or run 'azd ai models init' to configure your project") + } + + // Resolve subscription and tenant + subscriptionID := parentFlags.subscriptionId + if subscriptionID == "" { + return fmt.Errorf( + "subscription ID is required to list deployments.\n\n" + + "Provide it with --subscription (-s) or run 'azd ai models init' to configure your project") + } + + tenantID, err := resolveTenantID(ctx, subscriptionID) + if err != nil { + return fmt.Errorf("failed to resolve tenant ID: %w", err) + } + + credential, err := createCredential(tenantID) + if err != nil { + return fmt.Errorf("failed to create Azure credential: %w", err) + } + + spinner := ux.NewSpinner(&ux.SpinnerOptions{ + Text: "Fetching deployments...", + }) + if err := spinner.Start(ctx); err != nil { + fmt.Printf("failed to start spinner: %v\n", err) + } + + deployClient, err := client.NewDeploymentClient(subscriptionID, credential) + if err != nil { + _ = spinner.Stop(ctx) + fmt.Println() + return fmt.Errorf("failed to create deployment client: %w", err) + } + + deployments, err := deployClient.ListDeployments(ctx, resourceGroup, accountName) + _ = spinner.Stop(ctx) + fmt.Print("\n\n") + + if err != nil { + return fmt.Errorf("failed to list deployments: %w", err) + } + + switch flags.Output { + case "json": + if err := utils.PrintObject(deployments, utils.FormatJSON); err != nil { + return err + } + case "table", "": + if err := utils.PrintObject(deployments, utils.FormatTable); err != nil { + return err + } + default: + return fmt.Errorf("unsupported output format: %s (supported: table, json)", flags.Output) + } + + fmt.Printf("\n%d deployment(s) found\n", len(deployments)) + return nil +} diff --git a/cli/azd/extensions/azure.ai.models/internal/cmd/deployment_show.go b/cli/azd/extensions/azure.ai.models/internal/cmd/deployment_show.go new file mode 100644 index 00000000000..792f0db6460 --- /dev/null +++ b/cli/azd/extensions/azure.ai.models/internal/cmd/deployment_show.go @@ -0,0 +1,165 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "errors" + "fmt" + "strings" + + "azure.ai.models/internal/client" + "azure.ai.models/internal/utils" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/ux" + "github.com/spf13/cobra" +) + +type deploymentShowFlags struct { + Name string + Output string +} + +func newDeploymentShowCommand(parentFlags *deploymentFlags) *cobra.Command { + flags := &deploymentShowFlags{} + + cmd := &cobra.Command{ + Use: "show", + Short: "Show details of a model deployment", + Long: "Show detailed information about a specific model deployment in the Azure AI Foundry project.", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := azdext.WithAccessToken(cmd.Context()) + return runDeploymentShow(ctx, parentFlags, flags) + }, + } + + cmd.Flags().StringVarP(&flags.Name, "name", "n", "", "Deployment name (required)") + cmd.Flags().StringVarP(&flags.Output, "output", "o", "table", "Output format (table, json)") + + _ = cmd.MarkFlagRequired("name") + + return cmd +} + +func runDeploymentShow(ctx context.Context, parentFlags *deploymentFlags, flags *deploymentShowFlags) error { + azdClient, err := azdext.NewAzdClient() + if err != nil { + return fmt.Errorf("failed to create azd client: %w", err) + } + defer azdClient.Close() + + if err := azdext.WaitForDebugger(ctx, azdClient); err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, azdext.ErrDebuggerAborted) { + return nil + } + return fmt.Errorf("failed waiting for debugger: %w", err) + } + + // Resolve context + accountName, err := resolveAccountName(parentFlags.projectEndpoint) + if err != nil { + return fmt.Errorf("failed to resolve account name: %w", err) + } + + resourceGroup := parentFlags.resourceGroup + if resourceGroup == "" { + envMap := loadEnvMap(ctx, azdClient) + resourceGroup = envMap["AZURE_RESOURCE_GROUP_NAME"] + } + if resourceGroup == "" { + return fmt.Errorf( + "resource group is required to show deployment details.\n\n" + + "Provide it with --resource-group (-g) or run 'azd ai models init' to configure your project") + } + + subscriptionID := parentFlags.subscriptionId + if subscriptionID == "" { + return fmt.Errorf( + "subscription ID is required to show deployment details.\n\n" + + "Provide it with --subscription (-s) or run 'azd ai models init' to configure your project") + } + + tenantID, err := resolveTenantID(ctx, subscriptionID) + if err != nil { + return fmt.Errorf("failed to resolve tenant ID: %w", err) + } + + credential, err := createCredential(tenantID) + if err != nil { + return fmt.Errorf("failed to create Azure credential: %w", err) + } + + spinner := ux.NewSpinner(&ux.SpinnerOptions{ + Text: "Fetching deployment details...", + }) + if err := spinner.Start(ctx); err != nil { + fmt.Printf("failed to start spinner: %v\n", err) + } + + deployClient, err := client.NewDeploymentClient(subscriptionID, credential) + if err != nil { + _ = spinner.Stop(ctx) + fmt.Println() + return fmt.Errorf("failed to create deployment client: %w", err) + } + + detail, err := deployClient.GetDeployment(ctx, resourceGroup, accountName, flags.Name) + _ = spinner.Stop(ctx) + fmt.Print("\n\n") + + if err != nil { + return fmt.Errorf("failed to get deployment: %w", err) + } + + switch flags.Output { + case "json": + if err := utils.PrintObject(detail, utils.FormatJSON); err != nil { + return err + } + case "table", "": + fmt.Printf("Deployment: %s\n", detail.Name) + fmt.Println(strings.Repeat("─", 50)) + + fmt.Println("\nGeneral:") + fmt.Printf(" Name: %s\n", detail.Name) + fmt.Printf(" State: %s\n", detail.ProvisioningState) + if detail.ID != "" { + fmt.Printf(" ID: %s\n", detail.ID) + } + + fmt.Println("\nModel:") + fmt.Printf(" Name: %s\n", detail.ModelName) + fmt.Printf(" Format: %s\n", detail.ModelFormat) + fmt.Printf(" Version: %s\n", detail.ModelVersion) + if detail.ModelSource != "" { + fmt.Printf(" Source: %s\n", detail.ModelSource) + } + + fmt.Println("\nSKU:") + fmt.Printf(" Name: %s\n", detail.SkuName) + fmt.Printf(" Capacity: %d\n", detail.SkuCapacity) + + if detail.RaiPolicyName != "" { + fmt.Println("\nPolicies:") + fmt.Printf(" RAI Policy: %s\n", detail.RaiPolicyName) + } + + if detail.CreatedAt != "" || detail.LastModifiedAt != "" { + fmt.Println("\nTimestamps:") + if detail.CreatedAt != "" { + fmt.Printf(" Created: %s\n", detail.CreatedAt) + } + if detail.LastModifiedAt != "" { + fmt.Printf(" Last Modified: %s\n", detail.LastModifiedAt) + } + } + + fmt.Println(strings.Repeat("─", 50)) + default: + return fmt.Errorf("unsupported output format: %s (supported: table, json)", flags.Output) + } + + return nil +} diff --git a/cli/azd/extensions/azure.ai.models/internal/cmd/root.go b/cli/azd/extensions/azure.ai.models/internal/cmd/root.go index 454faae51dd..ad0e74765e6 100644 --- a/cli/azd/extensions/azure.ai.models/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.models/internal/cmd/root.go @@ -45,6 +45,7 @@ func NewRootCommand() *cobra.Command { rootCmd.AddCommand(newMetadataCommand()) rootCmd.AddCommand(newInitCommand()) rootCmd.AddCommand(newCustomCommand()) + rootCmd.AddCommand(newDeploymentCommand()) return rootCmd } diff --git a/cli/azd/extensions/azure.ai.models/pkg/models/deployment.go b/cli/azd/extensions/azure.ai.models/pkg/models/deployment.go new file mode 100644 index 00000000000..730deebf22c --- /dev/null +++ b/cli/azd/extensions/azure.ai.models/pkg/models/deployment.go @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package models + +// DeploymentConfig contains configuration for creating a model deployment. +type DeploymentConfig struct { + DeploymentName string + ModelName string + ModelVersion string + ModelFormat string + ModelSource string + SkuName string + SkuCapacity int32 + RaiPolicyName string + + // Azure context + SubscriptionID string + ResourceGroup string + AccountName string + TenantID string + + WaitForCompletion bool +} + +// DeploymentResult represents the result of a deployment operation. +type DeploymentResult struct { + ID string + Name string + ModelName string + ProvisioningState string +} + +// DeploymentInfo represents a deployment returned from a list operation. +type DeploymentInfo struct { + Name string `json:"name" table:"Name"` + ModelName string `json:"modelName" table:"Model"` + ModelFormat string `json:"modelFormat" table:"Format"` + ModelVersion string `json:"modelVersion" table:"Version"` + SkuName string `json:"skuName" table:"SKU"` + SkuCapacity int32 `json:"skuCapacity" table:"Capacity"` + ProvisioningState string `json:"provisioningState" table:"State"` +} + +// DeploymentDetail represents the full details of a deployment for the show command. +type DeploymentDetail struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + ModelName string `json:"modelName"` + ModelFormat string `json:"modelFormat"` + ModelVersion string `json:"modelVersion"` + ModelSource string `json:"modelSource,omitempty"` + SkuName string `json:"skuName"` + SkuCapacity int32 `json:"skuCapacity"` + ProvisioningState string `json:"provisioningState"` + RaiPolicyName string `json:"raiPolicyName,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` + LastModifiedAt string `json:"lastModifiedAt,omitempty"` +}