Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
30 changes: 30 additions & 0 deletions cli/azd/extensions/azure.ai.projects/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,36 @@ 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.

## Project authoring

Initialize a new Foundry project in the current azd workspace:

```sh
azd ai project init
```

For a new project, managed deployment declarations can be added before the
first provision:

```sh
azd ai project init
azd ai project deployment add --model <model-name>
azd provision
```

To use an existing project in automation, initialize it with its full ARM
resource ID. This stores the project identity in the active azd environment
and allows managed deployment declarations to be reconciled:

```sh
azd ai project init --project-id "<project-resource-id>"
azd ai project deployment add --model <model-name>
```

An endpoint-only project is suitable for configuration that does not manage
resources on the existing project. Use the full project resource ID before
adding managed deployments.

To reconcile deployments, connections, or a pending container registry on an existing project, set the project's full ARM resource ID in the active azd environment:

```sh
Expand Down
2 changes: 1 addition & 1 deletion cli/azd/extensions/azure.ai.projects/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ require (
go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel/trace v1.43.0
go.yaml.in/yaml/v3 v3.0.4
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478
google.golang.org/grpc v1.82.1
google.golang.org/protobuf v1.36.11
)
Expand Down Expand Up @@ -103,6 +104,5 @@ require (
golang.org/x/term v0.44.0 // indirect
golang.org/x/text v0.38.0 // indirect
golang.org/x/time v0.9.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package cmd

import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"

"azure.ai.projects/internal/exterrors"
"azure.ai.projects/internal/version"

"github.com/spf13/cobra"
)

const delegatedSchemaVersion = 1

const (
projectInitSourceAgents = "azure.ai.agents/init"
)

type delegatedProject struct {
ResourceID string `json:"resourceId,omitempty"`
Endpoint string `json:"endpoint,omitempty"`
}

type delegatedInfra struct {
EjectProvider string `json:"ejectProvider,omitempty"`
}

type delegatedRequirements struct {
AllowedLocations []string `json:"allowedLocations,omitempty"`
}

// projectInitRequest is the versioned IPC contract for agents.
type projectInitRequest struct {
SchemaVersion int `json:"schemaVersion"`
Source string `json:"source"`
SourceVersion string `json:"sourceVersion,omitempty"`
Project delegatedProject `json:"project"`
Infra delegatedInfra `json:"infra"`
Requirements delegatedRequirements `json:"requirements"`
ResolveAzureContext bool `json:"resolveAzureContext"`
Force bool `json:"force"`
}

type delegatedModel struct {
Name string `json:"name"`
DeploymentName string `json:"deploymentName,omitempty"`
RequiredCapabilities []string `json:"requiredCapabilities,omitempty"`
AllowedLocations []string `json:"allowedLocations,omitempty"`
ExcludedModelNames []string `json:"excludedModelNames,omitempty"`
}

// projectDeploymentAddRequest is the managed-model IPC contract.
type projectDeploymentAddRequest struct {
SchemaVersion int `json:"schemaVersion"`
Source string `json:"source"`
SourceVersion string `json:"sourceVersion,omitempty"`
Model delegatedModel `json:"model"`
SetAsDefault bool `json:"setAsDefault"`
Force bool `json:"force"`
}

type deploymentAddRequest = projectDeploymentAddRequest

type projectInitOutput struct {
SchemaVersion int `json:"schemaVersion"`
ProducerVersion string `json:"producerVersion"`
ServiceName string `json:"serviceName"`
Mode string `json:"mode"`
Mutation string `json:"mutation"`
Endpoint string `json:"endpoint,omitempty"`
ResourceID string `json:"resourceId,omitempty"`
}

type projectDeploymentAddOutput struct {
SchemaVersion int `json:"schemaVersion"`
ProducerVersion string `json:"producerVersion"`
ServiceName string `json:"serviceName"`
DeploymentName string `json:"deploymentName"`
Model deploymentOutputModel `json:"model"`
SKU deploymentOutputSKU `json:"sku"`
Mutation string `json:"mutation"`
}

type deploymentOutputModel struct {
Format string `json:"format"`
Name string `json:"name"`
Version string `json:"version"`
}

type deploymentOutputSKU struct {
Name string `json:"name"`
Capacity int `json:"capacity"`
}

func (r *projectInitRequest) validate() error {
if r == nil {
return contractValidationError("delegated project init request is empty")
}
if r.SchemaVersion != delegatedSchemaVersion {
return contractCompatibilityError(r.SchemaVersion)
}
if r.Source != projectInitSourceAgents {
return contractValidationError("source must be azure.ai.agents/init")
}
if r.Source == projectInitSourceAgents && strings.TrimSpace(r.SourceVersion) == "" {
return contractValidationError("sourceVersion is required for delegated requests")
}
if r.Project.ResourceID != "" && r.Project.Endpoint != "" {
return contractValidationError("project.resourceId and project.endpoint are mutually exclusive")
}
if r.Infra.EjectProvider != "" {
if _, err := parseInfraProvider(r.Infra.EjectProvider); err != nil {
return err
}
}
locations, err := normalizeLocations(r.Requirements.AllowedLocations)
if err != nil {
return err
}
if r.Requirements.AllowedLocations != nil && len(locations) == 0 {
return contractValidationError("requirements.allowedLocations must contain a location")
}
r.Requirements.AllowedLocations = locations
return nil
}

func validateProjectInitRequest(request projectInitRequest) error {
return request.validate()
}

func (r *projectDeploymentAddRequest) validate() error {
if r == nil {
return contractValidationError("delegated deployment request is empty")
}
if r.SchemaVersion != delegatedSchemaVersion {
return contractCompatibilityError(r.SchemaVersion)
}
if r.Source != projectInitSourceAgents {
return contractValidationError("source must be azure.ai.agents/init")
}
if r.Source == projectInitSourceAgents && strings.TrimSpace(r.SourceVersion) == "" {
return contractValidationError("sourceVersion is required for delegated requests")
}
if strings.TrimSpace(r.Model.Name) == "" {
return contractValidationError("model.name is required")
}
if strings.TrimSpace(r.Model.DeploymentName) == "" && r.Model.DeploymentName != "" {
return contractValidationError("model.deploymentName must not be whitespace")
}
locations, err := normalizeLocations(r.Model.AllowedLocations)
if err != nil {
return err
}
r.Model.AllowedLocations = locations
for _, capability := range r.Model.RequiredCapabilities {
if capability != "agentsV2" {
return contractValidationError(fmt.Sprintf("unknown required capability %q", capability))
}
}
r.Model.RequiredCapabilities = uniqueStrings(r.Model.RequiredCapabilities, false)
r.Model.ExcludedModelNames = uniqueStrings(r.Model.ExcludedModelNames, true)
return nil
}

func validateProjectDeploymentAddRequest(request projectDeploymentAddRequest) error {
return request.validate()
}

func contractCompatibilityError(got int) error {
return exterrors.Compatibility(
"project_contract_incompatible",
fmt.Sprintf(
"unsupported delegated contract schemaVersion %d (projects extension supports %d)",
got, delegatedSchemaVersion,
),
"upgrade azure.ai.agents and azure.ai.projects to compatible versions",
)
}

func contractValidationError(message string) error {
return exterrors.Validation("project_contract_invalid", message, "check the delegated request fields")
}

func normalizeLocations(values []string) ([]string, error) {
out := make([]string, 0, len(values))
seen := map[string]struct{}{}
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
return nil, contractValidationError("allowedLocations cannot contain an empty location")
}
key := strings.ToLower(value)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, value)
}
return out, nil
}

func uniqueStrings(values []string, insensitive bool) []string {
out := make([]string, 0, len(values))
seen := map[string]struct{}{}
for _, value := range values {
key := value
if insensitive {
key = strings.ToLower(key)
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, value)
}
return out
}

func decodeDelegatedJSON(path string, value any) error {
if err := validateDelegatedFilePath(path, "request", true); err != nil {
return err
}
file, err := os.Open(path) // #nosec G304 -- path is validated as a delegated file.
if err != nil {
return contractValidationError(fmt.Sprintf("read delegated request: %v", err))
}
defer file.Close()

decoder := json.NewDecoder(file)
decoder.DisallowUnknownFields()
if err := decoder.Decode(value); err != nil {
return contractValidationError(fmt.Sprintf("decode delegated request: %v", err))
}
var extra any
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
return contractValidationError("delegated request must contain exactly one JSON document")
}
return nil
}

func validateDelegatedFilePath(path, kind string, requireRegular bool) error {
if path == "" {
return contractValidationError(kind + " file path is required")
}
if !filepath.IsAbs(path) {
return contractValidationError(kind + " file path must be absolute")
}
abs, err := filepath.Abs(path)
if err != nil {
return contractValidationError(kind + " file path must be absolute")
}
info, statErr := os.Lstat(abs)
if statErr != nil {
if !requireRegular && os.IsNotExist(statErr) {
return nil
}
return contractValidationError(fmt.Sprintf("%s file is not accessible: %v", kind, statErr))
}
// Parent directories may use OS-provided aliases such as macOS /var.
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return contractValidationError(kind + " file must be a regular non-symlink file")
}
return nil
}

func delegatedProducerVersion() string {
return version.Version
}

func registerDelegatedContractFlags(
cmd *cobra.Command,
requestFile *string,
) {
cmd.Flags().StringVar(requestFile, "request-file", "", "Delegated request file")
_ = cmd.Flags().MarkHidden("request-file")
}
Loading
Loading