Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cli/azd/extensions/ai-non-interactive.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,5 @@ no non-interactive equivalent is a bug.
| --- | --- | --- | --- |
| `microsoft.foundry` provisioning | Azure subscription | `AZURE_SUBSCRIPTION_ID` in the active azd environment | Returns actionable missing-subscription guidance. |
| `microsoft.foundry` provisioning | Azure location | `AZURE_LOCATION` in the active azd environment | Returns actionable missing-location guidance. |
| Projects provisioning and legacy agents provisioning | Model deployments for the current subscription/location | `AI_PROJECT_DEPLOYMENTS` JSON array in the active azd environment (`[]` means no deployments) | With `--no-prompt` / `AZD_NO_PROMPT=true`, saves YAML deployments unchanged when unset or blank. Existing environment overrides are preserved; embedded infrastructure uses them instead of YAML defaults. Ejected user parameters retain precedence. |
| `azd down` with `microsoft.foundry` | Delete the Foundry resource group | `azd down --force` | Refuses deletion and instructs the caller to pass `--force`. |
62 changes: 62 additions & 0 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,68 @@ func updateLegacyProjectDeployments(
err,
)
}
response, err := azdClient.Environment().GetValues(ctx, &azdext.GetEnvironmentRequest{Name: envName})
if err != nil {
return fmt.Errorf("read environment for legacy deployments: %w", err)
}
values := map[string]string{}
for _, kv := range response.GetKeyValues() {
values[kv.GetKey()] = kv.GetValue()
}
if strings.TrimSpace(values["AI_PROJECT_DEPLOYMENTS"]) != "" {
return nil
}
if !azdext.DetectInteractive().NoPrompt {
Comment thread
Copilot marked this conversation as resolved.
location := values["AZURE_AI_DEPLOYMENTS_LOCATION"]
if location == "" {
location = values["AZURE_LOCATION"]
}
azureContext := &azdext.AzureContext{Scope: &azdext.AzureScope{
SubscriptionId: values["AZURE_SUBSCRIPTION_ID"],
TenantId: values["AZURE_TENANT_ID"],
Location: location,
}}
Comment thread
huimiu marked this conversation as resolved.
resolved := make([]project.Deployment, 0, len(deployments))
for _, declaration := range deployments {
model, err := azdClient.Prompt().PromptAiModel(ctx, &azdext.PromptAiModelRequest{
AzureContext: azureContext,
DefaultValue: declaration.Model.Name,
Filter: &azdext.AiModelFilterOptions{
Locations: []string{azureContext.Scope.Location},
},
Quota: &azdext.QuotaCheckOptions{MinRemainingCapacity: 1},
})
if err != nil {
return exterrors.FromPrompt(err, "select legacy deployment model")
}
if model.GetModel().GetName() == "" {
return fmt.Errorf("no model selected for deployment %q", declaration.Name)
}
response, err := azdClient.Prompt().PromptAiDeployment(ctx, &azdext.PromptAiDeploymentRequest{
AzureContext: azureContext,
ModelName: model.GetModel().GetName(),
Options: &azdext.AiModelDeploymentOptions{
Locations: []string{azureContext.Scope.Location},
},
Quota: &azdext.QuotaCheckOptions{MinRemainingCapacity: 1},
})
if err != nil {
return exterrors.FromPrompt(err, "select legacy model deployment")
}
selected := response.GetDeployment()
if selected.GetModelName() == "" || selected.GetVersion() == "" ||
selected.GetSku().GetName() == "" || selected.GetCapacity() <= 0 {
return fmt.Errorf("incomplete model selection for deployment %q", declaration.Name)
}
declaration.Model.Name = selected.GetModelName()
declaration.Model.Format = selected.GetFormat()
declaration.Model.Version = selected.GetVersion()
declaration.Sku.Name = selected.GetSku().GetName()
declaration.Sku.Capacity = int(selected.GetCapacity())
resolved = append(resolved, declaration)
}
deployments = resolved
}
return deploymentEnvUpdate(
ctx,
deployments,
Expand Down
120 changes: 120 additions & 0 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/listen_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package cmd

import (
"bytes"
"context"
"encoding/json"
"errors"
"maps"
Expand All @@ -26,6 +27,125 @@ import (
"google.golang.org/protobuf/types/known/structpb"
)

type legacyDeploymentPromptServer struct {
recordingPromptAiModelServer
deployments []*azdext.PromptAiDeploymentRequest
failAt int
}

func (s *legacyDeploymentPromptServer) PromptAiDeployment(
_ context.Context, request *azdext.PromptAiDeploymentRequest,
) (*azdext.PromptAiDeploymentResponse, error) {
s.deployments = append(s.deployments, request)
if s.failAt == len(s.deployments) {
return nil, errors.New("deployment selection cancelled")
}
return &azdext.PromptAiDeploymentResponse{Deployment: &azdext.AiModelDeployment{
ModelName: request.ModelName, Format: "OpenAI", Version: "env-version",
Sku: &azdext.AiModelSku{Name: "Standard"}, Capacity: 2,
}}, nil
}

func TestUpdateLegacyProjectDeploymentsPrecedence(t *testing.T) {
for _, test := range []struct {
name, value string
noPrompt, wantPrompt bool
failAt int
}{
{name: "interactive missing", wantPrompt: true},
{name: "interactive blank", value: " \t", wantPrompt: true},
{name: "no-prompt YAML", noPrompt: true},
{name: "set preserves override", value: `[{\"name\":\"env-b\"}]`},
{name: "empty skips prompts", value: "[]"},
{name: "no-prompt preserves override", noPrompt: true, value: "[]"},
{name: "legacy leaves malformed for provider validation", value: "{"},
{name: "selection failure is atomic", wantPrompt: true, failAt: 2},
} {
t.Run(test.name, func(t *testing.T) {
t.Setenv("AZD_NO_PROMPT", strconv.FormatBool(test.noPrompt))
t.Setenv("AI_PROJECT_DEPLOYMENTS", `[{"name":"process-must-not-win"}]`)
env := &testEnvironmentServiceServer{
current: &azdext.Environment{Name: "env-b"},
values: map[string]map[string]string{"env-b": {
"AI_PROJECT_DEPLOYMENTS": test.value,
"AZURE_SUBSCRIPTION_ID": "sub-b",
"AZURE_LOCATION": "westus",
}},
}
prompt := &legacyDeploymentPromptServer{failAt: test.failAt}
client := newTestAzdClient(t, env, nil, prompt)
declarations := []project.Deployment{
{Name: "chat", Model: project.DeploymentModel{Name: "yaml-model", Version: "yaml-version"},
Sku: project.DeploymentSku{Name: "YamlSku", Capacity: 100}},
{Name: "embedding", Model: project.DeploymentModel{Name: "yaml-embedding"}},
}
service := mustMarshalConfig(t, &project.ServiceTargetAgentConfig{Deployments: declarations})
service.Name, service.Host = "agent", AiAgentHost
service.AdditionalProperties, service.Config = service.Config, nil
services := map[string]*azdext.ServiceConfig{"agent": service}
err := updateLegacyProjectDeployments(t.Context(), client, services, "")
if test.failAt > 0 {
require.Error(t, err)
assert.Empty(t, env.setKeys, "do not persist a partial selection")
assert.Equal(t, test.value, env.values["env-b"]["AI_PROJECT_DEPLOYMENTS"])
return
}
require.NoError(t, err)
if test.wantPrompt {
require.Len(t, prompt.requests, 2)
require.Len(t, prompt.deployments, 2)
assert.Equal(t, "yaml-model", prompt.requests[0].DefaultValue)
assert.Equal(t, "yaml-embedding", prompt.requests[1].DefaultValue)
for _, request := range prompt.requests {
assert.Equal(t, "sub-b", request.AzureContext.Scope.SubscriptionId)
assert.Equal(t, "westus", request.AzureContext.Scope.Location)
assert.Equal(t, []string{"westus"}, request.Filter.Locations)
assert.Empty(t, request.Filter.Capabilities, "do not exclude non-agent models such as embeddings")
assert.Equal(t, float64(1), request.Quota.MinRemainingCapacity)
}
for _, request := range prompt.deployments {
assert.Equal(t, []string{"westus"}, request.Options.Locations)
assert.Empty(t, request.Options.Versions)
assert.Empty(t, request.Options.Skus)
assert.Nil(t, request.Options.Capacity)
assert.Equal(t, float64(1), request.Quota.MinRemainingCapacity)
}
value := env.values["env-b"]["AI_PROJECT_DEPLOYMENTS"]
assert.Contains(t, value, `\"name\":\"chat\"`)
assert.Contains(t, value, `\"name\":\"embedding\"`)
assert.Contains(t, value, `\"version\":\"env-version\"`)
require.NoError(t, updateLegacyProjectDeployments(t.Context(), client, services, ""))
assert.Len(t, prompt.requests, 2)
} else if test.value == "" {
assert.Empty(t, prompt.requests)
expected, err := json.Marshal(declarations)
require.NoError(t, err)
escaped := strings.ReplaceAll(string(expected), "\\", "\\\\")
escaped = strings.ReplaceAll(escaped, "\"", "\\\"")
assert.Equal(t, escaped, env.values["env-b"]["AI_PROJECT_DEPLOYMENTS"])
} else {
assert.Empty(t, prompt.requests)
assert.Empty(t, env.setKeys)
assert.Equal(t, test.value, env.values["env-b"]["AI_PROJECT_DEPLOYMENTS"])
}
})
}
}

func TestUpdateLegacyProjectDeploymentsSplitNoOp(t *testing.T) {
t.Parallel()
services := map[string]*azdext.ServiceConfig{
"project": {Host: AiProjectHost},
"agent": {
Host: AiAgentHost,
AdditionalProperties: mustStruct(t, map[string]any{
"deployments": []any{map[string]any{"name": "ignored"}},
}),
},
}
require.NoError(t, updateLegacyProjectDeployments(t.Context(), nil, services, ""))
}

func TestPrepareContainerSettings_AppliesSettingsInMemory(t *testing.T) {
t.Parallel()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ post:

goals:
- "Wait for 'azd provision' to complete. This creates the Azure infrastructure (Foundry project, model deployment, resource group) defined by the Tier 1 scaffold."
- "Confirm provision did not prompt for an AI model, model version, deployment SKU, or capacity: the pre-hook's deployment add must seed AI_PROJECT_DEPLOYMENTS in the active environment. Run 'azd env get-value AI_PROJECT_DEPLOYMENTS' and verify the saved list still contains model '{model}' and SKU '{model_sku}', with the deployment name matching 'azd env get-value AZURE_AI_MODEL_DEPLOYMENT_NAME'."
- "After provision succeeds, run 'azd deploy' and wait for it to complete. This deploys the agent to the provisioned Foundry project."
- "Run 'azd ai agent show' and verify the agent is accessible — confirm it reports an agent name and endpoint URL."
- "Run `azd ai agent invoke \"Hello, are you there?\"` and confirm the agent responds with a non-empty reply containing no authentication or endpoint errors. This validates the agent is functional, not just deployed."
Expand Down
10 changes: 10 additions & 0 deletions cli/azd/extensions/azure.ai.projects/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,16 @@ services:

When `endpoint` is omitted, `azd provision` creates a Foundry account and project. When it is set, provisioning reuses that project and reconciles the declarations that can be applied to an existing account.

For embedded infrastructure, the active azd environment's `AI_PROJECT_DEPLOYMENTS`
Comment thread
huimiu marked this conversation as resolved.
JSON array overrides the portable `deployments` defaults in `azure.yaml`. A value
of `[]` intentionally disables managed model deployments. When unset or blank,
interactive provisioning selects models for the current subscription and location
and saves the complete list to that environment; `--no-prompt` (or
`AZD_NO_PROMPT=true`) saves the YAML defaults unchanged. Invalid non-blank values
produce an error rather than being overwritten. `azd ai project deployment add`
also seeds or updates this environment list. Ejected infrastructure retains
user-owned deployment parameters.

### Deployment preview

`azd deploy --preview` performs no work for an `azure.ai.project` service, just
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import (
"strings"

"azure.ai.projects/internal/exterrors"
"azure.ai.projects/internal/provisioning"
"azure.ai.projects/internal/synthesis"

"github.com/azure/azure-dev/cli/azd/pkg/azdext"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -221,6 +223,10 @@ func (a *ProjectDeploymentAddAction) Run(ctx context.Context) error {
if err := validateConfiguredProjectIdentity(values, service); err != nil {
return err
}
envDeployments, deploymentsPresent, err := provisioning.DecodeProjectDeployments(values[projectDeploymentsEnvKey])
if err != nil {
return err
}
if requiresExistingProjectID(values, service) {
return exterrors.Validation(
"project_deployment_requires_id",
Expand Down Expand Up @@ -329,6 +335,43 @@ func (a *ProjectDeploymentAddAction) Run(ctx context.Context) error {
if err != nil {
return rollbackProjectDeploymentAdd(err, restoreEnvironment)
}
if !deploymentsPresent {
updated, _, readErr := reconciler.discoverProjectService(ctx)
if readErr != nil {
return rollbackProjectDeploymentAdd(readErr, restoreEnvironment, restoreDeployment)
}
if updated == nil {
return rollbackProjectDeploymentAdd(
fmt.Errorf("project service disappeared after adding deployment"), restoreEnvironment, restoreDeployment,
)
}
data, encodeErr := json.Marshal(updated.Resolved)
if encodeErr != nil {
return rollbackProjectDeploymentAdd(encodeErr, restoreEnvironment, restoreDeployment)
}
var cfg projectServiceConfig
if decodeErr := json.Unmarshal(data, &cfg); decodeErr != nil {
return rollbackProjectDeploymentAdd(decodeErr, restoreEnvironment, restoreDeployment)
}
envDeployments = cfg.Deployments
}
index := slices.IndexFunc(envDeployments, func(deployment synthesis.Deployment) bool {
return strings.EqualFold(deployment.Name, selected.Deployment.Name)
})
if index < 0 {
envDeployments = append(envDeployments, selected.Deployment)
} else {
envDeployments[index] = selected.Deployment
}
encodedDeployments, err := provisioning.EncodeProjectDeployments(envDeployments)
if err != nil {
return rollbackProjectDeploymentAdd(err, restoreEnvironment, restoreDeployment)
}
if err := setEnvironmentValue(projectDeploymentsEnvKey, encodedDeployments); err != nil {
return rollbackProjectDeploymentAdd(
fmt.Errorf("set %s: %w", projectDeploymentsEnvKey, err), restoreEnvironment, restoreDeployment,
)
}
if selected.Location != "" {
if err := setEnvironmentValue(
"AZURE_AI_DEPLOYMENTS_LOCATION",
Expand Down
Loading
Loading