From 38c0fd18a19a07117f99176457f7ed4a40ed15b9 Mon Sep 17 00:00:00 2001 From: Antriksh Jain Date: Mon, 27 Apr 2026 21:43:21 +0530 Subject: [PATCH 1/6] feat(ai-agents): fetch hosted-agent supported regions from manifest Replace the hardcoded supportedHostedAgentRegions slice with a fetch from a JSON manifest committed in the repo. Adding or removing regions no longer requires shipping a new extension release. The manifest URL temporarily points at raw.githubusercontent.com on main; will switch to an aka.ms link once provisioned. --- .../azure.ai.agents/hosted-agent-regions.json | 22 +++ .../cmd/init_foundry_resources_helpers.go | 5 +- .../internal/cmd/init_locations.go | 145 ++++++++++---- .../internal/cmd/init_locations_test.go | 187 +++++++++++++----- .../internal/cmd/init_models.go | 12 +- .../internal/exterrors/codes.go | 1 + 6 files changed, 282 insertions(+), 90 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.agents/hosted-agent-regions.json diff --git a/cli/azd/extensions/azure.ai.agents/hosted-agent-regions.json b/cli/azd/extensions/azure.ai.agents/hosted-agent-regions.json new file mode 100644 index 00000000000..c5b98a6b034 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/hosted-agent-regions.json @@ -0,0 +1,22 @@ +{ + "regions": [ + "australiaeast", + "brazilsouth", + "canadacentral", + "eastus2", + "francecentral", + "japaneast", + "koreacentral", + "northcentralus", + "norwayeast", + "polandcentral", + "southafricanorth", + "southeastasia", + "southindia", + "spaincentral", + "swedencentral", + "switzerlandnorth", + "westus", + "westus3" + ] +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go index b8f94476a6b..285de31c6d4 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go @@ -735,7 +735,10 @@ func ensureLocation( azureContext *azdext.AzureContext, envName string, ) error { - allowedLocations := supportedRegionsForInit() + allowedLocations, err := supportedRegionsForInit(ctx) + if err != nil { + return err + } if azureContext.Scope.Location != "" && locationAllowed(azureContext.Scope.Location, allowedLocations) { return nil diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations.go index a796f202e59..e007e6666b5 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations.go @@ -3,49 +3,116 @@ package cmd -import "slices" - -// No API available to query supported regions for hosted agents, so keep hardcoded list based on public documentation: -// https://learn.microsoft.com/azure/foundry/agents/concepts/hosted-agents#region-availability -var supportedHostedAgentRegions = []string{ - "australiaeast", - "brazilsouth", - "canadacentral", - "canadaeast", - "centralus", - "eastus", - "eastus2", - "francecentral", - "germanywestcentral", - "italynorth", - "japaneast", - "koreacentral", - "northcentralus", - "norwayeast", - "polandcentral", - "southafricanorth", - "southcentralus", - "southeastasia", - "southindia", - "spaincentral", - "swedencentral", - "switzerlandnorth", - "uaenorth", - "uksouth", - "westeurope", - "westus", - "westus3", +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "slices" + "sync" + "time" + + "azureaiagent/internal/exterrors" +) + +// hostedAgentRegionsURL points at the supported-regions manifest. +// TODO: switch to an aka.ms link once provisioned. +const hostedAgentRegionsURL = "https://raw.githubusercontent.com/Azure/azure-dev/main/" + + "cli/azd/extensions/azure.ai.agents/hosted-agent-regions.json" + +const hostedAgentRegionsFetchTimeout = 5 * time.Second + +type hostedAgentRegionsManifest struct { + Regions []string `json:"regions"` } -func supportedRegionsForInit() []string { - return slices.Clone(supportedHostedAgentRegions) +var regionsCache struct { + mu sync.Mutex + regions []string } -// supportedModelLocations returns the intersection of a model's available locations -// with the supported hosted agent regions. -func supportedModelLocations(modelLocations []string) []string { - supported := supportedRegionsForInit() +// supportedRegionsForInit returns the list of Azure regions supported for hosted agents. +// The result is cached for the process after the first successful fetch. +func supportedRegionsForInit(ctx context.Context) ([]string, error) { + regionsCache.mu.Lock() + defer regionsCache.mu.Unlock() + + if regionsCache.regions != nil { + return slices.Clone(regionsCache.regions), nil + } + + regions, err := fetchHostedAgentRegionsFromURL(ctx, http.DefaultClient, hostedAgentRegionsURL) + if err != nil { + return nil, err + } + + regionsCache.regions = regions + return slices.Clone(regions), nil +} + +// supportedModelLocations returns the intersection of a model's available locations with +// the supported hosted-agent regions. +func supportedModelLocations(ctx context.Context, modelLocations []string) ([]string, error) { + supported, err := supportedRegionsForInit(ctx) + if err != nil { + return nil, err + } + return slices.DeleteFunc(slices.Clone(modelLocations), func(loc string) bool { return !locationAllowed(loc, supported) - }) + }), nil +} + +func fetchHostedAgentRegionsFromURL(ctx context.Context, httpClient *http.Client, url string) ([]string, error) { + fetchCtx, cancel := context.WithTimeout(ctx, hostedAgentRegionsFetchTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(fetchCtx, http.MethodGet, url, nil) + if err != nil { + return nil, regionsFetchError(err) + } + + //nolint:gosec // URL is the hardcoded hostedAgentRegionsURL constant or test override + resp, err := httpClient.Do(req) + if err != nil { + return nil, regionsFetchError(err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, regionsFetchError(fmt.Errorf("unexpected HTTP status %d", resp.StatusCode)) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, regionsFetchError(err) + } + + var manifest hostedAgentRegionsManifest + if err := json.Unmarshal(body, &manifest); err != nil { + return nil, regionsFetchError(err) + } + + regions := make([]string, 0, len(manifest.Regions)) + for _, r := range manifest.Regions { + if normalized := normalizeLocationName(r); normalized != "" { + regions = append(regions, normalized) + } + } + + if len(regions) == 0 { + return nil, regionsFetchError(fmt.Errorf("manifest contained no valid regions")) + } + + return regions, nil +} + +func regionsFetchError(err error) error { + return exterrors.Dependency( + exterrors.CodeRegionsFetchFailed, + fmt.Sprintf("could not retrieve the list of supported Azure regions: %v", err), + "check your network connection and try again. "+ + "If the issue persists, file an issue at https://github.com/Azure/azure-dev/issues", + ) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations_test.go index 8e0bd8bab71..3acfd437349 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations_test.go @@ -4,78 +4,169 @@ package cmd import ( + "net/http" + "net/http/httptest" + "slices" "testing" + "time" "github.com/stretchr/testify/require" ) +func TestFetchHostedAgentRegionsFromURL_Success(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"regions": ["eastus2", "westus3", "swedencentral"]}`)) + })) + t.Cleanup(server.Close) + + regions, err := fetchHostedAgentRegionsFromURL(t.Context(), http.DefaultClient, server.URL) + require.NoError(t, err) + require.Equal(t, []string{"eastus2", "westus3", "swedencentral"}, regions) +} + +func TestFetchHostedAgentRegionsFromURL_NormalizesEntries(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"regions": [" EastUS2 ", "westus3", "", " "]}`)) + })) + t.Cleanup(server.Close) + + regions, err := fetchHostedAgentRegionsFromURL(t.Context(), http.DefaultClient, server.URL) + require.NoError(t, err) + require.Equal(t, []string{"eastus2", "westus3"}, regions) +} + +func TestFetchHostedAgentRegionsFromURL_HTTPError(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + })) + t.Cleanup(server.Close) + + _, err := fetchHostedAgentRegionsFromURL(t.Context(), http.DefaultClient, server.URL) + require.Error(t, err) +} + +func TestFetchHostedAgentRegionsFromURL_MalformedJSON(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{not json`)) + })) + t.Cleanup(server.Close) + + _, err := fetchHostedAgentRegionsFromURL(t.Context(), http.DefaultClient, server.URL) + require.Error(t, err) +} + +func TestFetchHostedAgentRegionsFromURL_EmptyManifest(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"regions": []}`)) + })) + t.Cleanup(server.Close) + + _, err := fetchHostedAgentRegionsFromURL(t.Context(), http.DefaultClient, server.URL) + require.Error(t, err) +} + +func TestFetchHostedAgentRegionsFromURL_RespectsTimeout(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(hostedAgentRegionsFetchTimeout + 2*time.Second) + })) + t.Cleanup(server.Close) + + start := time.Now() + _, err := fetchHostedAgentRegionsFromURL(t.Context(), http.DefaultClient, server.URL) + elapsed := time.Since(start) + + require.Error(t, err) + require.Less(t, elapsed, hostedAgentRegionsFetchTimeout+1*time.Second) +} + func TestSupportedModelLocations(t *testing.T) { t.Parallel() + resetRegionsCache(t, []string{"eastus2", "westus3"}) + tests := []struct { name string modelLocations []string - wantSubset bool - wantLen int + want []string }{ - { - name: "AllSupported", - modelLocations: []string{"eastus", "westus"}, - wantSubset: true, - wantLen: 2, - }, - { - name: "SomeUnsupported", - modelLocations: []string{"eastus", "unsupportedregion"}, - wantSubset: true, - wantLen: 1, - }, - { - name: "NoneSupported", - modelLocations: []string{"unsupportedregion1", "unsupportedregion2"}, - wantSubset: true, - wantLen: 0, - }, - { - name: "EmptyInput", - modelLocations: []string{}, - wantSubset: true, - wantLen: 0, - }, - { - name: "NilInput", - modelLocations: nil, - wantSubset: true, - wantLen: 0, - }, + {"AllSupported", []string{"eastus2", "westus3"}, []string{"eastus2", "westus3"}}, + {"SomeUnsupported", []string{"eastus2", "unsupported"}, []string{"eastus2"}}, + {"NoneSupported", []string{"unsupported1", "unsupported2"}, []string{}}, + {"EmptyInput", []string{}, []string{}}, + {"NilInput", nil, []string{}}, } - supported := supportedRegionsForInit() - for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - result := supportedModelLocations(tt.modelLocations) - require.Len(t, result, tt.wantLen) - - // Every returned location must be in the supported regions list - for _, loc := range result { - require.True(t, locationAllowed(loc, supported), - "returned location %q should be in supported regions", loc) - } + result, err := supportedModelLocations(t.Context(), tt.modelLocations) + require.NoError(t, err) + require.ElementsMatch(t, tt.want, result) }) } } -func TestSupportedModelLocationsDoesNotMutateInput(t *testing.T) { +func TestSupportedModelLocations_DoesNotMutateInput(t *testing.T) { t.Parallel() - input := []string{"eastus", "unsupportedregion", "westus"} - original := make([]string, len(input)) - copy(original, input) + resetRegionsCache(t, []string{"eastus2", "westus3"}) + + input := []string{"eastus2", "unsupported", "westus3"} + original := slices.Clone(input) + + _, err := supportedModelLocations(t.Context(), input) + require.NoError(t, err) + require.Equal(t, original, input) +} + +func TestSupportedRegionsForInit_CachesAfterFirstFetch(t *testing.T) { + resetRegionsCache(t, nil) + + hits := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits++ + _, _ = w.Write([]byte(`{"regions": ["eastus2"]}`)) + })) + t.Cleanup(server.Close) + + regions, err := fetchHostedAgentRegionsFromURL(t.Context(), http.DefaultClient, server.URL) + require.NoError(t, err) + regionsCache.mu.Lock() + regionsCache.regions = regions + regionsCache.mu.Unlock() + + for range 3 { + got, err := supportedRegionsForInit(t.Context()) + require.NoError(t, err) + require.Equal(t, []string{"eastus2"}, got) + } + require.Equal(t, 1, hits) +} + +func resetRegionsCache(t *testing.T, regions []string) { + t.Helper() - _ = supportedModelLocations(input) + regionsCache.mu.Lock() + prev := regionsCache.regions + regionsCache.regions = regions + regionsCache.mu.Unlock() - require.Equal(t, original, input, "input slice should not be mutated") + t.Cleanup(func() { + regionsCache.mu.Lock() + regionsCache.regions = prev + regionsCache.mu.Unlock() + }) } 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 d31bc6aa410..62154e2074a 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 @@ -621,11 +621,15 @@ func (a *modelSelector) promptForModelLocationMismatch( } if selectedChoice == "location" { + allowedLocations, err := supportedModelLocations(ctx, currentModel.Locations) + if err != nil { + return nil, "", err + } locationResp, err := a.azdClient.Prompt().PromptAiModelLocationWithQuota(ctx, &azdext.PromptAiModelLocationWithQuotaRequest{ AzureContext: a.azureContext, ModelName: currentModel.Name, - AllowedLocations: supportedModelLocations(currentModel.Locations), + AllowedLocations: allowedLocations, Quota: &azdext.QuotaCheckOptions{ MinRemainingCapacity: 1, }, @@ -672,11 +676,15 @@ func (a *modelSelector) promptForModelLocationMismatch( } selectedModel := modelResp.Model + allowedLocations, err := supportedModelLocations(ctx, selectedModel.Locations) + if err != nil { + return nil, "", err + } locationResp, err := a.azdClient.Prompt().PromptAiModelLocationWithQuota(ctx, &azdext.PromptAiModelLocationWithQuotaRequest{ AzureContext: a.azureContext, ModelName: selectedModel.Name, - AllowedLocations: supportedModelLocations(selectedModel.Locations), + AllowedLocations: allowedLocations, Quota: &azdext.QuotaCheckOptions{ MinRemainingCapacity: 1, }, diff --git a/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go index 0fe0d309bdd..70a6ef16a8c 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go +++ b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go @@ -84,6 +84,7 @@ const ( const ( CodeModelCatalogFailed = "model_catalog_failed" CodeModelResolutionFailed = "model_resolution_failed" + CodeRegionsFetchFailed = "regions_fetch_failed" ) // Error codes for session errors. From 6e3cc2b619d7e650f8e55e6a69aff756c3921c59 Mon Sep 17 00:00:00 2001 From: Antriksh Jain Date: Mon, 27 Apr 2026 22:36:34 +0530 Subject: [PATCH 2/6] Address PR review feedback - Switch manifest URL to https://aka.ms/azd-ai-agents/regions and make it a var so tests can override it. - supportedModelLocations now returns a structured CodeNoSupportedModelLocations error when the intersection is empty (an empty allowlist would otherwise disable downstream filtering and let users pick unsupported regions). - init_models.go callsites handle CodeNoSupportedModelLocations gracefully by continuing the recovery loop with a helpful message instead of aborting. - TestSupportedRegionsForInit_FetchesOnceAndCaches now exercises the real cached-fetch path via a URL override; tests touching the shared regionsCache no longer run in parallel. - Added test asserting the structured error code. --- .../internal/cmd/init_locations.go | 34 +++++++++++--- .../internal/cmd/init_locations_test.go | 44 ++++++++++++------- .../internal/cmd/init_models.go | 15 +++++++ .../internal/exterrors/codes.go | 7 +-- 4 files changed, 74 insertions(+), 26 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations.go index e007e6666b5..e5f192e23a6 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations.go @@ -6,6 +6,7 @@ package cmd import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -14,12 +15,13 @@ import ( "time" "azureaiagent/internal/exterrors" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" ) // hostedAgentRegionsURL points at the supported-regions manifest. -// TODO: switch to an aka.ms link once provisioned. -const hostedAgentRegionsURL = "https://raw.githubusercontent.com/Azure/azure-dev/main/" + - "cli/azd/extensions/azure.ai.agents/hosted-agent-regions.json" +// It is a var so tests can override it. +var hostedAgentRegionsURL = "https://aka.ms/azd-ai-agents/regions" const hostedAgentRegionsFetchTimeout = 5 * time.Second @@ -52,16 +54,28 @@ func supportedRegionsForInit(ctx context.Context) ([]string, error) { } // supportedModelLocations returns the intersection of a model's available locations with -// the supported hosted-agent regions. +// the supported hosted-agent regions. Returns an error when the intersection is empty +// because passing an empty allowlist downstream disables filtering, which would let users +// pick regions that are not supported for hosted agents. func supportedModelLocations(ctx context.Context, modelLocations []string) ([]string, error) { supported, err := supportedRegionsForInit(ctx) if err != nil { return nil, err } - return slices.DeleteFunc(slices.Clone(modelLocations), func(loc string) bool { + result := slices.DeleteFunc(slices.Clone(modelLocations), func(loc string) bool { return !locationAllowed(loc, supported) - }), nil + }) + + if len(result) == 0 { + return nil, exterrors.Dependency( + exterrors.CodeNoSupportedModelLocations, + "the selected model is not available in any region supported for hosted agents", + "select a different model.", + ) + } + + return result, nil } func fetchHostedAgentRegionsFromURL(ctx context.Context, httpClient *http.Client, url string) ([]string, error) { @@ -116,3 +130,11 @@ func regionsFetchError(err error) error { "If the issue persists, file an issue at https://github.com/Azure/azure-dev/issues", ) } + +// isNoSupportedLocationsError reports whether err is the structured error returned by +// [supportedModelLocations] when no region in the model's location list is supported +// for hosted agents. +func isNoSupportedLocationsError(err error) bool { + localErr, ok := errors.AsType[*azdext.LocalError](err) + return ok && localErr.Code == exterrors.CodeNoSupportedModelLocations +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations_test.go index 3acfd437349..a0772b9e79f 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations_test.go @@ -10,7 +10,10 @@ import ( "testing" "time" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/stretchr/testify/require" + + "azureaiagent/internal/exterrors" ) func TestFetchHostedAgentRegionsFromURL_Success(t *testing.T) { @@ -92,36 +95,45 @@ func TestFetchHostedAgentRegionsFromURL_RespectsTimeout(t *testing.T) { } func TestSupportedModelLocations(t *testing.T) { - t.Parallel() - resetRegionsCache(t, []string{"eastus2", "westus3"}) tests := []struct { name string modelLocations []string want []string + wantErr bool }{ - {"AllSupported", []string{"eastus2", "westus3"}, []string{"eastus2", "westus3"}}, - {"SomeUnsupported", []string{"eastus2", "unsupported"}, []string{"eastus2"}}, - {"NoneSupported", []string{"unsupported1", "unsupported2"}, []string{}}, - {"EmptyInput", []string{}, []string{}}, - {"NilInput", nil, []string{}}, + {"AllSupported", []string{"eastus2", "westus3"}, []string{"eastus2", "westus3"}, false}, + {"SomeUnsupported", []string{"eastus2", "unsupported"}, []string{"eastus2"}, false}, + {"NoneSupported", []string{"unsupported1", "unsupported2"}, nil, true}, + {"EmptyInput", []string{}, nil, true}, + {"NilInput", nil, nil, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - t.Parallel() - result, err := supportedModelLocations(t.Context(), tt.modelLocations) + if tt.wantErr { + require.Error(t, err) + return + } require.NoError(t, err) require.ElementsMatch(t, tt.want, result) }) } } -func TestSupportedModelLocations_DoesNotMutateInput(t *testing.T) { - t.Parallel() +func TestSupportedModelLocations_EmptyIntersectionReturnsStructuredError(t *testing.T) { + resetRegionsCache(t, []string{"eastus2"}) + _, err := supportedModelLocations(t.Context(), []string{"unsupported"}) + require.Error(t, err) + localErr, ok := err.(*azdext.LocalError) + require.True(t, ok, "expected *azdext.LocalError, got %T", err) + require.Equal(t, exterrors.CodeNoSupportedModelLocations, localErr.Code) +} + +func TestSupportedModelLocations_DoesNotMutateInput(t *testing.T) { resetRegionsCache(t, []string{"eastus2", "westus3"}) input := []string{"eastus2", "unsupported", "westus3"} @@ -132,7 +144,7 @@ func TestSupportedModelLocations_DoesNotMutateInput(t *testing.T) { require.Equal(t, original, input) } -func TestSupportedRegionsForInit_CachesAfterFirstFetch(t *testing.T) { +func TestSupportedRegionsForInit_FetchesOnceAndCaches(t *testing.T) { resetRegionsCache(t, nil) hits := 0 @@ -142,11 +154,9 @@ func TestSupportedRegionsForInit_CachesAfterFirstFetch(t *testing.T) { })) t.Cleanup(server.Close) - regions, err := fetchHostedAgentRegionsFromURL(t.Context(), http.DefaultClient, server.URL) - require.NoError(t, err) - regionsCache.mu.Lock() - regionsCache.regions = regions - regionsCache.mu.Unlock() + prev := hostedAgentRegionsURL + hostedAgentRegionsURL = server.URL + t.Cleanup(func() { hostedAgentRegionsURL = prev }) for range 3 { got, err := supportedRegionsForInit(t.Context()) 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 62154e2074a..1064359787b 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 @@ -623,6 +623,13 @@ func (a *modelSelector) promptForModelLocationMismatch( if selectedChoice == "location" { allowedLocations, err := supportedModelLocations(ctx, currentModel.Locations) if err != nil { + if isNoSupportedLocationsError(err) { + message = fmt.Sprintf( + "Model '%s' is not available in any region supported for hosted agents.", + currentModel.Name, + ) + continue + } return nil, "", err } locationResp, err := a.azdClient.Prompt().PromptAiModelLocationWithQuota(ctx, @@ -678,6 +685,14 @@ func (a *modelSelector) promptForModelLocationMismatch( selectedModel := modelResp.Model allowedLocations, err := supportedModelLocations(ctx, selectedModel.Locations) if err != nil { + if isNoSupportedLocationsError(err) { + currentModel = selectedModel + message = fmt.Sprintf( + "Model '%s' is not available in any region supported for hosted agents.", + selectedModel.Name, + ) + continue + } return nil, "", err } locationResp, err := a.azdClient.Prompt().PromptAiModelLocationWithQuota(ctx, diff --git a/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go index 70a6ef16a8c..3a1714c6929 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go +++ b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go @@ -82,9 +82,10 @@ const ( // Used as fallback codes with [FromAiService] when the gRPC response // doesn't include a more specific ErrorInfo reason. const ( - CodeModelCatalogFailed = "model_catalog_failed" - CodeModelResolutionFailed = "model_resolution_failed" - CodeRegionsFetchFailed = "regions_fetch_failed" + CodeModelCatalogFailed = "model_catalog_failed" + CodeModelResolutionFailed = "model_resolution_failed" + CodeRegionsFetchFailed = "regions_fetch_failed" + CodeNoSupportedModelLocations = "no_supported_model_locations" ) // Error codes for session errors. From ee9651294a583211f5fd7cef9566d9e8d15e8a35 Mon Sep 17 00:00:00 2001 From: Antriksh Jain Date: Mon, 27 Apr 2026 22:43:38 +0530 Subject: [PATCH 3/6] Use errors.AsType in test to satisfy errorlint --- .../azure.ai.agents/internal/cmd/init_locations_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations_test.go index a0772b9e79f..269ab804446 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations_test.go @@ -4,6 +4,7 @@ package cmd import ( + "errors" "net/http" "net/http/httptest" "slices" @@ -128,7 +129,7 @@ func TestSupportedModelLocations_EmptyIntersectionReturnsStructuredError(t *test _, err := supportedModelLocations(t.Context(), []string{"unsupported"}) require.Error(t, err) - localErr, ok := err.(*azdext.LocalError) + localErr, ok := errors.AsType[*azdext.LocalError](err) require.True(t, ok, "expected *azdext.LocalError, got %T", err) require.Equal(t, exterrors.CodeNoSupportedModelLocations, localErr.Code) } From 16a59646bcbb208b997ab8e34d2faba46c038976 Mon Sep 17 00:00:00 2001 From: Antriksh Jain Date: Mon, 27 Apr 2026 23:53:35 +0530 Subject: [PATCH 4/6] Address review: release mutex during fetch + cap manifest body size Fetch the regions manifest without holding regionsCache.mu so a caller's canceled context returns immediately instead of waiting up to the fetch timeout. The fetch is coordinated via an in-flight handle so concurrent callers share a single network round-trip; on failure the in-flight slot clears so the next caller retries instead of latching the error. Also bound the response body with io.LimitReader (1 MiB cap) to guard against unexpectedly large or hostile responses from the source URL. Adds tests for the size cap and for concurrent callers sharing a single fetch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../internal/cmd/init_locations.go | 76 ++++++++++++++++--- .../internal/cmd/init_locations_test.go | 52 +++++++++++++ 2 files changed, 117 insertions(+), 11 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations.go index e5f192e23a6..5613d3b62c8 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations.go @@ -23,34 +23,83 @@ import ( // It is a var so tests can override it. var hostedAgentRegionsURL = "https://aka.ms/azd-ai-agents/regions" -const hostedAgentRegionsFetchTimeout = 5 * time.Second +const ( + hostedAgentRegionsFetchTimeout = 5 * time.Second + // hostedAgentRegionsManifestMaxBytes caps the manifest body to guard against + // unexpectedly large responses from the source URL. + hostedAgentRegionsManifestMaxBytes = 1 << 20 // 1 MiB +) type hostedAgentRegionsManifest struct { Regions []string `json:"regions"` } var regionsCache struct { - mu sync.Mutex + mu sync.Mutex + regions []string + inflight *regionsFetch +} + +// regionsFetch coordinates concurrent callers waiting on the same in-flight fetch +// so the package-level mutex can be released while the network call is running. +type regionsFetch struct { + done chan struct{} regions []string + err error } // supportedRegionsForInit returns the list of Azure regions supported for hosted agents. // The result is cached for the process after the first successful fetch. +// +// The fetch itself is performed without holding regionsCache.mu so callers whose +// context is canceled can return promptly even if another goroutine is mid-fetch. func supportedRegionsForInit(ctx context.Context) ([]string, error) { regionsCache.mu.Lock() - defer regionsCache.mu.Unlock() - if regionsCache.regions != nil { - return slices.Clone(regionsCache.regions), nil + regions := slices.Clone(regionsCache.regions) + regionsCache.mu.Unlock() + return regions, nil } - regions, err := fetchHostedAgentRegionsFromURL(ctx, http.DefaultClient, hostedAgentRegionsURL) - if err != nil { - return nil, err + fetch := regionsCache.inflight + if fetch == nil { + fetch = ®ionsFetch{done: make(chan struct{})} + regionsCache.inflight = fetch + go runRegionsFetch(fetch) + } + regionsCache.mu.Unlock() + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-fetch.done: + if fetch.err != nil { + return nil, fetch.err + } + return slices.Clone(fetch.regions), nil } +} + +// runRegionsFetch performs the network fetch, populates the cache on success, and +// signals all waiters via fetch.done. A failed fetch clears the inflight slot so +// subsequent callers retry instead of latching the error forever. +func runRegionsFetch(fetch *regionsFetch) { + // The fetch uses its own timeout (hostedAgentRegionsFetchTimeout) and is + // independent of any single caller's context, since the result is shared. + regions, err := fetchHostedAgentRegionsFromURL( + context.Background(), http.DefaultClient, hostedAgentRegionsURL, + ) - regionsCache.regions = regions - return slices.Clone(regions), nil + regionsCache.mu.Lock() + if err == nil { + regionsCache.regions = regions + } + regionsCache.inflight = nil + regionsCache.mu.Unlock() + + fetch.regions = regions + fetch.err = err + close(fetch.done) } // supportedModelLocations returns the intersection of a model's available locations with @@ -98,10 +147,15 @@ func fetchHostedAgentRegionsFromURL(ctx context.Context, httpClient *http.Client return nil, regionsFetchError(fmt.Errorf("unexpected HTTP status %d", resp.StatusCode)) } - body, err := io.ReadAll(resp.Body) + body, err := io.ReadAll(io.LimitReader(resp.Body, hostedAgentRegionsManifestMaxBytes+1)) if err != nil { return nil, regionsFetchError(err) } + if len(body) > hostedAgentRegionsManifestMaxBytes { + return nil, regionsFetchError(fmt.Errorf( + "manifest exceeds %d byte limit", hostedAgentRegionsManifestMaxBytes, + )) + } var manifest hostedAgentRegionsManifest if err := json.Unmarshal(body, &manifest); err != nil { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations_test.go index 269ab804446..bab28644e66 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations_test.go @@ -8,6 +8,9 @@ import ( "net/http" "net/http/httptest" "slices" + "strings" + "sync" + "sync/atomic" "testing" "time" @@ -167,17 +170,66 @@ func TestSupportedRegionsForInit_FetchesOnceAndCaches(t *testing.T) { require.Equal(t, 1, hits) } +func TestFetchHostedAgentRegionsFromURL_RejectsOversizedManifest(t *testing.T) { + t.Parallel() + + huge := strings.Repeat("a", hostedAgentRegionsManifestMaxBytes+1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"regions":["` + huge + `"]}`)) + })) + t.Cleanup(server.Close) + + _, err := fetchHostedAgentRegionsFromURL(t.Context(), http.DefaultClient, server.URL) + require.Error(t, err) + require.Contains(t, err.Error(), "byte limit") +} + +func TestSupportedRegionsForInit_ConcurrentCallersFetchOnce(t *testing.T) { + resetRegionsCache(t, nil) + + var hits atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + // Brief delay so concurrent callers genuinely overlap on the in-flight fetch. + time.Sleep(50 * time.Millisecond) + _, _ = w.Write([]byte(`{"regions": ["eastus2"]}`)) + })) + t.Cleanup(server.Close) + + prev := hostedAgentRegionsURL + hostedAgentRegionsURL = server.URL + t.Cleanup(func() { hostedAgentRegionsURL = prev }) + + const callers = 8 + var wg sync.WaitGroup + wg.Add(callers) + for range callers { + go func() { + defer wg.Done() + got, err := supportedRegionsForInit(t.Context()) + require.NoError(t, err) + require.Equal(t, []string{"eastus2"}, got) + }() + } + wg.Wait() + + require.Equal(t, int32(1), hits.Load()) +} + func resetRegionsCache(t *testing.T, regions []string) { t.Helper() regionsCache.mu.Lock() prev := regionsCache.regions + prevInflight := regionsCache.inflight regionsCache.regions = regions + regionsCache.inflight = nil regionsCache.mu.Unlock() t.Cleanup(func() { regionsCache.mu.Lock() regionsCache.regions = prev + regionsCache.inflight = prevInflight regionsCache.mu.Unlock() }) } From 4259a6f1d7fe909694a1826a1dc0bea67b872019 Mon Sep 17 00:00:00 2001 From: Antriksh Jain Date: Tue, 28 Apr 2026 18:34:37 +0530 Subject: [PATCH 5/6] fix(ai-agents): use embedded regions manifest as fetch fallback If the live manifest fetch fails (transient network, restrictive proxy, outage), fall back to the build-time embedded copy of hosted-agent-regions.json so 'azd init' is not blocked. The JSON moves into the cmd package because //go:embed cannot reference files outside the package directory. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../cmd}/hosted-agent-regions.json | 0 .../internal/cmd/init_locations.go | 34 +++++++++++++++++-- .../internal/cmd/init_locations_test.go | 29 ++++++++++++++++ 3 files changed, 61 insertions(+), 2 deletions(-) rename cli/azd/extensions/azure.ai.agents/{ => internal/cmd}/hosted-agent-regions.json (100%) diff --git a/cli/azd/extensions/azure.ai.agents/hosted-agent-regions.json b/cli/azd/extensions/azure.ai.agents/internal/cmd/hosted-agent-regions.json similarity index 100% rename from cli/azd/extensions/azure.ai.agents/hosted-agent-regions.json rename to cli/azd/extensions/azure.ai.agents/internal/cmd/hosted-agent-regions.json diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations.go index 5613d3b62c8..18c4c4268e3 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations.go @@ -5,6 +5,7 @@ package cmd import ( "context" + _ "embed" "encoding/json" "errors" "fmt" @@ -23,6 +24,12 @@ import ( // It is a var so tests can override it. var hostedAgentRegionsURL = "https://aka.ms/azd-ai-agents/regions" +// embeddedHostedAgentRegionsJSON is the build-time fallback used when the live +// manifest fetch fails (e.g. transient network issues, restrictive proxies). +// +//go:embed hosted-agent-regions.json +var embeddedHostedAgentRegionsJSON []byte + const ( hostedAgentRegionsFetchTimeout = 5 * time.Second // hostedAgentRegionsManifestMaxBytes caps the manifest body to guard against @@ -81,8 +88,8 @@ func supportedRegionsForInit(ctx context.Context) ([]string, error) { } // runRegionsFetch performs the network fetch, populates the cache on success, and -// signals all waiters via fetch.done. A failed fetch clears the inflight slot so -// subsequent callers retry instead of latching the error forever. +// signals all waiters via fetch.done. If the fetch fails, the embedded build-time +// manifest is used as a fallback so a transient network issue doesn't halt init. func runRegionsFetch(fetch *regionsFetch) { // The fetch uses its own timeout (hostedAgentRegionsFetchTimeout) and is // independent of any single caller's context, since the result is shared. @@ -90,6 +97,13 @@ func runRegionsFetch(fetch *regionsFetch) { context.Background(), http.DefaultClient, hostedAgentRegionsURL, ) + if err != nil { + if fallback, fbErr := parseEmbeddedHostedAgentRegions(); fbErr == nil && len(fallback) > 0 { + regions = fallback + err = nil + } + } + regionsCache.mu.Lock() if err == nil { regionsCache.regions = regions @@ -102,6 +116,22 @@ func runRegionsFetch(fetch *regionsFetch) { close(fetch.done) } +// parseEmbeddedHostedAgentRegions decodes the embedded build-time manifest used +// as a fallback when the live fetch fails. +func parseEmbeddedHostedAgentRegions() ([]string, error) { + var manifest hostedAgentRegionsManifest + if err := json.Unmarshal(embeddedHostedAgentRegionsJSON, &manifest); err != nil { + return nil, err + } + regions := make([]string, 0, len(manifest.Regions)) + for _, r := range manifest.Regions { + if normalized := normalizeLocationName(r); normalized != "" { + regions = append(regions, normalized) + } + } + return regions, nil +} + // supportedModelLocations returns the intersection of a model's available locations with // the supported hosted-agent regions. Returns an error when the intersection is empty // because passing an empty allowlist downstream disables filtering, which would let users diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations_test.go index bab28644e66..8562a1d3bc5 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations_test.go @@ -216,6 +216,35 @@ func TestSupportedRegionsForInit_ConcurrentCallersFetchOnce(t *testing.T) { require.Equal(t, int32(1), hits.Load()) } +func TestSupportedRegionsForInit_FallsBackToEmbeddedOnFetchError(t *testing.T) { + resetRegionsCache(t, nil) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + })) + t.Cleanup(server.Close) + + prev := hostedAgentRegionsURL + hostedAgentRegionsURL = server.URL + t.Cleanup(func() { hostedAgentRegionsURL = prev }) + + got, err := supportedRegionsForInit(t.Context()) + require.NoError(t, err) + + want, err := parseEmbeddedHostedAgentRegions() + require.NoError(t, err) + require.NotEmpty(t, want) + require.Equal(t, want, got) +} + +func TestParseEmbeddedHostedAgentRegions_NotEmpty(t *testing.T) { + t.Parallel() + + regions, err := parseEmbeddedHostedAgentRegions() + require.NoError(t, err) + require.NotEmpty(t, regions, "embedded fallback manifest must contain at least one region") +} + func resetRegionsCache(t *testing.T, regions []string) { t.Helper() From ec2e6ed380c0726592d1c18be976e1cf69d78782 Mon Sep 17 00:00:00 2001 From: Antriksh Jain Date: Tue, 28 Apr 2026 18:40:22 +0530 Subject: [PATCH 6/6] fix(ai-agents): propagate ctx into regions fetch goroutine (gosec G118) Use context.WithoutCancel(callerCtx) so the goroutine inherits ctx values without being abortable by any single caller. Resolves the gosec G118 lint failure flagging context.Background() inside the goroutine. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../internal/cmd/init_locations.go | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations.go index 18c4c4268e3..b8044b87208 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_locations.go @@ -72,7 +72,10 @@ func supportedRegionsForInit(ctx context.Context) ([]string, error) { if fetch == nil { fetch = ®ionsFetch{done: make(chan struct{})} regionsCache.inflight = fetch - go runRegionsFetch(fetch) + // context.WithoutCancel keeps any context values but drops cancellation, + // because the fetch result is shared across all waiters and must not be + // aborted by a single caller's cancellation. + go runRegionsFetch(context.WithoutCancel(ctx), fetch) } regionsCache.mu.Unlock() @@ -90,12 +93,12 @@ func supportedRegionsForInit(ctx context.Context) ([]string, error) { // runRegionsFetch performs the network fetch, populates the cache on success, and // signals all waiters via fetch.done. If the fetch fails, the embedded build-time // manifest is used as a fallback so a transient network issue doesn't halt init. -func runRegionsFetch(fetch *regionsFetch) { - // The fetch uses its own timeout (hostedAgentRegionsFetchTimeout) and is - // independent of any single caller's context, since the result is shared. - regions, err := fetchHostedAgentRegionsFromURL( - context.Background(), http.DefaultClient, hostedAgentRegionsURL, - ) +// +// ctx must not carry a cancellation that any single caller can trigger, since the +// fetch result is shared. Callers pass context.WithoutCancel(callerCtx). +func runRegionsFetch(ctx context.Context, fetch *regionsFetch) { + // The fetch applies its own timeout (hostedAgentRegionsFetchTimeout). + regions, err := fetchHostedAgentRegionsFromURL(ctx, http.DefaultClient, hostedAgentRegionsURL) if err != nil { if fallback, fbErr := parseEmbeddedHostedAgentRegions(); fbErr == nil && len(fallback) > 0 {