From dcc4172cc5a370f94acd1983a991995bda80efe0 Mon Sep 17 00:00:00 2001 From: HarshaVardhan Babu Namburi Date: Mon, 31 Aug 2026 15:22:39 +0530 Subject: [PATCH 01/13] feat(ai-projects): add experiment tracking commands Add authenticated CLI coverage for run inspection, agent traces, OTLP ingestion, and W&B compatibility APIs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/docs/environment-variables.md | 1 + .../extensions/azure.ai.projects/README.md | 105 +++ .../internal/cmd/experiment.go | 860 ++++++++++++++++++ .../internal/cmd/experiment_test.go | 129 +++ .../azure.ai.projects/internal/cmd/root.go | 3 + .../internal/experimenttracking/client.go | 292 ++++++ .../experimenttracking/client_test.go | 202 ++++ .../internal/exterrors/codes.go | 5 + 8 files changed, 1597 insertions(+) create mode 100644 cli/azd/extensions/azure.ai.projects/internal/cmd/experiment.go create mode 100644 cli/azd/extensions/azure.ai.projects/internal/cmd/experiment_test.go create mode 100644 cli/azd/extensions/azure.ai.projects/internal/experimenttracking/client.go create mode 100644 cli/azd/extensions/azure.ai.projects/internal/experimenttracking/client_test.go diff --git a/cli/azd/docs/environment-variables.md b/cli/azd/docs/environment-variables.md index e815fa0ae77..690ae52d1e1 100644 --- a/cli/azd/docs/environment-variables.md +++ b/cli/azd/docs/environment-variables.md @@ -201,6 +201,7 @@ Metadata requests are unauthenticated when no matching token is set. | Variable | Description | | --- | --- | | `AZURE_AI_PROJECT_ID` | The Microsoft Foundry project resource ID used by the `azure.ai.agents` extension. | +| `AZURE_AI_PROJECT_API_KEY` | A Microsoft Foundry account API key accepted by the project data plane and used by `azure.ai.projects` experiment-tracking commands. When set in the host process, it takes precedence over bearer authentication. Do not persist this value in project files or source control. | | `FOUNDRY_PROJECT_ENDPOINT` | The Microsoft Foundry project endpoint used by the `azure.ai.agents` extension. Read first from the active azd environment and, if not present, from the host shell environment as an endpoint-resolution fallback. | | `AZURE_AI_PROJECT_PRINCIPAL_ID` | The principal ID associated with the Microsoft Foundry project identity. | | `AZURE_AI_ACCOUNT_NAME` | The Microsoft Foundry account name associated with the project. | diff --git a/cli/azd/extensions/azure.ai.projects/README.md b/cli/azd/extensions/azure.ai.projects/README.md index 34b51402003..19c2add05e7 100644 --- a/cli/azd/extensions/azure.ai.projects/README.md +++ b/cli/azd/extensions/azure.ai.projects/README.md @@ -41,3 +41,108 @@ existing Foundry project should be reused instead, configure its endpoint and se full project resource ID before retrying. The `azd ai project set`, `show`, and `unset` commands manage the default Foundry project endpoint context. They do not currently author the project service in `azure.yaml`. + +## Experiment tracking + +The extension exposes the Foundry project experiment-tracking APIs. Commands +reuse the project endpoint context described above and authenticate through +`azd auth login` with the `https://ai.azure.com/.default` scope. + +To use the Foundry account API key accepted by the project data plane, set +`AZURE_AI_PROJECT_API_KEY` in the current process. The environment variable +takes precedence over bearer authentication and should not be persisted in +`azure.yaml`, azd environment files, or shell profiles. + +The project ID is derived from the final path segment of the resolved endpoint: + +```text +https://my-account.services.ai.azure.com/api/projects/my-project + └─ project ID +``` + +Use `--project-id` only when calling a nonstandard endpoint whose path does not +contain the project ID. + +### Runs + +```sh +azd ai project run list +azd ai project run summary --run-id +azd ai project run metrics --run-id +azd ai project run system-metrics --run-id --name system/cpu +azd ai project run logs --run-id +azd ai project run log-records --run-id +azd ai project run traces --run-id +azd ai project run trace show --run-id --trace-id +azd ai project run compare \ + --run-id --run-id \ + --metric loss --min 0 --max 100 +``` + +All experiment-tracking commands emit JSON so automation receives the complete +service response without a lossy table projection. + +### Span filters + +Pass a filter inline or in a JSON file. When neither is provided, the command +uses `{"$expr":true}`. + +```json +{ + "$expr": { + "$eq": [ + { "$getField": "span_name" }, + { "$literal": "chat" } + ] + } +} +``` + +```sh +azd ai project run spans query \ + --run-id \ + --filter-file ./span-filter.json \ + --include-details \ + --limit 10 +``` + +The CLI wraps the filter with the resolved project ID: + +```json +{ + "project_id": "my-project", + "query": { + "$expr": { + "$eq": [ + { "$getField": "span_name" }, + { "$literal": "chat" } + ] + } + }, + "include_details": true, + "limit": 10 +} +``` + +Use `--request-file` to send a complete span-query or trace-chat request body. + +### Ingestion and W&B compatibility + +OTLP commands require an explicit payload and never send an empty no-op request: + +```sh +azd ai project ingest metrics --run-id --file ./metrics.pb +azd ai project ingest logs --run-id --file ./logs.pb +azd ai project ingest traces --run-id --file ./traces.pb +azd ai project ingest agent-traces --run-id --file ./agent-traces.json +``` + +Use `--file -` to read a payload from stdin. Advanced W&B-compatible requests +accept complete JSON request bodies: + +```sh +azd ai project wandb graphql --file ./graphql-request.json +azd ai project wandb file-stream \ + --run-id \ + --file ./file-stream-request.json +``` diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/experiment.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/experiment.go new file mode 100644 index 00000000000..41eaa51db3a --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/experiment.go @@ -0,0 +1,860 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strconv" + "strings" + + "azure.ai.projects/internal/experimenttracking" + "azure.ai.projects/internal/exterrors" + + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +const maxExperimentInputBytes = 64 << 20 + +const experimentAPIKeyEnv = "AZURE_AI_PROJECT_API_KEY" + +type experimentFlags struct { + projectEndpoint string + projectID string + apiVersion string +} + +type runRequestFlags struct { + experimentFlags + runID string + take int +} + +func newExperimentRunCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "run", + Short: "Inspect Foundry experiment-tracking runs.", + Args: cobra.NoArgs, + } + + cmd.AddCommand(newRunListCommand(extCtx)) + cmd.AddCommand(newRunHistoryKeysCommand(extCtx)) + cmd.AddCommand(newRunSummaryCommand(extCtx)) + cmd.AddCommand(newRunMetricsCommand(extCtx)) + cmd.AddCommand(newRunSystemMetricsCommand(extCtx)) + cmd.AddCommand(newRunLogsCommand(extCtx)) + cmd.AddCommand(newRunLogRecordsCommand(extCtx)) + cmd.AddCommand(newRunTracesCommand(extCtx)) + cmd.AddCommand(newRunTraceCommand(extCtx)) + cmd.AddCommand(newRunCompareCommand(extCtx)) + cmd.AddCommand(newRunSpansCommand(extCtx)) + return cmd +} + +func newRunListCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + flags := &runRequestFlags{take: 10} + cmd := &cobra.Command{ + Use: "list", + Short: "List experiment-tracking runs.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if flags.take <= 0 { + return invalidExperimentParameter("take", "--take must be greater than zero") + } + client, err := newExperimentClient(cmd.Context(), flags.experimentFlags) + if err != nil { + return err + } + return executeExperimentJSON( + cmd, + client, + http.MethodGet, + "runs", + takeQuery(flags.take), + nil, + nil, + ) + }, + } + addExperimentFlags(cmd, &flags.experimentFlags) + cmd.Flags().IntVar(&flags.take, "take", 10, "Maximum number of runs to return") + registerJSONOutput(cmd, extCtx) + return cmd +} + +func newRunHistoryKeysCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + return newRunGetCommand( + extCtx, + "history-keys", + "List history keys for a run.", + "history/keys", + false, + ) +} + +func newRunSummaryCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + return newRunGetCommand(extCtx, "summary", "Get the summary for a run.", "summary", true) +} + +func newRunMetricsCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + return newRunGetCommand(extCtx, "metrics", "List metrics for a run.", "metrics", true) +} + +func newRunLogsCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + return newRunGetCommand(extCtx, "logs", "Get console logs for a run.", "logs", true) +} + +func newRunLogRecordsCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + return newRunGetCommand(extCtx, "log-records", "Get structured log records for a run.", "log-records", true) +} + +func newRunTracesCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + return newRunGetCommand(extCtx, "traces", "List traces for a run.", "traces", true) +} + +func newRunGetCommand( + extCtx *azdext.ExtensionContext, + use string, + short string, + suffix string, + withTake bool, +) *cobra.Command { + flags := &runRequestFlags{take: 10} + cmd := &cobra.Command{ + Use: use, + Short: short, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if err := requireValue("run-id", flags.runID); err != nil { + return err + } + if withTake && flags.take <= 0 { + return invalidExperimentParameter("take", "--take must be greater than zero") + } + client, err := newExperimentClient(cmd.Context(), flags.experimentFlags) + if err != nil { + return err + } + var query url.Values + if withTake { + query = takeQuery(flags.take) + } + headers := http.Header(nil) + if suffix == "metrics" { + headers = client.RunHeaders(flags.runID) + } + return executeExperimentJSON( + cmd, + client, + http.MethodGet, + fmt.Sprintf("runs/%s/%s", url.PathEscape(flags.runID), suffix), + query, + headers, + nil, + ) + }, + } + addRunFlags(cmd, flags, withTake) + registerJSONOutput(cmd, extCtx) + return cmd +} + +func newRunSystemMetricsCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + flags := &runRequestFlags{take: 10} + var names []string + cmd := &cobra.Command{ + Use: "system-metrics", + Short: "Get selected system metrics for a run.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if err := requireValue("run-id", flags.runID); err != nil { + return err + } + if len(names) == 0 { + return invalidExperimentParameter("name", "provide at least one system metric name") + } + if flags.take <= 0 { + return invalidExperimentParameter("take", "--take must be greater than zero") + } + client, err := newExperimentClient(cmd.Context(), flags.experimentFlags) + if err != nil { + return err + } + query := takeQuery(flags.take) + for _, name := range names { + query.Add("names", name) + } + return executeExperimentJSON( + cmd, + client, + http.MethodGet, + fmt.Sprintf("runs/%s/system-metrics", url.PathEscape(flags.runID)), + query, + nil, + nil, + ) + }, + } + addRunFlags(cmd, flags, true) + cmd.Flags().StringSliceVar(&names, "name", nil, "System metric name; may be specified multiple times") + registerJSONOutput(cmd, extCtx) + return cmd +} + +func newRunTraceCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "trace", + Short: "Inspect or analyze a run trace.", + Args: cobra.NoArgs, + } + cmd.AddCommand(newRunTraceShowCommand(extCtx)) + cmd.AddCommand(newRunTraceChatCommand(extCtx)) + return cmd +} + +func newRunTraceShowCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + flags := &runRequestFlags{} + var traceID string + cmd := &cobra.Command{ + Use: "show", + Short: "Get one trace and its details.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if err := requireValues(map[string]string{"run-id": flags.runID, "trace-id": traceID}); err != nil { + return err + } + client, err := newExperimentClient(cmd.Context(), flags.experimentFlags) + if err != nil { + return err + } + return executeExperimentJSON( + cmd, + client, + http.MethodGet, + fmt.Sprintf( + "runs/%s/traces/%s", + url.PathEscape(flags.runID), + url.PathEscape(traceID), + ), + nil, + nil, + nil, + ) + }, + } + addRunFlags(cmd, flags, false) + cmd.Flags().StringVar(&traceID, "trace-id", "", "Trace ID") + registerJSONOutput(cmd, extCtx) + return cmd +} + +func newRunTraceChatCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + flags := &runRequestFlags{} + var traceID string + var requestFile string + cmd := &cobra.Command{ + Use: "chat", + Short: "Request the agent trace-chat response for a trace.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if err := requireValue("run-id", flags.runID); err != nil { + return err + } + if requestFile == "" { + if err := requireValue("trace-id", traceID); err != nil { + return err + } + } + client, err := newExperimentClient(cmd.Context(), flags.experimentFlags) + if err != nil { + return err + } + + body := any(map[string]any{ + "project_id": client.ProjectID(), + "trace_id": traceID, + }) + if requestFile != "" { + body, err = readJSONObject(requestFile) + if err != nil { + return err + } + } + + return executeExperimentJSON( + cmd, + client, + http.MethodPost, + fmt.Sprintf("runs/%s/agents/traces/chat", url.PathEscape(flags.runID)), + nil, + client.RunHeaders(flags.runID), + body, + ) + }, + } + addRunFlags(cmd, flags, false) + cmd.Flags().StringVar(&traceID, "trace-id", "", "Trace ID") + cmd.Flags().StringVar(&requestFile, "request-file", "", "Path to a complete JSON request body") + registerJSONOutput(cmd, extCtx) + return cmd +} + +func newRunCompareCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + flags := &experimentFlags{} + var runIDs []string + var metricNames []string + var minStep float64 + var maxStep float64 + cmd := &cobra.Command{ + Use: "compare", + Short: "Compare metrics across experiment-tracking runs.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if len(runIDs) < 2 { + return invalidExperimentParameter("run-id", "provide at least two run IDs") + } + if len(metricNames) == 0 { + return invalidExperimentParameter("metric", "provide at least one metric name") + } + if maxStep < minStep { + return invalidExperimentParameter("max", "--max must be greater than or equal to --min") + } + client, err := newExperimentClient(cmd.Context(), *flags) + if err != nil { + return err + } + body := map[string]any{ + "runIds": runIDs, + "metricNames": metricNames, + "min": minStep, + "max": maxStep, + } + return executeExperimentJSON(cmd, client, http.MethodPost, "runs/compare", nil, nil, body) + }, + } + addExperimentFlags(cmd, flags) + cmd.Flags().StringSliceVar(&runIDs, "run-id", nil, "Run ID; specify at least twice") + cmd.Flags().StringSliceVar(&metricNames, "metric", nil, "Metric name; may be specified multiple times") + cmd.Flags().Float64Var(&minStep, "min", 0, "Minimum metric step") + cmd.Flags().Float64Var(&maxStep, "max", 0, "Maximum metric step") + registerJSONOutput(cmd, extCtx) + return cmd +} + +func newRunSpansCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "spans", + Short: "Query run-scoped spans.", + Args: cobra.NoArgs, + } + cmd.AddCommand(newRunSpansQueryCommand(extCtx)) + return cmd +} + +func newRunSpansQueryCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + flags := &runRequestFlags{} + var filter string + var filterFile string + var requestFile string + var includeDetails bool + var limit int + + cmd := &cobra.Command{ + Use: "query", + Short: "Query spans using a JSON filter expression.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if err := requireValue("run-id", flags.runID); err != nil { + return err + } + client, err := newExperimentClient(cmd.Context(), flags.experimentFlags) + if err != nil { + return err + } + + var body any + if requestFile != "" { + if filter != "" || filterFile != "" { + return invalidExperimentParameter( + "request-file", + "--request-file cannot be combined with --filter or --filter-file", + ) + } + body, err = readJSONObject(requestFile) + } else { + query, queryErr := readFilterExpression(filter, filterFile) + if queryErr != nil { + return queryErr + } + if limit <= 0 { + return invalidExperimentParameter("limit", "--limit must be greater than zero") + } + body = buildSpanQueryBody(client.ProjectID(), query, includeDetails, limit) + } + if err != nil { + return err + } + + return executeExperimentJSON( + cmd, + client, + http.MethodPost, + fmt.Sprintf("runs/%s/agents/spans/query", url.PathEscape(flags.runID)), + nil, + client.RunHeaders(flags.runID), + body, + ) + }, + } + addRunFlags(cmd, flags, false) + cmd.Flags().StringVar(&filter, "filter", "", "Inline JSON span filter expression") + cmd.Flags().StringVar(&filterFile, "filter-file", "", "Path to a JSON span filter expression") + cmd.Flags().StringVar(&requestFile, "request-file", "", "Path to a complete JSON request body") + cmd.Flags().BoolVar(&includeDetails, "include-details", false, "Include detailed span data") + cmd.Flags().IntVar(&limit, "limit", 10, "Maximum number of spans to return") + registerJSONOutput(cmd, extCtx) + return cmd +} + +func newExperimentIngestCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "ingest", + Short: "Send telemetry to Foundry experiment tracking.", + Args: cobra.NoArgs, + } + cmd.AddCommand(newOTLPIngestCommand(extCtx, "metrics")) + cmd.AddCommand(newOTLPIngestCommand(extCtx, "logs")) + cmd.AddCommand(newOTLPIngestCommand(extCtx, "traces")) + cmd.AddCommand(newAgentTracesIngestCommand(extCtx)) + return cmd +} + +func newOTLPIngestCommand(extCtx *azdext.ExtensionContext, signal string) *cobra.Command { + flags := &runRequestFlags{} + var file string + cmd := &cobra.Command{ + Use: signal, + Short: fmt.Sprintf("Ingest OTLP %s from a protobuf file or stdin.", signal), + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if err := requireValues(map[string]string{"run-id": flags.runID, "file": file}); err != nil { + return err + } + payload, err := readExperimentInput(file) + if err != nil { + return err + } + client, err := newExperimentClient(cmd.Context(), flags.experimentFlags) + if err != nil { + return err + } + response, err := client.DoBytes( + cmd.Context(), + http.MethodPost, + "protocols/otlp/v1/"+signal, + nil, + client.RunHeaders(flags.runID), + "application/x-protobuf", + payload, + ) + if err != nil { + return classifyExperimentError(err) + } + if isJSONObject(response) { + return writeExperimentResponse(cmd, response, nil) + } + return writeExperimentResponse(cmd, json.RawMessage(`{"status":"accepted"}`), nil) + }, + } + addRunFlags(cmd, flags, false) + cmd.Flags().StringVar(&file, "file", "", "Protobuf payload path, or - for stdin") + registerJSONOutput(cmd, extCtx) + return cmd +} + +func newAgentTracesIngestCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + flags := &runRequestFlags{} + var file string + cmd := &cobra.Command{ + Use: "agent-traces", + Short: "Ingest agent OTEL traces from a JSON file or stdin.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if err := requireValues(map[string]string{"run-id": flags.runID, "file": file}); err != nil { + return err + } + body, err := readJSONObject(file) + if err != nil { + return err + } + if _, found := body["run_id"]; !found { + body["run_id"] = flags.runID + } + client, err := newExperimentClient(cmd.Context(), flags.experimentFlags) + if err != nil { + return err + } + return executeExperimentJSON(cmd, client, http.MethodPost, "agents/otel/v1/traces", nil, nil, body) + }, + } + addRunFlags(cmd, flags, false) + cmd.Flags().StringVar(&file, "file", "", "JSON payload path, or - for stdin") + registerJSONOutput(cmd, extCtx) + return cmd +} + +func newExperimentWandBCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "wandb", + Short: "Use experiment-tracking W&B compatibility APIs.", + Args: cobra.NoArgs, + } + cmd.AddCommand(newWandBGraphQLCommand(extCtx)) + cmd.AddCommand(newWandBFileStreamCommand(extCtx)) + return cmd +} + +func newWandBGraphQLCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + flags := &experimentFlags{} + var file string + cmd := &cobra.Command{ + Use: "graphql", + Short: "Execute a W&B-compatible GraphQL request.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if err := requireValue("file", file); err != nil { + return err + } + body, err := readJSONObject(file) + if err != nil { + return err + } + client, err := newExperimentClient(cmd.Context(), *flags) + if err != nil { + return err + } + return executeExperimentJSON(cmd, client, http.MethodPost, "graphql", nil, nil, body) + }, + } + addExperimentFlags(cmd, flags) + cmd.Flags().StringVar(&file, "file", "", "GraphQL JSON request path, or - for stdin") + registerJSONOutput(cmd, extCtx) + return cmd +} + +func newWandBFileStreamCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + flags := &runRequestFlags{} + var entity string + var project string + var file string + cmd := &cobra.Command{ + Use: "file-stream", + Short: "Send a W&B-compatible FileStream payload.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if err := requireValues(map[string]string{ + "run-id": flags.runID, + "file": file, + }); err != nil { + return err + } + body, err := readJSONObject(file) + if err != nil { + return err + } + client, err := newExperimentClient(cmd.Context(), flags.experimentFlags) + if err != nil { + return err + } + if project == "" { + project = client.ProjectID() + } + if entity == "" { + entity = client.AccountID() + } + apiPath := fmt.Sprintf( + "files/%s/%s/%s/file_stream", + url.PathEscape(entity), + url.PathEscape(project), + url.PathEscape(flags.runID), + ) + return executeExperimentJSON(cmd, client, http.MethodPost, apiPath, nil, nil, body) + }, + } + addRunFlags(cmd, flags, false) + cmd.Flags().StringVar(&entity, "entity", "", "W&B entity or account name") + cmd.Flags().StringVar(&project, "wandb-project", "", "W&B project name; defaults to the Foundry project ID") + cmd.Flags().StringVar(&file, "file", "", "FileStream JSON request path, or - for stdin") + registerJSONOutput(cmd, extCtx) + return cmd +} + +func addExperimentFlags(cmd *cobra.Command, flags *experimentFlags) { + cmd.Flags().StringVarP( + &flags.projectEndpoint, + "project-endpoint", + "p", + "", + "Foundry project endpoint URL", + ) + cmd.Flags().StringVar( + &flags.projectID, + "project-id", + "", + "Override the project ID derived from the endpoint", + ) + cmd.Flags().StringVar(&flags.apiVersion, "api-version", "v1", "Experiment-tracking API version") +} + +func addRunFlags(cmd *cobra.Command, flags *runRequestFlags, withTake bool) { + addExperimentFlags(cmd, &flags.experimentFlags) + cmd.Flags().StringVar(&flags.runID, "run-id", "", "Experiment-tracking run ID") + if withTake { + cmd.Flags().IntVar(&flags.take, "take", 10, "Maximum number of records to return") + } +} + +func registerJSONOutput(cmd *cobra.Command, extCtx *azdext.ExtensionContext) { + _ = ensureExtensionContext(extCtx) + azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ + Name: "output", + AllowedValues: []string{"json"}, + Default: "json", + }) +} + +func newExperimentClient( + ctx context.Context, + flags experimentFlags, +) (*experimenttracking.Client, error) { + resolved, err := resolveProjectEndpoint(ctx, resolveProjectEndpointOpts{ + FlagValue: flags.projectEndpoint, + }) + if err != nil { + return nil, err + } + + if apiKey := os.Getenv(experimentAPIKeyEnv); apiKey != "" { + client, err := experimenttracking.NewClientWithAPIKey( + resolved.Endpoint, + flags.projectID, + flags.apiVersion, + apiKey, + ) + if err != nil { + return nil, exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("configure experiment-tracking client: %s", err), + "verify the Foundry project endpoint and API key configuration", + ) + } + return client, nil + } + + credential, err := azidentity.NewAzureDeveloperCLICredential(nil) + if err != nil { + return nil, exterrors.Auth( + exterrors.CodeCredentialCreationFailed, + fmt.Sprintf("create Azure Developer CLI credential: %s", err), + "run 'azd auth login' and retry", + ) + } + + client, err := experimenttracking.NewClient( + resolved.Endpoint, + flags.projectID, + flags.apiVersion, + credential, + ) + if err != nil { + return nil, exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("configure experiment-tracking client: %s", err), + "provide a standard Foundry project endpoint or set --project-id explicitly", + ) + } + return client, nil +} + +func executeExperimentJSON( + cmd *cobra.Command, + client *experimenttracking.Client, + method string, + apiPath string, + query url.Values, + headers http.Header, + body any, +) error { + response, err := client.DoJSON(cmd.Context(), method, apiPath, query, headers, body) + return writeExperimentResponse(cmd, response, classifyExperimentError(err)) +} + +func classifyExperimentError(err error) error { + if err == nil { + return nil + } + if strings.Contains(strings.ToLower(err.Error()), "access token") { + return exterrors.Auth( + exterrors.CodeAuthenticationFailed, + fmt.Sprintf("authenticate to Foundry experiment tracking: %s", err), + "run 'azd auth login' and verify access to the Foundry project", + ) + } + return exterrors.ServiceFromAzure(err, exterrors.OpExperimentRequest) +} + +func writeExperimentResponse(cmd *cobra.Command, response json.RawMessage, err error) error { + if err != nil { + return err + } + + var formatted bytes.Buffer + if indentErr := json.Indent(&formatted, response, "", " "); indentErr != nil { + return exterrors.Internal( + exterrors.CodeExperimentRequestFailed, + fmt.Sprintf("format experiment-tracking response: %s", indentErr), + ) + } + formatted.WriteByte('\n') + _, writeErr := formatted.WriteTo(cmd.OutOrStdout()) + if writeErr != nil { + return fmt.Errorf("write response: %w", writeErr) + } + return nil +} + +func takeQuery(take int) url.Values { + query := make(url.Values) + query.Set("take", strconv.Itoa(take)) + return query +} + +func readFilterExpression(inline string, file string) (json.RawMessage, error) { + if inline != "" && file != "" { + return nil, invalidExperimentParameter("filter", "--filter and --filter-file cannot be combined") + } + + data := []byte(`{"$expr":true}`) + var err error + if file != "" { + data, err = readExperimentInput(file) + } else if inline != "" { + data = []byte(inline) + } + if err != nil { + return nil, err + } + if !isJSONObject(data) { + return nil, invalidExperimentPayload("span filter must be a JSON object") + } + return json.RawMessage(data), nil +} + +func buildSpanQueryBody( + projectID string, + query json.RawMessage, + includeDetails bool, + limit int, +) map[string]any { + return map[string]any{ + "project_id": projectID, + "query": query, + "include_details": includeDetails, + "limit": limit, + } +} + +func readJSONObject(file string) (map[string]any, error) { + data, err := readExperimentInput(file) + if err != nil { + return nil, err + } + + var object map[string]any + if err := json.Unmarshal(data, &object); err != nil { + return nil, invalidExperimentPayload(fmt.Sprintf("parse JSON object: %s", err)) + } + if object == nil { + return nil, invalidExperimentPayload("payload must be a JSON object") + } + return object, nil +} + +func isJSONObject(data []byte) bool { + var object map[string]any + return json.Unmarshal(data, &object) == nil && object != nil +} + +func readExperimentInput(file string) ([]byte, error) { + var reader io.Reader + if file == "-" { + reader = os.Stdin + } else { + opened, err := os.Open(file) + if err != nil { + return nil, exterrors.Dependency( + exterrors.CodeExperimentInputReadFailed, + fmt.Sprintf("open experiment payload %q: %s", file, err), + "verify the file path and permissions", + ) + } + defer opened.Close() + reader = opened + } + + data, err := io.ReadAll(io.LimitReader(reader, maxExperimentInputBytes+1)) + if err != nil { + return nil, exterrors.Dependency( + exterrors.CodeExperimentInputReadFailed, + fmt.Sprintf("read experiment payload: %s", err), + "verify the input can be read and retry", + ) + } + if len(data) > maxExperimentInputBytes { + return nil, invalidExperimentPayload( + fmt.Sprintf("payload exceeds %d bytes", maxExperimentInputBytes), + ) + } + return data, nil +} + +func requireValue(name string, value string) error { + if strings.TrimSpace(value) == "" { + return invalidExperimentParameter(name, fmt.Sprintf("--%s is required", name)) + } + return nil +} + +func requireValues(values map[string]string) error { + for name, value := range values { + if err := requireValue(name, value); err != nil { + return err + } + } + return nil +} + +func invalidExperimentParameter(name string, message string) error { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + message, + fmt.Sprintf("provide a valid --%s value", name), + ) +} + +func invalidExperimentPayload(message string) error { + return exterrors.Validation( + exterrors.CodeInvalidExperimentPayload, + message, + "provide a valid JSON object using the documented request schema", + ) +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/experiment_test.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/experiment_test.go new file mode 100644 index 00000000000..3a0fd57e4ac --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/experiment_test.go @@ -0,0 +1,129 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRootIncludesExperimentTrackingCommands(t *testing.T) { + root := NewRootCommand() + names := map[string]bool{} + for _, command := range root.Commands() { + names[command.Name()] = true + } + + assert.True(t, names["run"]) + assert.True(t, names["ingest"]) + assert.True(t, names["wandb"]) +} + +func TestRunCommandIncludesAllReadAndAgentSurfaces(t *testing.T) { + command := newExperimentRunCommand(nil) + names := map[string]bool{} + for _, child := range command.Commands() { + names[child.Name()] = true + } + + for _, name := range []string{ + "compare", + "history-keys", + "list", + "log-records", + "logs", + "metrics", + "spans", + "summary", + "system-metrics", + "trace", + "traces", + } { + assert.True(t, names[name], "missing run command %q", name) + } +} + +func TestReadFilterExpressionDefaultsToMatchAll(t *testing.T) { + filter, err := readFilterExpression("", "") + require.NoError(t, err) + assert.JSONEq(t, `{"$expr":true}`, string(filter)) +} + +func TestReadFilterExpressionFromFile(t *testing.T) { + filterPath := filepath.Join(t.TempDir(), "filter.json") + require.NoError(t, os.WriteFile( + filterPath, + []byte(`{ + "$expr": { + "$eq": [ + {"$getField": "span_name"}, + {"$literal": "chat"} + ] + } + }`), + 0o600, + )) + + filter, err := readFilterExpression("", filterPath) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal(filter, &parsed)) + require.Contains(t, parsed, "$expr") +} + +func TestReadFilterExpressionRejectsConflictingInputs(t *testing.T) { + _, err := readFilterExpression(`{"$expr":true}`, "filter.json") + require.Error(t, err) +} + +func TestBuildSpanQueryBody(t *testing.T) { + body := buildSpanQueryBody( + "my-project", + json.RawMessage(`{ + "$expr": { + "$eq": [ + {"$getField": "span_name"}, + {"$literal": "chat"} + ] + } + }`), + true, + 10, + ) + + data, err := json.Marshal(body) + require.NoError(t, err) + assert.JSONEq(t, `{ + "project_id": "my-project", + "query": { + "$expr": { + "$eq": [ + {"$getField": "span_name"}, + {"$literal": "chat"} + ] + } + }, + "include_details": true, + "limit": 10 + }`, string(data)) +} + +func TestExperimentCommandsUseJSONOutput(t *testing.T) { + commands := []*cobra.Command{ + newRunListCommand(nil), + newRunSpansQueryCommand(nil), + newOTLPIngestCommand(nil, "metrics"), + newWandBGraphQLCommand(nil), + } + for _, command := range commands { + assertOutputFlagOptions(t, command, "json", []string{"json"}) + } +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/root.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/root.go index f376368b423..10121721a1b 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/root.go @@ -33,6 +33,9 @@ func NewRootCommand() *cobra.Command { rootCmd.AddCommand(newProjectSetCommand(extCtx)) rootCmd.AddCommand(newProjectUnsetCommand(extCtx)) rootCmd.AddCommand(newProjectShowCommand(extCtx)) + rootCmd.AddCommand(newExperimentRunCommand(extCtx)) + rootCmd.AddCommand(newExperimentIngestCommand(extCtx)) + rootCmd.AddCommand(newExperimentWandBCommand(extCtx)) rootCmd.AddCommand(azdext.NewListenCommand(configureExtensionHost)) return rootCmd diff --git a/cli/azd/extensions/azure.ai.projects/internal/experimenttracking/client.go b/cli/azd/extensions/azure.ai.projects/internal/experimenttracking/client.go new file mode 100644 index 00000000000..56effe88b21 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/experimenttracking/client.go @@ -0,0 +1,292 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Package experimenttracking provides an authenticated client for Foundry +// experiment-tracking APIs. +package experimenttracking + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "path" + "strings" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" +) + +const ( + defaultAPIVersion = "v1" + tokenScope = "https://ai.azure.com/.default" + maxResponseBytes = 64 << 20 + defaultTimeout = 30 * time.Second +) + +// Client calls the experiment-tracking APIs for a Foundry project. +type Client struct { + projectEndpoint string + projectID string + accountID string + apiVersion string + credential azcore.TokenCredential + apiKey string + httpClient *http.Client +} + +// NewClient creates an experiment-tracking client. +func NewClient( + projectEndpoint string, + projectIDOverride string, + apiVersion string, + credential azcore.TokenCredential, +) (*Client, error) { + return newClient( + projectEndpoint, + projectIDOverride, + apiVersion, + credential, + "", + &http.Client{Timeout: defaultTimeout}, + ) +} + +// NewClientWithAPIKey creates an experiment-tracking client that authenticates +// with a Foundry project API key. +func NewClientWithAPIKey( + projectEndpoint string, + projectIDOverride string, + apiVersion string, + apiKey string, +) (*Client, error) { + return newClient( + projectEndpoint, + projectIDOverride, + apiVersion, + nil, + apiKey, + &http.Client{Timeout: defaultTimeout}, + ) +} + +func newClient( + projectEndpoint string, + projectIDOverride string, + apiVersion string, + credential azcore.TokenCredential, + apiKey string, + httpClient *http.Client, +) (*Client, error) { + apiKey = strings.TrimSpace(apiKey) + if credential == nil && apiKey == "" { + return nil, fmt.Errorf("credential or API key must be provided") + } + + parsed, err := url.Parse(projectEndpoint) + if err != nil { + return nil, fmt.Errorf("parse project endpoint: %w", err) + } + if parsed.Scheme != "https" && parsed.Scheme != "http" { + return nil, fmt.Errorf("project endpoint must use http or https") + } + + projectID, err := projectIDFromEndpoint(parsed) + if err != nil && strings.TrimSpace(projectIDOverride) == "" { + return nil, err + } + if override := strings.TrimSpace(projectIDOverride); override != "" { + projectID = override + } + if strings.ContainsAny(projectID, `/\`) { + return nil, fmt.Errorf("project ID must not contain path separators") + } + + accountID := strings.Split(parsed.Hostname(), ".")[0] + if accountID == "" { + return nil, fmt.Errorf("derive account ID from project endpoint") + } + + if apiVersion == "" { + apiVersion = defaultAPIVersion + } + + return &Client{ + projectEndpoint: strings.TrimRight(projectEndpoint, "/"), + projectID: projectID, + accountID: accountID, + apiVersion: apiVersion, + credential: credential, + apiKey: apiKey, + httpClient: httpClient, + }, nil +} + +func projectIDFromEndpoint(endpoint *url.URL) (string, error) { + const prefix = "/api/projects/" + + escapedPath := strings.TrimRight(endpoint.EscapedPath(), "/") + if !strings.HasPrefix(escapedPath, prefix) { + return "", fmt.Errorf("project endpoint path must match %s", prefix) + } + + escapedID := strings.TrimPrefix(escapedPath, prefix) + if escapedID == "" || strings.Contains(escapedID, "/") { + return "", fmt.Errorf("project endpoint must contain exactly one project ID path segment") + } + + projectID, err := url.PathUnescape(escapedID) + if err != nil { + return "", fmt.Errorf("decode project ID: %w", err) + } + return projectID, nil +} + +// ProjectID returns the project ID derived from the endpoint or explicit override. +func (c *Client) ProjectID() string { + return c.projectID +} + +// AccountID returns the account ID derived from the endpoint host. +func (c *Client) AccountID() string { + return c.accountID +} + +// RunHeaders returns compatibility headers required by selected run APIs. +func (c *Client) RunHeaders(runID string) http.Header { + headers := make(http.Header) + headers.Set("X-WANDB-USERNAME", c.accountID) + headers.Set("X-Helios-Project-Id", c.projectID) + headers.Set("x-helios-run-id", runID) + return headers +} + +// DoJSON sends an authenticated JSON request and returns its response body. +func (c *Client) DoJSON( + ctx context.Context, + method string, + apiPath string, + query url.Values, + headers http.Header, + body any, +) (json.RawMessage, error) { + var reader io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("encode request body: %w", err) + } + reader = bytes.NewReader(data) + } + + if headers == nil { + headers = make(http.Header) + } + headers.Set("Accept", "application/json") + if body != nil { + headers.Set("Content-Type", "application/json") + } + + return c.do(ctx, method, apiPath, query, headers, reader) +} + +// DoBytes sends an authenticated request with an arbitrary content type. +func (c *Client) DoBytes( + ctx context.Context, + method string, + apiPath string, + query url.Values, + headers http.Header, + contentType string, + body []byte, +) (json.RawMessage, error) { + if headers == nil { + headers = make(http.Header) + } + headers.Set("Accept", contentType) + headers.Set("Content-Type", contentType) + return c.do(ctx, method, apiPath, query, headers, bytes.NewReader(body)) +} + +func (c *Client) do( + ctx context.Context, + method string, + apiPath string, + query url.Values, + headers http.Header, + body io.Reader, +) (json.RawMessage, error) { + requestURL, err := c.requestURL(apiPath, query) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, method, requestURL, body) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + req.Header = headers.Clone() + if c.apiKey != "" { + req.Header.Set("api-key", c.apiKey) + } else { + token, tokenErr := c.credential.GetToken(ctx, policy.TokenRequestOptions{ + Scopes: []string{tokenScope}, + }) + if tokenErr != nil { + return nil, fmt.Errorf("acquire Foundry access token: %w", tokenErr) + } + req.Header.Set("Authorization", "Bearer "+token.Token) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("send request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return nil, runtime.NewResponseError(resp) + } + + data, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1)) + if err != nil { + return nil, fmt.Errorf("read response body: %w", err) + } + if len(data) > maxResponseBytes { + return nil, fmt.Errorf("response body exceeds %d bytes", maxResponseBytes) + } + if len(bytes.TrimSpace(data)) == 0 { + return json.RawMessage(`{}`), nil + } + + return json.RawMessage(data), nil +} + +func (c *Client) requestURL(apiPath string, query url.Values) (string, error) { + base, err := url.Parse(c.projectEndpoint) + if err != nil { + return "", fmt.Errorf("parse project endpoint: %w", err) + } + + rawPath := path.Join(base.EscapedPath(), "experiment_tracking", apiPath) + decodedPath, err := url.PathUnescape(rawPath) + if err != nil { + return "", fmt.Errorf("decode request path: %w", err) + } + base.Path = decodedPath + base.RawPath = rawPath + values := base.Query() + for key, entries := range query { + for _, value := range entries { + values.Add(key, value) + } + } + values.Set("api-version", c.apiVersion) + base.RawQuery = values.Encode() + return base.String(), nil +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/experimenttracking/client_test.go b/cli/azd/extensions/azure.ai.projects/internal/experimenttracking/client_test.go new file mode 100644 index 00000000000..e535e58b2f6 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/experimenttracking/client_test.go @@ -0,0 +1,202 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package experimenttracking + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type staticCredential struct{} + +func (staticCredential) GetToken( + context.Context, + policy.TokenRequestOptions, +) (azcore.AccessToken, error) { + return azcore.AccessToken{ + Token: "test-token", + ExpiresOn: time.Now().Add(time.Hour), + }, nil +} + +func TestNewClientDerivesProjectAndAccountIDs(t *testing.T) { + client, err := newClient( + "https://sample.services.ai.azure.com/api/projects/my%20project", + "", + "", + staticCredential{}, + "", + http.DefaultClient, + ) + require.NoError(t, err) + assert.Equal(t, "my project", client.ProjectID()) + assert.Equal(t, "sample", client.AccountID()) +} + +func TestNewClientUsesProjectIDOverride(t *testing.T) { + client, err := newClient( + "https://sample.services.ai.azure.com/custom/path", + "override-project", + "2026-01-01-preview", + staticCredential{}, + "", + http.DefaultClient, + ) + require.NoError(t, err) + assert.Equal(t, "override-project", client.ProjectID()) +} + +func TestDoJSONAddsAuthVersionAndRepeatedQueryValues(t *testing.T) { + var gotBody map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) + assert.Equal(t, "/api/projects/project/experiment_tracking/runs/run/system-metrics", r.URL.Path) + assert.Equal(t, []string{"cpu", "memory"}, r.URL.Query()["names"]) + assert.Equal(t, "v1", r.URL.Query().Get("api-version")) + require.NoError(t, json.NewDecoder(r.Body).Decode(&gotBody)) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"ok":true}`) + })) + t.Cleanup(server.Close) + + client, err := newClient( + server.URL+"/api/projects/project", + "", + "", + staticCredential{}, + "", + server.Client(), + ) + require.NoError(t, err) + + response, err := client.DoJSON( + t.Context(), + http.MethodPost, + "runs/run/system-metrics", + map[string][]string{"names": {"cpu", "memory"}}, + nil, + map[string]any{"value": true}, + ) + require.NoError(t, err) + assert.JSONEq(t, `{"ok":true}`, string(response)) + assert.Equal(t, true, gotBody["value"]) +} + +func TestDoJSONReturnsResponseError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(w, `{"error":{"code":"BadFilter","message":"invalid filter"}}`) + })) + t.Cleanup(server.Close) + + client, err := newClient( + server.URL+"/api/projects/project", + "", + "", + staticCredential{}, + "", + server.Client(), + ) + require.NoError(t, err) + + _, err = client.DoJSON(t.Context(), http.MethodGet, "runs", nil, nil, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "BadFilter") +} + +func TestRunHeaders(t *testing.T) { + client, err := newClient( + "https://account.services.ai.azure.com/api/projects/project", + "", + "", + staticCredential{}, + "", + http.DefaultClient, + ) + require.NoError(t, err) + + headers := client.RunHeaders("run-1") + assert.Equal(t, "account", headers.Get("X-WANDB-USERNAME")) + assert.Equal(t, "project", headers.Get("X-Helios-Project-Id")) + assert.Equal(t, "run-1", headers.Get("x-helios-run-id")) +} + +func TestNewClientRejectsInvalidDerivedProjectID(t *testing.T) { + _, err := newClient( + "https://account.services.ai.azure.com/api/projects/a/b", + "", + "", + staticCredential{}, + "", + http.DefaultClient, + ) + require.Error(t, err) + assert.True(t, strings.Contains(err.Error(), "exactly one project ID")) +} + +func TestDoJSONUsesAPIKeyAuthentication(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "", r.Header.Get("Authorization")) + assert.Equal(t, "project-key", r.Header.Get("api-key")) + _, _ = io.WriteString(w, `{"ok":true}`) + })) + t.Cleanup(server.Close) + + client, err := newClient( + server.URL+"/api/projects/project", + "", + "", + nil, + "project-key", + server.Client(), + ) + require.NoError(t, err) + + _, err = client.DoJSON(t.Context(), http.MethodGet, "runs", nil, nil, nil) + require.NoError(t, err) +} + +func TestDoJSONPreservesEscapedPathSegments(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal( + t, + "/api/projects/project/experiment_tracking/runs/run%20one/traces/trace%2Fone", + r.URL.EscapedPath(), + ) + _, _ = io.WriteString(w, `{"ok":true}`) + })) + t.Cleanup(server.Close) + + client, err := newClient( + server.URL+"/api/projects/project", + "", + "", + nil, + "project-key", + server.Client(), + ) + require.NoError(t, err) + + _, err = client.DoJSON( + t.Context(), + http.MethodGet, + "runs/run%20one/traces/trace%2Fone", + nil, + nil, + nil, + ) + require.NoError(t, err) +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/exterrors/codes.go b/cli/azd/extensions/azure.ai.projects/internal/exterrors/codes.go index 97020c74a8e..d3ea95e0f57 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/exterrors/codes.go +++ b/cli/azd/extensions/azure.ai.projects/internal/exterrors/codes.go @@ -20,6 +20,7 @@ const ( CodeOnDiskParametersInvalid = "ondisk_parameters_invalid" CodeOnDiskTemplateMissing = "ondisk_template_missing" CodeArmWhatIfFailed = "arm_what_if_failed" + CodeInvalidExperimentPayload = "invalid_experiment_payload" ) // Error codes commonly used for dependency errors. @@ -33,12 +34,15 @@ const ( CodeMissingAzureSubscription = "missing_azure_subscription_id" CodeMissingAzureLocation = "missing_azure_location" CodeProvisioningServiceNotFound = "provisioning_service_not_found" + CodeExperimentInputReadFailed = "experiment_input_read_failed" ) const ( //nolint:gosec // error code, not a credential CodeCredentialCreationFailed = "credential_creation_failed" + CodeAuthenticationFailed = "authentication_failed" CodeTenantLookupFailed = "tenant_lookup_failed" + CodeExperimentRequestFailed = "experiment_request_failed" ) const ( @@ -53,4 +57,5 @@ const ( OpCognitiveDeploymentDelete = "cognitive_deployment_delete" OpProjectConnectionDelete = "project_connection_delete" OpProjectConnectionGet = "project_connection_get" + OpExperimentRequest = "experiment_request" ) From 4d5ac12758844c49e6e59ef645c14510ec770881 Mon Sep 17 00:00:00 2001 From: HarshaVardhan Babu Namburi Date: Mon, 31 Aug 2026 17:04:05 +0530 Subject: [PATCH 02/13] Fix experiment tracking quality gates Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/extensions/azure.ai.projects/cspell.yaml | 2 ++ .../extensions/azure.ai.projects/internal/cmd/experiment.go | 1 + .../azure.ai.projects/internal/experimenttracking/client.go | 4 ++-- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/cli/azd/extensions/azure.ai.projects/cspell.yaml b/cli/azd/extensions/azure.ai.projects/cspell.yaml index d1bb1ef30cc..8cae8f618fe 100644 --- a/cli/azd/extensions/azure.ai.projects/cspell.yaml +++ b/cli/azd/extensions/azure.ai.projects/cspell.yaml @@ -2,9 +2,11 @@ import: ../../.vscode/cspell.yaml words: - alnum - exterrors + - experimenttracking - foundryproject - idempotently - ondisk - purgeable + - wandb # Contributors - hemarina diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/experiment.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/experiment.go index 41eaa51db3a..2f6db2725fb 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/experiment.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/experiment.go @@ -799,6 +799,7 @@ func readExperimentInput(file string) ([]byte, error) { if file == "-" { reader = os.Stdin } else { + //nolint:gosec // The payload path is explicitly supplied by the user. opened, err := os.Open(file) if err != nil { return nil, exterrors.Dependency( diff --git a/cli/azd/extensions/azure.ai.projects/internal/experimenttracking/client.go b/cli/azd/extensions/azure.ai.projects/internal/experimenttracking/client.go index 56effe88b21..ef516807605 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/experimenttracking/client.go +++ b/cli/azd/extensions/azure.ai.projects/internal/experimenttracking/client.go @@ -24,7 +24,7 @@ import ( const ( defaultAPIVersion = "v1" - tokenScope = "https://ai.azure.com/.default" + foundryScope = "https://ai.azure.com/.default" maxResponseBytes = 64 << 20 defaultTimeout = 30 * time.Second ) @@ -235,7 +235,7 @@ func (c *Client) do( req.Header.Set("api-key", c.apiKey) } else { token, tokenErr := c.credential.GetToken(ctx, policy.TokenRequestOptions{ - Scopes: []string{tokenScope}, + Scopes: []string{foundryScope}, }) if tokenErr != nil { return nil, fmt.Errorf("acquire Foundry access token: %w", tokenErr) From d0c93e61b2e04f8401c0731e3d91af7634478262 Mon Sep 17 00:00:00 2001 From: HarshaVardhan Babu Namburi Date: Tue, 1 Sep 2026 07:42:35 +0530 Subject: [PATCH 03/13] Address experiment tracking review comments Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/docs/environment-variables.md | 4 +- .../internal/cmd/experiment.go | 32 ++++++++++++++- .../internal/cmd/experiment_test.go | 40 +++++++++++++++++++ 3 files changed, 72 insertions(+), 4 deletions(-) diff --git a/cli/azd/docs/environment-variables.md b/cli/azd/docs/environment-variables.md index 690ae52d1e1..82cc0e1d43a 100644 --- a/cli/azd/docs/environment-variables.md +++ b/cli/azd/docs/environment-variables.md @@ -196,13 +196,13 @@ Metadata requests are unauthenticated when no matching token is set. > **Note**: These variables are defined and consumed by individual azd extensions. As the extension > ecosystem grows, extension-specific variables may move to each extension's own documentation. -### azure.ai.agents +### azure.ai.projects and azure.ai.agents | Variable | Description | | --- | --- | | `AZURE_AI_PROJECT_ID` | The Microsoft Foundry project resource ID used by the `azure.ai.agents` extension. | | `AZURE_AI_PROJECT_API_KEY` | A Microsoft Foundry account API key accepted by the project data plane and used by `azure.ai.projects` experiment-tracking commands. When set in the host process, it takes precedence over bearer authentication. Do not persist this value in project files or source control. | -| `FOUNDRY_PROJECT_ENDPOINT` | The Microsoft Foundry project endpoint used by the `azure.ai.agents` extension. Read first from the active azd environment and, if not present, from the host shell environment as an endpoint-resolution fallback. | +| `FOUNDRY_PROJECT_ENDPOINT` | The Microsoft Foundry project endpoint used by the `azure.ai.projects` endpoint resolver and the `azure.ai.agents` extension. The projects resolver checks the active azd environment before global project configuration and uses the host shell environment as its final endpoint fallback. | | `AZURE_AI_PROJECT_PRINCIPAL_ID` | The principal ID associated with the Microsoft Foundry project identity. | | `AZURE_AI_ACCOUNT_NAME` | The Microsoft Foundry account name associated with the project. | | `AZURE_AI_PROJECT_NAME` | The Microsoft Foundry project name. | diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/experiment.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/experiment.go index 2f6db2725fb..3e123825cb5 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/experiment.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/experiment.go @@ -7,6 +7,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -449,7 +450,7 @@ func newOTLPIngestCommand(extCtx *azdext.ExtensionContext, signal string) *cobra if err := requireValues(map[string]string{"run-id": flags.runID, "file": file}); err != nil { return err } - payload, err := readExperimentInput(file) + payload, err := readNonEmptyExperimentInput(file) if err != nil { return err } @@ -779,21 +780,48 @@ func readJSONObject(file string) (map[string]any, error) { return nil, err } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() var object map[string]any - if err := json.Unmarshal(data, &object); err != nil { + if err := decoder.Decode(&object); err != nil { return nil, invalidExperimentPayload(fmt.Sprintf("parse JSON object: %s", err)) } if object == nil { return nil, invalidExperimentPayload("payload must be a JSON object") } + if err := ensureJSONDocumentEnd(decoder); err != nil { + return nil, invalidExperimentPayload(fmt.Sprintf("parse JSON object: %s", err)) + } return object, nil } +func ensureJSONDocumentEnd(decoder *json.Decoder) error { + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + if err == nil { + return errors.New("payload must contain exactly one JSON object") + } + return err + } + return nil +} + func isJSONObject(data []byte) bool { var object map[string]any return json.Unmarshal(data, &object) == nil && object != nil } +func readNonEmptyExperimentInput(file string) ([]byte, error) { + data, err := readExperimentInput(file) + if err != nil { + return nil, err + } + if len(data) == 0 { + return nil, invalidExperimentPayload("payload must not be empty") + } + return data, nil +} + func readExperimentInput(file string) ([]byte, error) { var reader io.Reader if file == "-" { diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/experiment_test.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/experiment_test.go index 3a0fd57e4ac..4a84223bad0 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/experiment_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/experiment_test.go @@ -116,6 +116,46 @@ func TestBuildSpanQueryBody(t *testing.T) { }`, string(data)) } +func TestReadJSONObjectPreservesLargeInteger(t *testing.T) { + requestPath := filepath.Join(t.TempDir(), "request.json") + require.NoError(t, os.WriteFile( + requestPath, + []byte(`{"id":9007199254740993}`), + 0o600, + )) + + body, err := readJSONObject(requestPath) + require.NoError(t, err) + + id, ok := body["id"].(json.Number) + require.True(t, ok) + assert.Equal(t, "9007199254740993", id.String()) + + encoded, err := json.Marshal(body) + require.NoError(t, err) + assert.JSONEq(t, `{"id":9007199254740993}`, string(encoded)) +} + +func TestReadJSONObjectRejectsMultipleValues(t *testing.T) { + requestPath := filepath.Join(t.TempDir(), "request.json") + require.NoError(t, os.WriteFile( + requestPath, + []byte(`{"first":true} {"second":true}`), + 0o600, + )) + + _, err := readJSONObject(requestPath) + require.Error(t, err) +} + +func TestReadNonEmptyExperimentInputRejectsEmptyPayload(t *testing.T) { + payloadPath := filepath.Join(t.TempDir(), "payload.pb") + require.NoError(t, os.WriteFile(payloadPath, nil, 0o600)) + + _, err := readNonEmptyExperimentInput(payloadPath) + require.Error(t, err) +} + func TestExperimentCommandsUseJSONOutput(t *testing.T) { commands := []*cobra.Command{ newRunListCommand(nil), From 5303a9e5770ac33cc72d0bc97627c6602c15a62d Mon Sep 17 00:00:00 2001 From: HarshaVardhan Babu Namburi Date: Tue, 1 Sep 2026 13:04:51 +0530 Subject: [PATCH 04/13] Add Foundry Loom extension Move experiment tracking commands under azd ai loom run while preserving the existing azure.ai.projects command surface. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/CODEOWNERS | 1 + .github/workflows/lint-ext-azure-ai-loom.yml | 22 ++ cli/azd/docs/environment-variables.md | 6 +- cli/azd/extensions/azure.ai.loom/.gitignore | 28 ++ .../extensions/azure.ai.loom/.golangci.yaml | 17 + cli/azd/extensions/azure.ai.loom/CHANGELOG.md | 8 + cli/azd/extensions/azure.ai.loom/README.md | 108 ++++++ cli/azd/extensions/azure.ai.loom/build.ps1 | 78 +++++ cli/azd/extensions/azure.ai.loom/build.sh | 66 ++++ cli/azd/extensions/azure.ai.loom/ci-build.ps1 | 80 +++++ cli/azd/extensions/azure.ai.loom/ci-test.ps1 | 25 ++ cli/azd/extensions/azure.ai.loom/cspell.yaml | 7 + .../extensions/azure.ai.loom/extension.yaml | 25 ++ cli/azd/extensions/azure.ai.loom/go.mod | 104 ++++++ cli/azd/extensions/azure.ai.loom/go.sum | 314 ++++++++++++++++++ .../azure.ai.loom/internal/cmd/metadata.go | 15 + .../internal/cmd/project_endpoint.go | 66 ++++ .../internal/cmd/project_resolver.go | 108 ++++++ .../internal/cmd/project_resolver_test.go | 52 +++ .../azure.ai.loom/internal/cmd/root.go | 31 ++ .../internal/cmd/run.go} | 15 +- .../internal/cmd/run_test.go} | 34 +- .../azure.ai.loom/internal/cmd/version.go | 20 ++ .../internal/experimenttracking/client.go | 0 .../experimenttracking/client_test.go | 0 .../azure.ai.loom/internal/exterrors/codes.go | 16 + .../internal/exterrors/errors.go | 73 ++++ cli/azd/extensions/azure.ai.loom/main.go | 14 + cli/azd/extensions/azure.ai.loom/version.txt | 1 + .../extensions/azure.ai.projects/README.md | 105 ------ .../extensions/azure.ai.projects/cspell.yaml | 2 - .../azure.ai.projects/internal/cmd/root.go | 3 - .../internal/exterrors/codes.go | 4 - eng/pipelines/release-ext-azure-ai-loom.yml | 49 +++ 34 files changed, 1369 insertions(+), 128 deletions(-) create mode 100644 .github/workflows/lint-ext-azure-ai-loom.yml create mode 100644 cli/azd/extensions/azure.ai.loom/.gitignore create mode 100644 cli/azd/extensions/azure.ai.loom/.golangci.yaml create mode 100644 cli/azd/extensions/azure.ai.loom/CHANGELOG.md create mode 100644 cli/azd/extensions/azure.ai.loom/README.md create mode 100644 cli/azd/extensions/azure.ai.loom/build.ps1 create mode 100644 cli/azd/extensions/azure.ai.loom/build.sh create mode 100644 cli/azd/extensions/azure.ai.loom/ci-build.ps1 create mode 100644 cli/azd/extensions/azure.ai.loom/ci-test.ps1 create mode 100644 cli/azd/extensions/azure.ai.loom/cspell.yaml create mode 100644 cli/azd/extensions/azure.ai.loom/extension.yaml create mode 100644 cli/azd/extensions/azure.ai.loom/go.mod create mode 100644 cli/azd/extensions/azure.ai.loom/go.sum create mode 100644 cli/azd/extensions/azure.ai.loom/internal/cmd/metadata.go create mode 100644 cli/azd/extensions/azure.ai.loom/internal/cmd/project_endpoint.go create mode 100644 cli/azd/extensions/azure.ai.loom/internal/cmd/project_resolver.go create mode 100644 cli/azd/extensions/azure.ai.loom/internal/cmd/project_resolver_test.go create mode 100644 cli/azd/extensions/azure.ai.loom/internal/cmd/root.go rename cli/azd/extensions/{azure.ai.projects/internal/cmd/experiment.go => azure.ai.loom/internal/cmd/run.go} (98%) rename cli/azd/extensions/{azure.ai.projects/internal/cmd/experiment_test.go => azure.ai.loom/internal/cmd/run_test.go} (81%) create mode 100644 cli/azd/extensions/azure.ai.loom/internal/cmd/version.go rename cli/azd/extensions/{azure.ai.projects => azure.ai.loom}/internal/experimenttracking/client.go (100%) rename cli/azd/extensions/{azure.ai.projects => azure.ai.loom}/internal/experimenttracking/client_test.go (100%) create mode 100644 cli/azd/extensions/azure.ai.loom/internal/exterrors/codes.go create mode 100644 cli/azd/extensions/azure.ai.loom/internal/exterrors/errors.go create mode 100644 cli/azd/extensions/azure.ai.loom/main.go create mode 100644 cli/azd/extensions/azure.ai.loom/version.txt create mode 100644 eng/pipelines/release-ext-azure-ai-loom.yml diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3cf4f011a00..59a8b4e4404 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -21,6 +21,7 @@ /cli/azd/extensions/azure.ai.connections/ @JeffreyCA @glharper @trangevi @trrwilson @therealjohn @huimiu @hund030 @m5i-work @v1212 /cli/azd/extensions/azure.ai.finetune/ @JeffreyCA @trangevi @achauhan-scc @kingernupur @saanikaguptamicrosoft /cli/azd/extensions/azure.ai.inspector/ @JeffreyCA @glharper @trangevi @trrwilson @therealjohn @anchenyi @XiaofuHuang +/cli/azd/extensions/azure.ai.loom/ @JeffreyCA @glharper @trangevi @trrwilson @therealjohn @huimiu @hund030 @m5i-work @v1212 /cli/azd/extensions/azure.ai.models/ @JeffreyCA @trangevi @achauhan-scc @kingernupur @saanikaguptamicrosoft /cli/azd/extensions/azure.ai.projects/ @JeffreyCA @glharper @trangevi @trrwilson @therealjohn @huimiu @hund030 @m5i-work @v1212 /cli/azd/extensions/azure.ai.rle/ @JeffreyCA @glharper @trangevi @trrwilson @therealjohn @huimiu @hund030 @m5i-work @v1212 diff --git a/.github/workflows/lint-ext-azure-ai-loom.yml b/.github/workflows/lint-ext-azure-ai-loom.yml new file mode 100644 index 00000000000..43e804c8d95 --- /dev/null +++ b/.github/workflows/lint-ext-azure-ai-loom.yml @@ -0,0 +1,22 @@ +name: ext-azure-ai-loom-ci + +on: + pull_request: + paths: + - "cli/azd/extensions/azure.ai.loom/**" + - ".github/workflows/lint-ext-azure-ai-loom.yml" + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +jobs: + lint: + uses: ./.github/workflows/lint-go.yml + with: + working-directory: cli/azd/extensions/azure.ai.loom diff --git a/cli/azd/docs/environment-variables.md b/cli/azd/docs/environment-variables.md index 82cc0e1d43a..acfa066d2a3 100644 --- a/cli/azd/docs/environment-variables.md +++ b/cli/azd/docs/environment-variables.md @@ -196,13 +196,13 @@ Metadata requests are unauthenticated when no matching token is set. > **Note**: These variables are defined and consumed by individual azd extensions. As the extension > ecosystem grows, extension-specific variables may move to each extension's own documentation. -### azure.ai.projects and azure.ai.agents +### Microsoft Foundry extensions | Variable | Description | | --- | --- | | `AZURE_AI_PROJECT_ID` | The Microsoft Foundry project resource ID used by the `azure.ai.agents` extension. | -| `AZURE_AI_PROJECT_API_KEY` | A Microsoft Foundry account API key accepted by the project data plane and used by `azure.ai.projects` experiment-tracking commands. When set in the host process, it takes precedence over bearer authentication. Do not persist this value in project files or source control. | -| `FOUNDRY_PROJECT_ENDPOINT` | The Microsoft Foundry project endpoint used by the `azure.ai.projects` endpoint resolver and the `azure.ai.agents` extension. The projects resolver checks the active azd environment before global project configuration and uses the host shell environment as its final endpoint fallback. | +| `AZURE_AI_PROJECT_API_KEY` | A Microsoft Foundry account API key accepted by the project data plane and used by `azure.ai.loom` experiment-tracking commands. When set in the host process, it takes precedence over bearer authentication. Do not persist this value in project files or source control. | +| `FOUNDRY_PROJECT_ENDPOINT` | The Microsoft Foundry project endpoint used by `azure.ai.projects`, `azure.ai.loom`, and `azure.ai.agents`. The projects and Loom resolvers check the active azd environment before global project configuration and use the host shell environment as their final endpoint fallback. | | `AZURE_AI_PROJECT_PRINCIPAL_ID` | The principal ID associated with the Microsoft Foundry project identity. | | `AZURE_AI_ACCOUNT_NAME` | The Microsoft Foundry account name associated with the project. | | `AZURE_AI_PROJECT_NAME` | The Microsoft Foundry project name. | diff --git a/cli/azd/extensions/azure.ai.loom/.gitignore b/cli/azd/extensions/azure.ai.loom/.gitignore new file mode 100644 index 00000000000..b629bbb1f4a --- /dev/null +++ b/cli/azd/extensions/azure.ai.loom/.gitignore @@ -0,0 +1,28 @@ +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Build output directory +bin/ + +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work +go.work.sum + +# env file +.env diff --git a/cli/azd/extensions/azure.ai.loom/.golangci.yaml b/cli/azd/extensions/azure.ai.loom/.golangci.yaml new file mode 100644 index 00000000000..b88a74c6a0b --- /dev/null +++ b/cli/azd/extensions/azure.ai.loom/.golangci.yaml @@ -0,0 +1,17 @@ +version: "2" + +linters: + default: none + enable: + - gosec + - lll + - unused + - errorlint + settings: + lll: + line-length: 220 + tab-width: 4 + +formatters: + enable: + - gofmt diff --git a/cli/azd/extensions/azure.ai.loom/CHANGELOG.md b/cli/azd/extensions/azure.ai.loom/CHANGELOG.md new file mode 100644 index 00000000000..88eec57b211 --- /dev/null +++ b/cli/azd/extensions/azure.ai.loom/CHANGELOG.md @@ -0,0 +1,8 @@ +# Release History + +## 0.1.0-preview (Unreleased) + +### Features Added + +- Added commands under `azd ai loom run` for Foundry experiment runs, traces, + spans, OpenTelemetry ingestion, and W&B-compatible APIs. diff --git a/cli/azd/extensions/azure.ai.loom/README.md b/cli/azd/extensions/azure.ai.loom/README.md new file mode 100644 index 00000000000..96aabc7b673 --- /dev/null +++ b/cli/azd/extensions/azure.ai.loom/README.md @@ -0,0 +1,108 @@ +# Foundry Loom + +Inspect and ingest Microsoft Foundry experiment-tracking data from `azd`. (Preview) + +## Installation + +```sh +azd extension install azure.ai.loom +``` + +The extension can reuse the project endpoint persisted by `azure.ai.projects`, +but it can also run independently with `--project-endpoint` or an environment +variable. + +## Authentication and project resolution + +Commands authenticate through `azd auth login` using the +`https://ai.azure.com/.default` scope. To use a Foundry account API key accepted +by the project data plane, set `AZURE_AI_PROJECT_API_KEY` in the current process. +The API key takes precedence over bearer authentication and must not be stored +in project files or source control. + +The project endpoint is resolved in this order: + +1. `--project-endpoint` +2. `FOUNDRY_PROJECT_ENDPOINT` or `AZURE_AI_PROJECT_ENDPOINT` in the active azd environment +3. The endpoint saved by `azd ai project set ` +4. `FOUNDRY_PROJECT_ENDPOINT` in the host shell + +The project ID is derived from `/api/projects/` in the endpoint. Use +`--project-id` only when an API-compatible endpoint requires an override. + +## Commands + +### Inspect runs + +```sh +azd ai loom run list +azd ai loom run history-keys --run-id +azd ai loom run summary --run-id +azd ai loom run metrics --run-id +azd ai loom run system-metrics --run-id --name system/cpu +azd ai loom run logs --run-id +azd ai loom run log-records --run-id +azd ai loom run compare \ + --run-id --run-id \ + --metric loss --min 0 --max 100 +``` + +### Traces and spans + +```sh +azd ai loom run trace list --run-id +azd ai loom run trace show --run-id --trace-id +azd ai loom run trace chat --run-id --trace-id +azd ai loom run span query \ + --run-id \ + --filter-file ./span-filter.json \ + --include-details \ + --limit 10 +``` + +A span filter contains the query expression only: + +```json +{ + "$expr": { + "$eq": [ + { "$getField": "span_name" }, + { "$literal": "chat" } + ] + } +} +``` + +When no filter is provided, the command uses `{"$expr":true}`. Use +`--request-file` to send a complete span-query or trace-chat request body. + +### Ingest OpenTelemetry data + +```sh +azd ai loom run ingest metrics --run-id --file ./metrics.pb +azd ai loom run ingest logs --run-id --file ./logs.pb +azd ai loom run ingest traces --run-id --file ./traces.pb +azd ai loom run ingest agent-traces --run-id --file ./agent-traces.json +``` + +The OTLP metrics, logs, and traces commands require binary protobuf payloads. +Agent traces require JSON. Use `--file -` to read from stdin. Empty payloads are +rejected before a service request is made. + +### W&B compatibility + +```sh +azd ai loom run wandb graphql --file ./graphql-request.json +azd ai loom run wandb file-stream \ + --run-id \ + --file ./file-stream-request.json +``` + +All commands emit complete JSON responses for automation. + +## Development + +```sh +azd x build +go test ./... -count=1 +``` diff --git a/cli/azd/extensions/azure.ai.loom/build.ps1 b/cli/azd/extensions/azure.ai.loom/build.ps1 new file mode 100644 index 00000000000..5ceb60a8bbc --- /dev/null +++ b/cli/azd/extensions/azure.ai.loom/build.ps1 @@ -0,0 +1,78 @@ +# Ensure script fails on any error +$ErrorActionPreference = 'Stop' + +# Get the directory of the script +$EXTENSION_DIR = Split-Path -Parent $MyInvocation.MyCommand.Path + +# Change to the script directory +Set-Location -Path $EXTENSION_DIR + +# Create a safe version of EXTENSION_ID replacing dots with dashes +$EXTENSION_ID_SAFE = $env:EXTENSION_ID -replace '\.', '-' + +# Define output directory +$OUTPUT_DIR = if ($env:OUTPUT_DIR) { $env:OUTPUT_DIR } else { Join-Path $EXTENSION_DIR "bin" } + +# Create output directory if it doesn't exist +if (-not (Test-Path -Path $OUTPUT_DIR)) { + New-Item -ItemType Directory -Path $OUTPUT_DIR | Out-Null +} + +# Get Git commit hash and build date +$COMMIT = git rev-parse HEAD +if ($LASTEXITCODE -ne 0) { + Write-Host "Error: Failed to get git commit hash" + exit 1 +} +$BUILD_DATE = (Get-Date -Format "yyyy-MM-ddTHH:mm:ssZ") + +# List of OS and architecture combinations +if ($env:EXTENSION_PLATFORM) { + $PLATFORMS = @($env:EXTENSION_PLATFORM) +} +else { + $PLATFORMS = @( + "windows/amd64", + "windows/arm64", + "darwin/amd64", + "darwin/arm64", + "linux/amd64", + "linux/arm64" + ) +} + +$APP_PATH = "$env:EXTENSION_ID/internal/cmd" + +# Loop through platforms and build +foreach ($PLATFORM in $PLATFORMS) { + $OS, $ARCH = $PLATFORM -split '/' + + $OUTPUT_NAME = Join-Path $OUTPUT_DIR "$EXTENSION_ID_SAFE-$OS-$ARCH" + + if ($OS -eq "windows") { + $OUTPUT_NAME += ".exe" + } + + Write-Host "Building for $OS/$ARCH..." + + # Delete the output file if it already exists + if (Test-Path -Path $OUTPUT_NAME) { + Remove-Item -Path $OUTPUT_NAME -Force + } + + # Set environment variables for Go build + $env:GOOS = $OS + $env:GOARCH = $ARCH + + go build ` + -ldflags="-X '$APP_PATH.Version=$env:EXTENSION_VERSION' -X '$APP_PATH.Commit=$COMMIT' -X '$APP_PATH.BuildDate=$BUILD_DATE'" ` + -o $OUTPUT_NAME + + if ($LASTEXITCODE -ne 0) { + Write-Host "An error occurred while building for $OS/$ARCH" + exit 1 + } +} + +Write-Host "Build completed successfully!" +Write-Host "Binaries are located in the $OUTPUT_DIR directory." diff --git a/cli/azd/extensions/azure.ai.loom/build.sh b/cli/azd/extensions/azure.ai.loom/build.sh new file mode 100644 index 00000000000..f1a995ec5e9 --- /dev/null +++ b/cli/azd/extensions/azure.ai.loom/build.sh @@ -0,0 +1,66 @@ +#!/bin/bash + +# Get the directory of the script +EXTENSION_DIR="$(cd "$(dirname "$0")" && pwd)" + +# Change to the script directory +cd "$EXTENSION_DIR" || exit + +# Create a safe version of EXTENSION_ID replacing dots with dashes +EXTENSION_ID_SAFE="${EXTENSION_ID//./-}" + +# Define output directory +OUTPUT_DIR="${OUTPUT_DIR:-$EXTENSION_DIR/bin}" + +# Create output and target directories if they don't exist +mkdir -p "$OUTPUT_DIR" + +# Get Git commit hash and build date +COMMIT=$(git rev-parse HEAD) +BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) + +# List of OS and architecture combinations +if [ -n "$EXTENSION_PLATFORM" ]; then + PLATFORMS=("$EXTENSION_PLATFORM") +else + PLATFORMS=( + "windows/amd64" + "windows/arm64" + "darwin/amd64" + "darwin/arm64" + "linux/amd64" + "linux/arm64" + ) +fi + +APP_PATH="$EXTENSION_ID/internal/cmd" + +# Loop through platforms and build +for PLATFORM in "${PLATFORMS[@]}"; do + OS=$(echo "$PLATFORM" | cut -d'/' -f1) + ARCH=$(echo "$PLATFORM" | cut -d'/' -f2) + + OUTPUT_NAME="$OUTPUT_DIR/$EXTENSION_ID_SAFE-$OS-$ARCH" + + if [ "$OS" = "windows" ]; then + OUTPUT_NAME+='.exe' + fi + + echo "Building for $OS/$ARCH..." + + # Delete the output file if it already exists + [ -f "$OUTPUT_NAME" ] && rm -f "$OUTPUT_NAME" + + # Set environment variables for Go build + GOOS=$OS GOARCH=$ARCH go build \ + -ldflags="-X '$APP_PATH.Version=$EXTENSION_VERSION' -X '$APP_PATH.Commit=$COMMIT' -X '$APP_PATH.BuildDate=$BUILD_DATE'" \ + -o "$OUTPUT_NAME" + + if [ $? -ne 0 ]; then + echo "An error occurred while building for $OS/$ARCH" + exit 1 + fi +done + +echo "Build completed successfully!" +echo "Binaries are located in the $OUTPUT_DIR directory." diff --git a/cli/azd/extensions/azure.ai.loom/ci-build.ps1 b/cli/azd/extensions/azure.ai.loom/ci-build.ps1 new file mode 100644 index 00000000000..f29fa5e5171 --- /dev/null +++ b/cli/azd/extensions/azure.ai.loom/ci-build.ps1 @@ -0,0 +1,80 @@ +param( + [string] $Version = (Get-Content "$PSScriptRoot/version.txt"), + [string] $SourceVersion = (git rev-parse HEAD), + [switch] $CodeCoverageEnabled, + [switch] $BuildRecordMode, + [string] $MSYS2Shell, # path to msys2_shell.cmd + [string] $OutputFileName +) + +$PSNativeCommandArgumentPassing = 'Legacy' + +go clean +if ($LASTEXITCODE) { + Write-Host "Error running go clean" + exit $LASTEXITCODE +} + +$buildFlags = @( + "-trimpath", + "-buildmode=pie" +) + +if ($CodeCoverageEnabled) { + $buildFlags += "-cover" +} + +$buildFlags += @( + "-tags=cfi,cfg,osusergo", + "-ldflags=-s -w -X azure.ai.loom/internal/cmd.Version=$Version -X azure.ai.loom/internal/cmd.Commit=$SourceVersion -X azure.ai.loom/internal/cmd.BuildDate=$(Get-Date -Format o) ", + "-o=$OutputFileName" +) + +function PrintFlags() { + foreach ($buildFlag in $buildFlags) { + Write-Host " $buildFlag" + } +} + +$oldGOEXPERIMENT = $env:GOEXPERIMENT +$env:GOEXPERIMENT = "loopvar" + +try { + Write-Host "Running: go build" + PrintFlags + go build @buildFlags + if ($LASTEXITCODE) { + Write-Host "Error running go build" + exit $LASTEXITCODE + } + + if ($BuildRecordMode) { + # Modify build tags to include record + $recordTagPatched = $false + for ($i = 0; $i -lt $buildFlags.Length; $i++) { + if ($buildFlags[$i].StartsWith("-tags=")) { + $buildFlags[$i] += ",record" + $recordTagPatched = $true + } + } + if (-not $recordTagPatched) { + $buildFlags += "-tags=record" + } + $recordOutput = "-o=$OutputFileName-record" + if ($IsWindows) { $recordOutput += ".exe" } + $buildFlags += $recordOutput + + Write-Host "Running: go build (record)" + PrintFlags + go build @buildFlags + if ($LASTEXITCODE) { + Write-Host "Error running go build (record)" + exit $LASTEXITCODE + } + } + + Write-Host "go build succeeded" +} +finally { + $env:GOEXPERIMENT = $oldGOEXPERIMENT +} diff --git a/cli/azd/extensions/azure.ai.loom/ci-test.ps1 b/cli/azd/extensions/azure.ai.loom/ci-test.ps1 new file mode 100644 index 00000000000..347e1b6e107 --- /dev/null +++ b/cli/azd/extensions/azure.ai.loom/ci-test.ps1 @@ -0,0 +1,25 @@ +$gopath = go env GOPATH +$gotestsumBinary = "gotestsum" +if ($IsWindows) { + $gotestsumBinary += ".exe" +} +$gotestsum = Join-Path $gopath "bin" $gotestsumBinary + +Write-Host "Running unit tests..." + +if (Test-Path $gotestsum) { + & $gotestsum --format testname -- ./... -count=1 +} else { + Write-Host "gotestsum not found, using go test..." -ForegroundColor Yellow + go test ./... -v -count=1 +} + +if ($LASTEXITCODE -ne 0) { + Write-Host "" + Write-Host "Tests failed with exit code: $LASTEXITCODE" -ForegroundColor Red + exit $LASTEXITCODE +} + +Write-Host "" +Write-Host "All tests passed!" -ForegroundColor Green +exit 0 diff --git a/cli/azd/extensions/azure.ai.loom/cspell.yaml b/cli/azd/extensions/azure.ai.loom/cspell.yaml new file mode 100644 index 00000000000..e937ce3d320 --- /dev/null +++ b/cli/azd/extensions/azure.ai.loom/cspell.yaml @@ -0,0 +1,7 @@ +import: ../../.vscode/cspell.yaml +words: + - exterrors + - experimenttracking + - filestream + - opentelemetry + - wandb diff --git a/cli/azd/extensions/azure.ai.loom/extension.yaml b/cli/azd/extensions/azure.ai.loom/extension.yaml new file mode 100644 index 00000000000..f9faa5bcbc3 --- /dev/null +++ b/cli/azd/extensions/azure.ai.loom/extension.yaml @@ -0,0 +1,25 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/refs/heads/main/cli/azd/extensions/extension.schema.json +capabilities: + - custom-commands + - metadata +description: Inspect and ingest Microsoft Foundry experiment-tracking data. (Preview) +displayName: Foundry Loom (Preview) +id: azure.ai.loom +language: go +namespace: ai.loom +tags: + - ai + - loom +usage: azd ai loom [options] +version: 0.1.0-preview +requiredAzdVersion: ">=1.32.0" +examples: + - name: list runs + description: List experiment-tracking runs. + usage: azd ai loom run list + - name: query spans + description: Query spans for an experiment-tracking run. + usage: azd ai loom run span query --run-id + - name: ingest traces + description: Ingest an OTLP protobuf trace payload. + usage: azd ai loom run ingest traces --run-id --file ./traces.pb diff --git a/cli/azd/extensions/azure.ai.loom/go.mod b/cli/azd/extensions/azure.ai.loom/go.mod new file mode 100644 index 00000000000..1d3f52b3b2f --- /dev/null +++ b/cli/azd/extensions/azure.ai.loom/go.mod @@ -0,0 +1,104 @@ +module azure.ai.loom + +go 1.26.4 + +require ( + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 + github.com/azure/azure-dev/cli/azd v1.32.0 + github.com/spf13/cobra v1.10.1 + github.com/stretchr/testify v1.11.1 + google.golang.org/grpc v1.82.1 +) + +require ( + dario.cat/mergo v1.0.2 // indirect + github.com/AlecAivazis/survey/v2 v2.3.7 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect + github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect + github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/adam-lavrik/go-imath v0.0.0-20210910152346-265a42a96f0b // indirect + github.com/alecthomas/chroma/v2 v2.20.0 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/blang/semver/v4 v4.0.0 // indirect + github.com/braydonk/yaml v0.9.0 // indirect + github.com/buger/goterm v1.0.4 // indirect + github.com/buger/jsonparser v1.1.2 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/colorprofile v0.3.2 // indirect + github.com/charmbracelet/glamour v0.10.0 // indirect + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect + github.com/charmbracelet/x/ansi v0.10.2 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20251008171431-5d3777519489 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/cli/browser v1.3.0 // indirect + github.com/clipperhouse/uax29/v2 v2.2.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/drone/envsubst v1.0.3 // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/gofrs/flock v0.12.1 // indirect + github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/golobby/container/v3 v3.3.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/css v1.0.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/invopop/jsonschema v0.13.0 // indirect + github.com/jmespath-community/go-jmespath v1.1.1 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mailru/easyjson v0.9.1 // indirect + github.com/mark3labs/mcp-go v0.41.1 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect + github.com/microsoft/ApplicationInsights-Go v0.4.4 // indirect + github.com/microsoft/go-deviceid v1.0.0 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/nathan-fiscaletti/consolesize-go v0.0.0-20220204101620-317176b6684d // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/theckman/yacspin v0.13.12 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + github.com/yuin/goldmark v1.7.13 // indirect + github.com/yuin/goldmark-emoji v1.0.6 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/sdk v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/exp v0.0.0-20250911091902-df9299821621 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + 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 + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/cli/azd/extensions/azure.ai.loom/go.sum b/cli/azd/extensions/azure.ai.loom/go.sum new file mode 100644 index 00000000000..a84edd2f3ee --- /dev/null +++ b/cli/azd/extensions/azure.ai.loom/go.sum @@ -0,0 +1,314 @@ +code.cloudfoundry.org/clock v0.0.0-20180518195852-02e53af36e6c/go.mod h1:QD9Lzhd/ux6eNQVUDVRJX/RKTigpewimNYBi7ivZKY8= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= +github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 h1:JXg2dwJUmPB9JmtVmdEB16APJ7jurfbY5jnfXpJoRMc= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0 h1:PTFGRSlMKCQelWwxUyYVEUqseBJVemLyqWJjvMyt0do= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0/go.mod h1:LRr2FzBTQlONPPa5HREE5+RjSCTXl7BwOvYOaWTqCaI= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0 h1:2qsIIvxVT+uE6yrNldntJKlLRgxGbZ85kgtz5SNBhMw= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0/go.mod h1:AW8VEadnhw9xox+VaVd9sP7NjzOAnaZBLRH6Tq3cJ38= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0 h1:nnQ9vXH039UrEFxi08pPuZBE7VfqSJt343uJLw0rhWI= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0/go.mod h1:4YIVtzMFVsPwBvitCDX7J9sqthSj43QD1sP6fYc1egc= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 h1:Dd+RhdJn0OTtVGaeDLZpcumkIVCtA/3/Fo42+eoYvVM= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0/go.mod h1:5kakwfW5CjC9KK+Q4wjXAg+ShuIm2mBMua0ZFj2C8PE= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0 h1:wxQx2Bt4xzPIKvW59WQf1tJNx/ZZKPfN+EhPX3Z6CYY= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0/go.mod h1:TpiwjwnW/khS0LKs4vW5UmmT9OWcxaveS8U7+tlknzo= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0 h1:/g8S6wk65vfC6m3FIxJ+i5QDyN9JWwXI8Hb0Img10hU= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0/go.mod h1:gpl+q95AzZlKVI3xSoseF9QPrypk0hQqBiJYeB/cR/I= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= +github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= +github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= +github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= +github.com/adam-lavrik/go-imath v0.0.0-20210910152346-265a42a96f0b h1:g9SuFmxM/WucQFKTMSP+irxyf5m0RiUJreBDhGI6jSA= +github.com/adam-lavrik/go-imath v0.0.0-20210910152346-265a42a96f0b/go.mod h1:XjvqMUpGd3Xn9Jtzk/4GEBCSoBX0eB2RyriXgne0IdM= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= +github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= +github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg= +github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/azure/azure-dev/cli/azd v1.32.0 h1:uhe7yvCHYEqd4qPs4PXwzXsjWb395oljFY87uzCGj+c= +github.com/azure/azure-dev/cli/azd v1.32.0/go.mod h1:4iHvNefTWxdFoIJWPc52E00EDWtgU0j7MfFyjqwNKaw= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= +github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M= +github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0= +github.com/braydonk/yaml v0.9.0 h1:ewGMrVmEVpsm3VwXQDR388sLg5+aQ8Yihp6/hc4m+h4= +github.com/braydonk/yaml v0.9.0/go.mod h1:hcm3h581tudlirk8XEUPDBAimBPbmnL0Y45hCRl47N4= +github.com/buger/goterm v1.0.4 h1:Z9YvGmOih81P0FbVtEYTFF6YsSgxSUKEhf/f9bTMXbY= +github.com/buger/goterm v1.0.4/go.mod h1:HiFWV3xnkolgrBV3mY8m0X0Pumt4zg4QhbdOzQtB8tE= +github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= +github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI= +github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI= +github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY= +github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= +github.com/charmbracelet/x/ansi v0.10.2 h1:ith2ArZS0CJG30cIUfID1LXN7ZFXRCww6RUvAPA+Pzw= +github.com/charmbracelet/x/ansi v0.10.2/go.mod h1:HbLdJjQH4UH4AqA2HpRWuWNluRE6zxJH/yteYEYCFa8= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= +github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/slice v0.0.0-20251008171431-5d3777519489 h1:a5q2sWiet6kgqucSGjYN1jhT2cn4bMKUwprtm2IGRto= +github.com/charmbracelet/x/exp/slice v0.0.0-20251008171431-5d3777519489/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo= +github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk= +github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= +github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= +github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/drone/envsubst v1.0.3 h1:PCIBwNDYjs50AsLZPYdfhSATKaRg/FJmDc2D6+C2x8g= +github.com/drone/envsubst v1.0.3/go.mod h1:N2jZmlMufstn1KEqvbHjw40h1KyTmnVzHcSc9bFiJ2g= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= +github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= +github.com/gofrs/uuid v3.3.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golobby/container/v3 v3.3.2 h1:7u+RgNnsdVlhGoS8gY4EXAG601vpMMzLZlYqSp77Quw= +github.com/golobby/container/v3 v3.3.2/go.mod h1:RDdKpnKpV1Of11PFBe7Dxc2C1k2KaLE4FD47FflAmj0= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= +github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/jmespath-community/go-jmespath v1.1.1 h1:bFikPhsi/FdmlZhVgSCd2jj1e7G/rw+zyQfyg5UF+L4= +github.com/jmespath-community/go-jmespath v1.1.1/go.mod h1:4gOyFJsR/Gk+05RgTKYrifT7tBPWD8Lubtb5jRrfy9I= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= +github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mark3labs/mcp-go v0.41.1 h1:w78eWfiQam2i8ICL7AL0WFiq7KHNJQ6UB53ZVtH4KGA= +github.com/mark3labs/mcp-go v0.41.1/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= +github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/microsoft/ApplicationInsights-Go v0.4.4 h1:G4+H9WNs6ygSCe6sUyxRc2U81TI5Es90b2t/MwX5KqY= +github.com/microsoft/ApplicationInsights-Go v0.4.4/go.mod h1:fKRUseBqkw6bDiXTs3ESTiU/4YTIHsQS4W3fP2ieF4U= +github.com/microsoft/go-deviceid v1.0.0 h1:i5AQ654Xk9kfvwJeKQm3w2+eT1+ImBDVEpAR0AjpP40= +github.com/microsoft/go-deviceid v1.0.0/go.mod h1:KY13FeVdHkzD8gy+6T8+kVmD/7RMpTaWW75K+T4uZWg= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/nathan-fiscaletti/consolesize-go v0.0.0-20220204101620-317176b6684d h1:NqRhLdNVlozULwM1B3VaHhcXYSgrOAv8V5BE65om+1Q= +github.com/nathan-fiscaletti/consolesize-go v0.0.0-20220204101620-317176b6684d/go.mod h1:cxIIfNMTwff8f/ZvRouvWYF6wOoO7nj99neWSx2q/Es= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= +github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tedsuo/ifrit v0.0.0-20180802180643-bea94bb476cc/go.mod h1:eyZnKCc955uh98WQvzOm0dgAeLnf2O0Rz0LPoC5ze+0= +github.com/theckman/yacspin v0.13.12 h1:CdZ57+n0U6JMuh2xqjnjRq5Haj6v1ner2djtLQRzJr4= +github.com/theckman/yacspin v0.13.12/go.mod h1:Rd2+oG2LmQi5f3zC3yeZAOl245z8QOvrH4OPOJNZxLg= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= +github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= +github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/exp v0.0.0-20250911091902-df9299821621 h1:2id6c1/gto0kaHYyrixvknJ8tUK/Qs5IsmBtrc+FtgU= +golang.org/x/exp v0.0.0-20250911091902-df9299821621/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210331175145-43e1dd70ce54/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= +golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cli/azd/extensions/azure.ai.loom/internal/cmd/metadata.go b/cli/azd/extensions/azure.ai.loom/internal/cmd/metadata.go new file mode 100644 index 00000000000..2837dae9c54 --- /dev/null +++ b/cli/azd/extensions/azure.ai.loom/internal/cmd/metadata.go @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +func newMetadataCommand(rootCmd *cobra.Command) *cobra.Command { + return azdext.NewMetadataCommand("1.0", "azure.ai.loom", func() *cobra.Command { + return rootCmd + }) +} diff --git a/cli/azd/extensions/azure.ai.loom/internal/cmd/project_endpoint.go b/cli/azd/extensions/azure.ai.loom/internal/cmd/project_endpoint.go new file mode 100644 index 00000000000..e9d36f0c5c5 --- /dev/null +++ b/cli/azd/extensions/azure.ai.loom/internal/cmd/project_endpoint.go @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "fmt" + "net/url" + "strings" + + "azure.ai.loom/internal/exterrors" +) + +func validateProjectEndpoint(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", exterrors.Validation( + exterrors.CodeInvalidParameter, + "project endpoint must not be empty", + "provide a Foundry project endpoint URL", + ) + } + + endpoint, err := url.Parse(raw) + if err != nil { + return "", exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("invalid project endpoint URL: %s", err), + "provide a valid https:// Foundry project endpoint URL", + ) + } + if !strings.EqualFold(endpoint.Scheme, "https") { + return "", exterrors.Validation( + exterrors.CodeInvalidParameter, + "project endpoint must use https", + "provide an https:// URL", + ) + } + host := strings.ToLower(endpoint.Hostname()) + if host == "" || !strings.HasSuffix(host, ".services.ai.azure.com") { + return "", exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("project endpoint host %q is not a recognized Foundry host", host), + "provide a project endpoint whose host ends with .services.ai.azure.com", + ) + } + if endpoint.Port() != "" || endpoint.User != nil || endpoint.RawQuery != "" || endpoint.Fragment != "" { + return "", exterrors.Validation( + exterrors.CodeInvalidParameter, + "project endpoint must not include credentials, a port, query parameters, or a fragment", + "provide the base Foundry project endpoint", + ) + } + + path := strings.TrimRight(endpoint.EscapedPath(), "/") + return fmt.Sprintf("https://%s%s", host, path), nil +} + +func noProjectEndpointError() error { + return exterrors.Dependency( + exterrors.CodeMissingProjectEndpoint, + "no Foundry project endpoint resolved", + "provide --project-endpoint, configure `azd ai project set `, "+ + "or set FOUNDRY_PROJECT_ENDPOINT", + ) +} diff --git a/cli/azd/extensions/azure.ai.loom/internal/cmd/project_resolver.go b/cli/azd/extensions/azure.ai.loom/internal/cmd/project_resolver.go new file mode 100644 index 00000000000..2a850b8fd17 --- /dev/null +++ b/cli/azd/extensions/azure.ai.loom/internal/cmd/project_resolver.go @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "errors" + "os" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const projectContextConfigPath = "extensions.ai-projects.context" + +type resolveProjectEndpointOpts struct { + FlagValue string + ReadAzdHostedSources func(context.Context) (azdHostedSources, error) +} + +type resolvedEndpoint struct { + Endpoint string +} + +type projectContextState struct { + Endpoint string `json:"endpoint"` +} + +type azdHostedSources struct { + EnvValue string + Config projectContextState +} + +func readAzdHostedSources(ctx context.Context) (azdHostedSources, error) { + var sources azdHostedSources + azdClient, err := azdext.NewAzdClient() + if err != nil { + return sources, nil + } + defer azdClient.Close() + + if current, currentErr := azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}); currentErr == nil { + for _, key := range []string{"FOUNDRY_PROJECT_ENDPOINT", "AZURE_AI_PROJECT_ENDPOINT"} { + value, valueErr := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ + EnvName: current.Environment.Name, + Key: key, + }) + if valueErr == nil && value.Value != "" { + sources.EnvValue = value.Value + break + } + } + } + + config, err := azdext.NewConfigHelper(azdClient) + if err != nil { + return sources, nil + } + _, err = config.GetUserJSON(ctx, projectContextConfigPath, &sources.Config) + if err != nil && !containsGRPCCode(err, codes.Unavailable) { + return sources, err + } + return sources, nil +} + +func resolveProjectEndpoint( + ctx context.Context, + opts resolveProjectEndpointOpts, +) (*resolvedEndpoint, error) { + if opts.FlagValue != "" { + endpoint, err := validateProjectEndpoint(opts.FlagValue) + if err != nil { + return nil, err + } + return &resolvedEndpoint{Endpoint: endpoint}, nil + } + + readSources := opts.ReadAzdHostedSources + if readSources == nil { + readSources = readAzdHostedSources + } + sources, err := readSources(ctx) + if err != nil { + return nil, err + } + for _, candidate := range []string{sources.EnvValue, sources.Config.Endpoint, os.Getenv("FOUNDRY_PROJECT_ENDPOINT")} { + if candidate == "" { + continue + } + endpoint, err := validateProjectEndpoint(candidate) + if err != nil { + return nil, err + } + return &resolvedEndpoint{Endpoint: endpoint}, nil + } + return nil, noProjectEndpointError() +} + +func containsGRPCCode(err error, code codes.Code) bool { + for ; err != nil; err = errors.Unwrap(err) { + if grpcStatus, ok := status.FromError(err); ok && grpcStatus.Code() == code { + return true + } + } + return false +} diff --git a/cli/azd/extensions/azure.ai.loom/internal/cmd/project_resolver_test.go b/cli/azd/extensions/azure.ai.loom/internal/cmd/project_resolver_test.go new file mode 100644 index 00000000000..6ce09572453 --- /dev/null +++ b/cli/azd/extensions/azure.ai.loom/internal/cmd/project_resolver_test.go @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testProjectEndpoint = "https://account.services.ai.azure.com/api/projects/project" + +func TestResolveProjectEndpointPrefersFlag(t *testing.T) { + resolved, err := resolveProjectEndpoint(t.Context(), resolveProjectEndpointOpts{ + FlagValue: testProjectEndpoint, + ReadAzdHostedSources: func(context.Context) (azdHostedSources, error) { + return azdHostedSources{ + EnvValue: "https://other.services.ai.azure.com/api/projects/other", + }, nil + }, + }) + + require.NoError(t, err) + assert.Equal(t, testProjectEndpoint, resolved.Endpoint) +} + +func TestResolveProjectEndpointUsesPersistedProjectContext(t *testing.T) { + resolved, err := resolveProjectEndpoint(t.Context(), resolveProjectEndpointOpts{ + ReadAzdHostedSources: func(context.Context) (azdHostedSources, error) { + return azdHostedSources{ + Config: projectContextState{Endpoint: testProjectEndpoint}, + }, nil + }, + }) + + require.NoError(t, err) + assert.Equal(t, testProjectEndpoint, resolved.Endpoint) +} + +func TestValidateProjectEndpointDoesNotDiscloseCredentials(t *testing.T) { + credential := "user:secret" + _, err := validateProjectEndpoint( + "https://" + credential + "@account.services.ai.azure.com/api/projects/project?sig=sensitive", + ) + + require.Error(t, err) + assert.NotContains(t, err.Error(), credential) + assert.NotContains(t, err.Error(), "sensitive") +} diff --git a/cli/azd/extensions/azure.ai.loom/internal/cmd/root.go b/cli/azd/extensions/azure.ai.loom/internal/cmd/root.go new file mode 100644 index 00000000000..600e5647189 --- /dev/null +++ b/cli/azd/extensions/azure.ai.loom/internal/cmd/root.go @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +func NewRootCommand() *cobra.Command { + rootCmd, extCtx := azdext.NewExtensionRootCommand(azdext.ExtensionCommandOptions{ + Name: "loom", + Use: "loom [options]", + Short: "Inspect and ingest Microsoft Foundry experiment-tracking data. (Preview)", + }) + + rootCmd.SilenceUsage = true + rootCmd.SilenceErrors = true + rootCmd.CompletionOptions = cobra.CompletionOptions{ + DisableDefaultCmd: true, + } + + rootCmd.SetHelpCommand(&cobra.Command{Hidden: true}) + + rootCmd.AddCommand(newVersionCommand(&extCtx.OutputFormat)) + rootCmd.AddCommand(newMetadataCommand(rootCmd)) + rootCmd.AddCommand(newExperimentRunCommand(extCtx)) + + return rootCmd +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/experiment.go b/cli/azd/extensions/azure.ai.loom/internal/cmd/run.go similarity index 98% rename from cli/azd/extensions/azure.ai.projects/internal/cmd/experiment.go rename to cli/azd/extensions/azure.ai.loom/internal/cmd/run.go index 3e123825cb5..cbf0e830f40 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/experiment.go +++ b/cli/azd/extensions/azure.ai.loom/internal/cmd/run.go @@ -16,8 +16,8 @@ import ( "strconv" "strings" - "azure.ai.projects/internal/experimenttracking" - "azure.ai.projects/internal/exterrors" + "azure.ai.loom/internal/experimenttracking" + "azure.ai.loom/internal/exterrors" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/azure/azure-dev/cli/azd/pkg/azdext" @@ -54,10 +54,11 @@ func newExperimentRunCommand(extCtx *azdext.ExtensionContext) *cobra.Command { cmd.AddCommand(newRunSystemMetricsCommand(extCtx)) cmd.AddCommand(newRunLogsCommand(extCtx)) cmd.AddCommand(newRunLogRecordsCommand(extCtx)) - cmd.AddCommand(newRunTracesCommand(extCtx)) cmd.AddCommand(newRunTraceCommand(extCtx)) cmd.AddCommand(newRunCompareCommand(extCtx)) cmd.AddCommand(newRunSpansCommand(extCtx)) + cmd.AddCommand(newExperimentIngestCommand(extCtx)) + cmd.AddCommand(newExperimentWandBCommand(extCtx)) return cmd } @@ -119,7 +120,7 @@ func newRunLogRecordsCommand(extCtx *azdext.ExtensionContext) *cobra.Command { } func newRunTracesCommand(extCtx *azdext.ExtensionContext) *cobra.Command { - return newRunGetCommand(extCtx, "traces", "List traces for a run.", "traces", true) + return newRunGetCommand(extCtx, "list", "List traces for a run.", "traces", true) } func newRunGetCommand( @@ -217,6 +218,7 @@ func newRunTraceCommand(extCtx *azdext.ExtensionContext) *cobra.Command { Short: "Inspect or analyze a run trace.", Args: cobra.NoArgs, } + cmd.AddCommand(newRunTracesCommand(extCtx)) cmd.AddCommand(newRunTraceShowCommand(extCtx)) cmd.AddCommand(newRunTraceChatCommand(extCtx)) return cmd @@ -353,7 +355,7 @@ func newRunCompareCommand(extCtx *azdext.ExtensionContext) *cobra.Command { func newRunSpansCommand(extCtx *azdext.ExtensionContext) *cobra.Command { cmd := &cobra.Command{ - Use: "spans", + Use: "span", Short: "Query run-scoped spans.", Args: cobra.NoArgs, } @@ -624,8 +626,7 @@ func addRunFlags(cmd *cobra.Command, flags *runRequestFlags, withTake bool) { } } -func registerJSONOutput(cmd *cobra.Command, extCtx *azdext.ExtensionContext) { - _ = ensureExtensionContext(extCtx) +func registerJSONOutput(cmd *cobra.Command, _ *azdext.ExtensionContext) { azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ Name: "output", AllowedValues: []string{"json"}, diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/experiment_test.go b/cli/azd/extensions/azure.ai.loom/internal/cmd/run_test.go similarity index 81% rename from cli/azd/extensions/azure.ai.projects/internal/cmd/experiment_test.go rename to cli/azd/extensions/azure.ai.loom/internal/cmd/run_test.go index 4a84223bad0..695f3402873 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/experiment_test.go +++ b/cli/azd/extensions/azure.ai.loom/internal/cmd/run_test.go @@ -22,8 +22,8 @@ func TestRootIncludesExperimentTrackingCommands(t *testing.T) { } assert.True(t, names["run"]) - assert.True(t, names["ingest"]) - assert.True(t, names["wandb"]) + assert.False(t, names["ingest"]) + assert.False(t, names["wandb"]) } func TestRunCommandIncludesAllReadAndAgentSurfaces(t *testing.T) { @@ -40,16 +40,29 @@ func TestRunCommandIncludesAllReadAndAgentSurfaces(t *testing.T) { "log-records", "logs", "metrics", - "spans", + "span", "summary", "system-metrics", "trace", - "traces", + "ingest", + "wandb", } { assert.True(t, names[name], "missing run command %q", name) } } +func TestTraceCommandIncludesListShowAndChat(t *testing.T) { + command := newRunTraceCommand(nil) + names := map[string]bool{} + for _, child := range command.Commands() { + names[child.Name()] = true + } + + for _, name := range []string{"list", "show", "chat"} { + assert.True(t, names[name], "missing trace command %q", name) + } +} + func TestReadFilterExpressionDefaultsToMatchAll(t *testing.T) { filter, err := readFilterExpression("", "") require.NoError(t, err) @@ -167,3 +180,16 @@ func TestExperimentCommandsUseJSONOutput(t *testing.T) { assertOutputFlagOptions(t, command, "json", []string{"json"}) } } + +func assertOutputFlagOptions(t *testing.T, cmd *cobra.Command, wantDefault string, wantAllowed []string) { + t.Helper() + require.NotNil(t, cmd.Annotations) + assert.Equal(t, wantDefault, cmd.Annotations["azdext.default/output"]) + + var allowed []string + require.NoError(t, json.Unmarshal( + []byte(cmd.Annotations["azdext.allowed-values/output"]), + &allowed, + )) + assert.Equal(t, wantAllowed, allowed) +} diff --git a/cli/azd/extensions/azure.ai.loom/internal/cmd/version.go b/cli/azd/extensions/azure.ai.loom/internal/cmd/version.go new file mode 100644 index 00000000000..1c9e92b5123 --- /dev/null +++ b/cli/azd/extensions/azure.ai.loom/internal/cmd/version.go @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +var ( + // Populated at build time + Version = "dev" // Default value for development builds + Commit = "none" + BuildDate = "unknown" +) + +func newVersionCommand(outputFormat *string) *cobra.Command { + return azdext.NewVersionCommand("azure.ai.loom", Version, outputFormat) +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/experimenttracking/client.go b/cli/azd/extensions/azure.ai.loom/internal/experimenttracking/client.go similarity index 100% rename from cli/azd/extensions/azure.ai.projects/internal/experimenttracking/client.go rename to cli/azd/extensions/azure.ai.loom/internal/experimenttracking/client.go diff --git a/cli/azd/extensions/azure.ai.projects/internal/experimenttracking/client_test.go b/cli/azd/extensions/azure.ai.loom/internal/experimenttracking/client_test.go similarity index 100% rename from cli/azd/extensions/azure.ai.projects/internal/experimenttracking/client_test.go rename to cli/azd/extensions/azure.ai.loom/internal/experimenttracking/client_test.go diff --git a/cli/azd/extensions/azure.ai.loom/internal/exterrors/codes.go b/cli/azd/extensions/azure.ai.loom/internal/exterrors/codes.go new file mode 100644 index 00000000000..5d23936986e --- /dev/null +++ b/cli/azd/extensions/azure.ai.loom/internal/exterrors/codes.go @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package exterrors + +const ( + CodeInvalidParameter = "invalid_parameter" + CodeInvalidExperimentPayload = "invalid_experiment_payload" + CodeMissingProjectEndpoint = "missing_project_endpoint" + CodeExperimentInputReadFailed = "experiment_input_read_failed" + CodeCredentialCreationFailed = "credential_creation_failed" //nolint:gosec // Error code, not a credential. + CodeAuthenticationFailed = "authentication_failed" + CodeExperimentRequestFailed = "experiment_request_failed" +) + +const OpExperimentRequest = "experiment_request" diff --git a/cli/azd/extensions/azure.ai.loom/internal/exterrors/errors.go b/cli/azd/extensions/azure.ai.loom/internal/exterrors/errors.go new file mode 100644 index 00000000000..ee58ae9f8c5 --- /dev/null +++ b/cli/azd/extensions/azure.ai.loom/internal/exterrors/errors.go @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Package exterrors provides structured errors for the azure.ai.loom extension. +package exterrors + +import ( + "errors" + "fmt" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +// Validation returns an input-validation error. +func Validation(code, message, suggestion string) error { + return &azdext.LocalError{ + Message: message, + Code: code, + Category: azdext.LocalErrorCategoryValidation, + Suggestion: suggestion, + } +} + +// Dependency returns a missing-dependency error. +func Dependency(code, message, suggestion string) error { + return &azdext.LocalError{ + Message: message, + Code: code, + Category: azdext.LocalErrorCategoryDependency, + Suggestion: suggestion, + } +} + +// Auth returns an authentication error. +func Auth(code, message, suggestion string) error { + return &azdext.LocalError{ + Message: message, + Code: code, + Category: azdext.LocalErrorCategoryAuth, + Suggestion: suggestion, + } +} + +// Internal returns an unexpected local error. +func Internal(code, message string) error { + return &azdext.LocalError{ + Message: message, + Code: code, + Category: azdext.LocalErrorCategoryInternal, + } +} + +// ServiceFromAzure converts an Azure response error into an extension service error. +func ServiceFromAzure(err error, operation string) error { + if responseErr, ok := errors.AsType[*azcore.ResponseError](err); ok { + serviceName := "" + if responseErr.RawResponse != nil && responseErr.RawResponse.Request != nil { + serviceName = responseErr.RawResponse.Request.Host + } + errorCode := responseErr.ErrorCode + if errorCode == "" { + errorCode = fmt.Sprintf("%d", responseErr.StatusCode) + } + return &azdext.ServiceError{ + Message: fmt.Sprintf("%s: %s", operation, responseErr.Error()), + ErrorCode: fmt.Sprintf("%s.%s", operation, errorCode), + StatusCode: responseErr.StatusCode, + ServiceName: serviceName, + } + } + return Internal(operation, fmt.Sprintf("%s: %s", operation, err)) +} diff --git a/cli/azd/extensions/azure.ai.loom/main.go b/cli/azd/extensions/azure.ai.loom/main.go new file mode 100644 index 00000000000..ba3c023a81e --- /dev/null +++ b/cli/azd/extensions/azure.ai.loom/main.go @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package main + +import ( + "azure.ai.loom/internal/cmd" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +func main() { + azdext.Run(cmd.NewRootCommand()) +} diff --git a/cli/azd/extensions/azure.ai.loom/version.txt b/cli/azd/extensions/azure.ai.loom/version.txt new file mode 100644 index 00000000000..2c31a296e4c --- /dev/null +++ b/cli/azd/extensions/azure.ai.loom/version.txt @@ -0,0 +1 @@ +0.1.0-preview diff --git a/cli/azd/extensions/azure.ai.projects/README.md b/cli/azd/extensions/azure.ai.projects/README.md index 19c2add05e7..34b51402003 100644 --- a/cli/azd/extensions/azure.ai.projects/README.md +++ b/cli/azd/extensions/azure.ai.projects/README.md @@ -41,108 +41,3 @@ existing Foundry project should be reused instead, configure its endpoint and se full project resource ID before retrying. The `azd ai project set`, `show`, and `unset` commands manage the default Foundry project endpoint context. They do not currently author the project service in `azure.yaml`. - -## Experiment tracking - -The extension exposes the Foundry project experiment-tracking APIs. Commands -reuse the project endpoint context described above and authenticate through -`azd auth login` with the `https://ai.azure.com/.default` scope. - -To use the Foundry account API key accepted by the project data plane, set -`AZURE_AI_PROJECT_API_KEY` in the current process. The environment variable -takes precedence over bearer authentication and should not be persisted in -`azure.yaml`, azd environment files, or shell profiles. - -The project ID is derived from the final path segment of the resolved endpoint: - -```text -https://my-account.services.ai.azure.com/api/projects/my-project - └─ project ID -``` - -Use `--project-id` only when calling a nonstandard endpoint whose path does not -contain the project ID. - -### Runs - -```sh -azd ai project run list -azd ai project run summary --run-id -azd ai project run metrics --run-id -azd ai project run system-metrics --run-id --name system/cpu -azd ai project run logs --run-id -azd ai project run log-records --run-id -azd ai project run traces --run-id -azd ai project run trace show --run-id --trace-id -azd ai project run compare \ - --run-id --run-id \ - --metric loss --min 0 --max 100 -``` - -All experiment-tracking commands emit JSON so automation receives the complete -service response without a lossy table projection. - -### Span filters - -Pass a filter inline or in a JSON file. When neither is provided, the command -uses `{"$expr":true}`. - -```json -{ - "$expr": { - "$eq": [ - { "$getField": "span_name" }, - { "$literal": "chat" } - ] - } -} -``` - -```sh -azd ai project run spans query \ - --run-id \ - --filter-file ./span-filter.json \ - --include-details \ - --limit 10 -``` - -The CLI wraps the filter with the resolved project ID: - -```json -{ - "project_id": "my-project", - "query": { - "$expr": { - "$eq": [ - { "$getField": "span_name" }, - { "$literal": "chat" } - ] - } - }, - "include_details": true, - "limit": 10 -} -``` - -Use `--request-file` to send a complete span-query or trace-chat request body. - -### Ingestion and W&B compatibility - -OTLP commands require an explicit payload and never send an empty no-op request: - -```sh -azd ai project ingest metrics --run-id --file ./metrics.pb -azd ai project ingest logs --run-id --file ./logs.pb -azd ai project ingest traces --run-id --file ./traces.pb -azd ai project ingest agent-traces --run-id --file ./agent-traces.json -``` - -Use `--file -` to read a payload from stdin. Advanced W&B-compatible requests -accept complete JSON request bodies: - -```sh -azd ai project wandb graphql --file ./graphql-request.json -azd ai project wandb file-stream \ - --run-id \ - --file ./file-stream-request.json -``` diff --git a/cli/azd/extensions/azure.ai.projects/cspell.yaml b/cli/azd/extensions/azure.ai.projects/cspell.yaml index 8cae8f618fe..d1bb1ef30cc 100644 --- a/cli/azd/extensions/azure.ai.projects/cspell.yaml +++ b/cli/azd/extensions/azure.ai.projects/cspell.yaml @@ -2,11 +2,9 @@ import: ../../.vscode/cspell.yaml words: - alnum - exterrors - - experimenttracking - foundryproject - idempotently - ondisk - purgeable - - wandb # Contributors - hemarina diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/root.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/root.go index 10121721a1b..f376368b423 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/root.go @@ -33,9 +33,6 @@ func NewRootCommand() *cobra.Command { rootCmd.AddCommand(newProjectSetCommand(extCtx)) rootCmd.AddCommand(newProjectUnsetCommand(extCtx)) rootCmd.AddCommand(newProjectShowCommand(extCtx)) - rootCmd.AddCommand(newExperimentRunCommand(extCtx)) - rootCmd.AddCommand(newExperimentIngestCommand(extCtx)) - rootCmd.AddCommand(newExperimentWandBCommand(extCtx)) rootCmd.AddCommand(azdext.NewListenCommand(configureExtensionHost)) return rootCmd diff --git a/cli/azd/extensions/azure.ai.projects/internal/exterrors/codes.go b/cli/azd/extensions/azure.ai.projects/internal/exterrors/codes.go index d3ea95e0f57..91cf2b6b485 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/exterrors/codes.go +++ b/cli/azd/extensions/azure.ai.projects/internal/exterrors/codes.go @@ -20,7 +20,6 @@ const ( CodeOnDiskParametersInvalid = "ondisk_parameters_invalid" CodeOnDiskTemplateMissing = "ondisk_template_missing" CodeArmWhatIfFailed = "arm_what_if_failed" - CodeInvalidExperimentPayload = "invalid_experiment_payload" ) // Error codes commonly used for dependency errors. @@ -34,7 +33,6 @@ const ( CodeMissingAzureSubscription = "missing_azure_subscription_id" CodeMissingAzureLocation = "missing_azure_location" CodeProvisioningServiceNotFound = "provisioning_service_not_found" - CodeExperimentInputReadFailed = "experiment_input_read_failed" ) const ( @@ -42,7 +40,6 @@ const ( CodeCredentialCreationFailed = "credential_creation_failed" CodeAuthenticationFailed = "authentication_failed" CodeTenantLookupFailed = "tenant_lookup_failed" - CodeExperimentRequestFailed = "experiment_request_failed" ) const ( @@ -57,5 +54,4 @@ const ( OpCognitiveDeploymentDelete = "cognitive_deployment_delete" OpProjectConnectionDelete = "project_connection_delete" OpProjectConnectionGet = "project_connection_get" - OpExperimentRequest = "experiment_request" ) diff --git a/eng/pipelines/release-ext-azure-ai-loom.yml b/eng/pipelines/release-ext-azure-ai-loom.yml new file mode 100644 index 00000000000..501e8d81f5e --- /dev/null +++ b/eng/pipelines/release-ext-azure-ai-loom.yml @@ -0,0 +1,49 @@ +# Continuous deployment trigger +trigger: + branches: + include: + - main + paths: + include: + - cli/azd/extensions/azure.ai.loom + - /eng/pipelines/templates/stages/release-azd-extension.yml + - /eng/pipelines/templates/stages/publish-extension-pr.yml + - /eng/scripts/Set-ExtensionVersionVariable.ps1 + - /eng/pipelines/templates/jobs/build-azd-extension.yml + - /eng/pipelines/templates/jobs/cross-build-azd-extension.yml + - /eng/pipelines/templates/variables/image.yml + +pr: + paths: + include: + - cli/azd/extensions/azure.ai.loom + - eng/pipelines/release-ext-azure-ai-loom.yml + - /eng/pipelines/templates/stages/release-azd-extension.yml + - /eng/pipelines/templates/stages/publish-extension-pr.yml + - /eng/scripts/Set-ExtensionVersionVariable.ps1 + - eng/pipelines/templates/steps/publish-cli.yml + exclude: + - cli/azd/docs/** + +parameters: + - name: PublishToRegistry + displayName: Publish to registry + type: string + # Scheduled (nightly) runs override this in the shared templates; the runtime + # parameter default must be a literal because it renders before variables exist. + default: stable + values: + - stable + - dev + - nightly + +extends: + template: /eng/pipelines/templates/stages/1es-redirect.yml + parameters: + stages: + - template: /eng/pipelines/templates/stages/release-azd-extension.yml + parameters: + AzdExtensionId: azure.ai.loom + SanitizedExtensionId: azure-ai-loom + AzdExtensionDirectory: cli/azd/extensions/azure.ai.loom + PublishToRegistry: ${{ parameters.PublishToRegistry }} From 1fd54111c3d3ec97bf4221379c0d528b974703b6 Mon Sep 17 00:00:00 2001 From: HarshaVardhan Babu Namburi Date: Tue, 1 Sep 2026 14:27:11 +0530 Subject: [PATCH 05/13] Add Loom API smoke test script Exercise every azd ai loom run surface with configurable project, run, trace, and payload inputs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/extensions/azure.ai.loom/README.md | 21 ++ cli/azd/extensions/azure.ai.loom/cspell.yaml | 1 + cli/azd/extensions/azure.ai.loom/test-all.ps1 | 314 ++++++++++++++++++ 3 files changed, 336 insertions(+) create mode 100644 cli/azd/extensions/azure.ai.loom/test-all.ps1 diff --git a/cli/azd/extensions/azure.ai.loom/README.md b/cli/azd/extensions/azure.ai.loom/README.md index 96aabc7b673..5088de59896 100644 --- a/cli/azd/extensions/azure.ai.loom/README.md +++ b/cli/azd/extensions/azure.ai.loom/README.md @@ -106,3 +106,24 @@ All commands emit complete JSON responses for automation. azd x build go test ./... -count=1 ``` + +To exercise every command against a project from PowerShell: + +```powershell +.\test-all.ps1 ` + -ProjectEndpoint "https://.services.ai.azure.com/api/projects/" ` + -RunId "" ` + -SecondRunId "" ` + -TraceId "" ` + -MetricsFile .\testdata\metrics.pb ` + -LogsFile .\testdata\logs.pb ` + -TracesFile .\testdata\traces.pb ` + -AgentTracesFile .\testdata\agent-traces.json ` + -GraphQLFile .\testdata\graphql-request.json ` + -FileStreamFile .\testdata\file-stream-request.json +``` + +The script builds and installs the extension, runs all commands, and prints a +pass/fail summary. Set `AZURE_AI_PROJECT_API_KEY` before running it to use API +key authentication. Otherwise, it uses the current `azd auth login` session. +Use `-SkipWriteOperations` to test only inspection, trace, and span commands. diff --git a/cli/azd/extensions/azure.ai.loom/cspell.yaml b/cli/azd/extensions/azure.ai.loom/cspell.yaml index e937ce3d320..22af27a4f14 100644 --- a/cli/azd/extensions/azure.ai.loom/cspell.yaml +++ b/cli/azd/extensions/azure.ai.loom/cspell.yaml @@ -4,4 +4,5 @@ words: - experimenttracking - filestream - opentelemetry + - pscustomobject - wandb diff --git a/cli/azd/extensions/azure.ai.loom/test-all.ps1 b/cli/azd/extensions/azure.ai.loom/test-all.ps1 new file mode 100644 index 00000000000..1f3830089c8 --- /dev/null +++ b/cli/azd/extensions/azure.ai.loom/test-all.ps1 @@ -0,0 +1,314 @@ +param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string] $ProjectEndpoint, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string] $RunId, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string] $SecondRunId, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string] $TraceId, + + [string] $ProjectId, + [string] $ApiVersion = "v1", + [string[]] $MetricName = @("loss"), + [string[]] $SystemMetricName = @("system/cpu"), + [string] $SpanName = "chat", + [double] $MinStep = 0, + [double] $MaxStep = 100, + [ValidateRange(1, [int]::MaxValue)] + [int] $Take = 10, + + [string] $MetricsFile, + [string] $LogsFile, + [string] $TracesFile, + [string] $AgentTracesFile, + [string] $GraphQLFile, + [string] $FileStreamFile, + [string] $WandBEntity, + [string] $WandBProject, + + [string] $AzdPath = "azd", + [switch] $SkipBuild, + [switch] $SkipWriteOperations, + [switch] $StopOnFailure +) + +$ErrorActionPreference = "Stop" + +$results = [System.Collections.Generic.List[object]]::new() + +function Add-TestResult { + param( + [string] $Name, + [string] $Status, + [int] $ExitCode + ) + + $results.Add([pscustomobject]@{ + Test = $Name + Status = $Status + ExitCode = $ExitCode + }) +} + +function Invoke-AzdTest { + param( + [string] $Name, + [string[]] $CommandArguments, + [switch] $Required + ) + + Write-Host "" + Write-Host "==> $Name" -ForegroundColor Cyan + + try { + & $AzdPath @CommandArguments + $exitCode = $LASTEXITCODE + } + catch { + Write-Host $_.Exception.Message -ForegroundColor Red + $exitCode = 1 + } + + if ($exitCode -eq 0) { + Add-TestResult -Name $Name -Status "Passed" -ExitCode 0 + Write-Host "Passed: $Name" -ForegroundColor Green + return + } + + Add-TestResult -Name $Name -Status "Failed" -ExitCode $exitCode + Write-Host "Failed: $Name (exit code $exitCode)" -ForegroundColor Red + if ($Required -or $StopOnFailure) { + throw "Stopping after failed test: $Name" + } +} + +function Resolve-RequiredFile { + param( + [string] $ParameterName, + [string] $Path + ) + + if ([string]::IsNullOrWhiteSpace($Path)) { + throw "-$ParameterName is required unless -SkipWriteOperations is set." + } + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + throw "The file supplied through -$ParameterName does not exist." + } + + return (Resolve-Path -LiteralPath $Path).Path +} + +if ($MaxStep -lt $MinStep) { + throw "-MaxStep must be greater than or equal to -MinStep." +} +if ($MetricName.Count -eq 0) { + throw "Provide at least one value through -MetricName." +} +if ($SystemMetricName.Count -eq 0) { + throw "Provide at least one value through -SystemMetricName." +} + +$azdCommand = Get-Command $AzdPath -ErrorAction SilentlyContinue +if ($null -eq $azdCommand) { + throw "Could not find azd. Install azd 1.32.0 or later, or provide -AzdPath." +} + +if (-not $SkipWriteOperations) { + $MetricsFile = Resolve-RequiredFile -ParameterName "MetricsFile" -Path $MetricsFile + $LogsFile = Resolve-RequiredFile -ParameterName "LogsFile" -Path $LogsFile + $TracesFile = Resolve-RequiredFile -ParameterName "TracesFile" -Path $TracesFile + $AgentTracesFile = Resolve-RequiredFile -ParameterName "AgentTracesFile" -Path $AgentTracesFile + $GraphQLFile = Resolve-RequiredFile -ParameterName "GraphQLFile" -Path $GraphQLFile + $FileStreamFile = Resolve-RequiredFile -ParameterName "FileStreamFile" -Path $FileStreamFile +} + +$commonArguments = @("--api-version", $ApiVersion, "--output", "json") +if (-not [string]::IsNullOrWhiteSpace($ProjectId)) { + $commonArguments += @("--project-id", $ProjectId) +} + +$hadProjectEndpoint = Test-Path Env:FOUNDRY_PROJECT_ENDPOINT +$previousProjectEndpoint = $env:FOUNDRY_PROJECT_ENDPOINT +$env:FOUNDRY_PROJECT_ENDPOINT = $ProjectEndpoint + +try { + if (-not $SkipBuild) { + Push-Location $PSScriptRoot + try { + Invoke-AzdTest -Name "Build and install azure.ai.loom" -CommandArguments @("x", "build") -Required + } + finally { + Pop-Location + } + } + + Invoke-AzdTest -Name "Show Loom command help" ` + -CommandArguments @("ai", "loom", "--help") + + Invoke-AzdTest -Name "List runs" ` + -CommandArguments (@("ai", "loom", "run", "list", "--take", $Take) + $commonArguments) + + Invoke-AzdTest -Name "List run history keys" ` + -CommandArguments (@("ai", "loom", "run", "history-keys", "--run-id", $RunId) + $commonArguments) + + Invoke-AzdTest -Name "Get run summary" ` + -CommandArguments (@("ai", "loom", "run", "summary", "--run-id", $RunId, "--take", $Take) + $commonArguments) + + Invoke-AzdTest -Name "List run metrics" ` + -CommandArguments (@("ai", "loom", "run", "metrics", "--run-id", $RunId, "--take", $Take) + $commonArguments) + + $systemMetricArguments = @( + "ai", "loom", "run", "system-metrics", + "--run-id", $RunId, + "--take", $Take + ) + foreach ($name in $SystemMetricName) { + $systemMetricArguments += @("--name", $name) + } + Invoke-AzdTest -Name "Get run system metrics" ` + -CommandArguments ($systemMetricArguments + $commonArguments) + + Invoke-AzdTest -Name "Get run logs" ` + -CommandArguments (@("ai", "loom", "run", "logs", "--run-id", $RunId, "--take", $Take) + $commonArguments) + + Invoke-AzdTest -Name "Get run log records" ` + -CommandArguments (@("ai", "loom", "run", "log-records", "--run-id", $RunId, "--take", $Take) + $commonArguments) + + $compareArguments = @( + "ai", "loom", "run", "compare", + "--run-id", $RunId, + "--run-id", $SecondRunId, + "--min", $MinStep, + "--max", $MaxStep + ) + foreach ($name in $MetricName) { + $compareArguments += @("--metric", $name) + } + Invoke-AzdTest -Name "Compare runs" ` + -CommandArguments ($compareArguments + $commonArguments) + + Invoke-AzdTest -Name "List run traces" ` + -CommandArguments (@("ai", "loom", "run", "trace", "list", "--run-id", $RunId, "--take", $Take) + $commonArguments) + + Invoke-AzdTest -Name "Show run trace" ` + -CommandArguments (@( + "ai", "loom", "run", "trace", "show", + "--run-id", $RunId, + "--trace-id", $TraceId + ) + $commonArguments) + + Invoke-AzdTest -Name "Request trace chat" ` + -CommandArguments (@( + "ai", "loom", "run", "trace", "chat", + "--run-id", $RunId, + "--trace-id", $TraceId + ) + $commonArguments) + + $spanFilter = @{ + '$expr' = @{ + '$eq' = @( + @{ '$getField' = "span_name" }, + @{ '$literal' = $SpanName } + ) + } + } | ConvertTo-Json -Depth 5 -Compress + Invoke-AzdTest -Name "Query run spans" ` + -CommandArguments (@( + "ai", "loom", "run", "span", "query", + "--run-id", $RunId, + "--filter", $spanFilter, + "--include-details", + "--limit", $Take + ) + $commonArguments) + + if ($SkipWriteOperations) { + foreach ($name in @( + "Ingest OTLP metrics", + "Ingest OTLP logs", + "Ingest OTLP traces", + "Ingest agent traces", + "Execute W&B GraphQL request", + "Send W&B file stream" + )) { + Add-TestResult -Name $name -Status "Skipped" -ExitCode 0 + } + } + else { + Invoke-AzdTest -Name "Ingest OTLP metrics" ` + -CommandArguments (@( + "ai", "loom", "run", "ingest", "metrics", + "--run-id", $RunId, + "--file", $MetricsFile + ) + $commonArguments) + + Invoke-AzdTest -Name "Ingest OTLP logs" ` + -CommandArguments (@( + "ai", "loom", "run", "ingest", "logs", + "--run-id", $RunId, + "--file", $LogsFile + ) + $commonArguments) + + Invoke-AzdTest -Name "Ingest OTLP traces" ` + -CommandArguments (@( + "ai", "loom", "run", "ingest", "traces", + "--run-id", $RunId, + "--file", $TracesFile + ) + $commonArguments) + + Invoke-AzdTest -Name "Ingest agent traces" ` + -CommandArguments (@( + "ai", "loom", "run", "ingest", "agent-traces", + "--run-id", $RunId, + "--file", $AgentTracesFile + ) + $commonArguments) + + Invoke-AzdTest -Name "Execute W&B GraphQL request" ` + -CommandArguments (@( + "ai", "loom", "run", "wandb", "graphql", + "--file", $GraphQLFile + ) + $commonArguments) + + $fileStreamArguments = @( + "ai", "loom", "run", "wandb", "file-stream", + "--run-id", $RunId, + "--file", $FileStreamFile + ) + if (-not [string]::IsNullOrWhiteSpace($WandBEntity)) { + $fileStreamArguments += @("--entity", $WandBEntity) + } + if (-not [string]::IsNullOrWhiteSpace($WandBProject)) { + $fileStreamArguments += @("--wandb-project", $WandBProject) + } + Invoke-AzdTest -Name "Send W&B file stream" ` + -CommandArguments ($fileStreamArguments + $commonArguments) + } +} +finally { + if ($hadProjectEndpoint) { + $env:FOUNDRY_PROJECT_ENDPOINT = $previousProjectEndpoint + } + else { + Remove-Item Env:FOUNDRY_PROJECT_ENDPOINT -ErrorAction SilentlyContinue + } +} + +Write-Host "" +Write-Host "Loom smoke-test results" -ForegroundColor Cyan +$results | Format-Table -AutoSize | Out-Host + +$failed = @($results | Where-Object Status -eq "Failed") +if ($failed.Count -gt 0) { + Write-Host "$($failed.Count) test(s) failed." -ForegroundColor Red + exit 1 +} + +Write-Host "All executed tests passed." -ForegroundColor Green +exit 0 From 35e02a8f55530680e815da833578637b1606266f Mon Sep 17 00:00:00 2001 From: HarshaVardhan Babu Namburi Date: Tue, 1 Sep 2026 15:13:23 +0530 Subject: [PATCH 06/13] Generate synthetic Loom ingestion data Create temporary OTLP protobuf and JSON fixtures so the smoke test exercises ingestion and W&B APIs without caller-provided payload files. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/extensions/azure.ai.loom/README.md | 12 +- cli/azd/extensions/azure.ai.loom/cspell.yaml | 1 + cli/azd/extensions/azure.ai.loom/test-all.ps1 | 365 ++++++++++++++++-- 3 files changed, 341 insertions(+), 37 deletions(-) diff --git a/cli/azd/extensions/azure.ai.loom/README.md b/cli/azd/extensions/azure.ai.loom/README.md index 5088de59896..b933bded36a 100644 --- a/cli/azd/extensions/azure.ai.loom/README.md +++ b/cli/azd/extensions/azure.ai.loom/README.md @@ -114,16 +114,12 @@ To exercise every command against a project from PowerShell: -ProjectEndpoint "https://.services.ai.azure.com/api/projects/" ` -RunId "" ` -SecondRunId "" ` - -TraceId "" ` - -MetricsFile .\testdata\metrics.pb ` - -LogsFile .\testdata\logs.pb ` - -TracesFile .\testdata\traces.pb ` - -AgentTracesFile .\testdata\agent-traces.json ` - -GraphQLFile .\testdata\graphql-request.json ` - -FileStreamFile .\testdata\file-stream-request.json + -TraceId "" ``` The script builds and installs the extension, runs all commands, and prints a pass/fail summary. Set `AZURE_AI_PROJECT_API_KEY` before running it to use API key authentication. Otherwise, it uses the current `azd auth login` session. -Use `-SkipWriteOperations` to test only inspection, trace, and span commands. +It generates temporary synthetic OTLP protobuf, agent-trace, GraphQL, and W&B +file-stream payloads for the ingestion tests and removes them afterward. Use +`-SkipWriteOperations` to test only inspection, trace, and span commands. diff --git a/cli/azd/extensions/azure.ai.loom/cspell.yaml b/cli/azd/extensions/azure.ai.loom/cspell.yaml index 22af27a4f14..0cac30a3a28 100644 --- a/cli/azd/extensions/azure.ai.loom/cspell.yaml +++ b/cli/azd/extensions/azure.ai.loom/cspell.yaml @@ -5,4 +5,5 @@ words: - filestream - opentelemetry - pscustomobject + - varint - wandb diff --git a/cli/azd/extensions/azure.ai.loom/test-all.ps1 b/cli/azd/extensions/azure.ai.loom/test-all.ps1 index 1f3830089c8..29129d12f25 100644 --- a/cli/azd/extensions/azure.ai.loom/test-all.ps1 +++ b/cli/azd/extensions/azure.ai.loom/test-all.ps1 @@ -25,12 +25,6 @@ param( [ValidateRange(1, [int]::MaxValue)] [int] $Take = 10, - [string] $MetricsFile, - [string] $LogsFile, - [string] $TracesFile, - [string] $AgentTracesFile, - [string] $GraphQLFile, - [string] $FileStreamFile, [string] $WandBEntity, [string] $WandBProject, @@ -90,20 +84,304 @@ function Invoke-AzdTest { } } -function Resolve-RequiredFile { +function Write-ProtobufVarint { param( - [string] $ParameterName, - [string] $Path + [System.IO.Stream] $Stream, + [uint64] $Value ) - if ([string]::IsNullOrWhiteSpace($Path)) { - throw "-$ParameterName is required unless -SkipWriteOperations is set." + while ($Value -ge 0x80) { + $Stream.WriteByte([byte](($Value -band 0x7f) -bor 0x80)) + $Value = $Value -shr 7 } - if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { - throw "The file supplied through -$ParameterName does not exist." + $Stream.WriteByte([byte]$Value) +} + +function Write-ProtobufKey { + param( + [System.IO.Stream] $Stream, + [int] $FieldNumber, + [int] $WireType + ) + + Write-ProtobufVarint -Stream $Stream -Value ([uint64](($FieldNumber -shl 3) -bor $WireType)) +} + +function Write-ProtobufBytesField { + param( + [System.IO.Stream] $Stream, + [int] $FieldNumber, + [byte[]] $Value + ) + + Write-ProtobufKey -Stream $Stream -FieldNumber $FieldNumber -WireType 2 + Write-ProtobufVarint -Stream $Stream -Value ([uint64]$Value.Length) + $Stream.Write($Value, 0, $Value.Length) +} + +function Write-ProtobufStringField { + param( + [System.IO.Stream] $Stream, + [int] $FieldNumber, + [string] $Value + ) + + Write-ProtobufBytesField ` + -Stream $Stream ` + -FieldNumber $FieldNumber ` + -Value ([System.Text.Encoding]::UTF8.GetBytes($Value)) +} + +function Write-ProtobufEnumField { + param( + [System.IO.Stream] $Stream, + [int] $FieldNumber, + [uint64] $Value + ) + + Write-ProtobufKey -Stream $Stream -FieldNumber $FieldNumber -WireType 0 + Write-ProtobufVarint -Stream $Stream -Value $Value +} + +function Write-ProtobufFixed64Field { + param( + [System.IO.Stream] $Stream, + [int] $FieldNumber, + [byte[]] $Value + ) + + if ($Value.Length -ne 8) { + throw "A protobuf fixed64 field requires exactly eight bytes." + } + if (-not [System.BitConverter]::IsLittleEndian) { + [array]::Reverse($Value) } - return (Resolve-Path -LiteralPath $Path).Path + Write-ProtobufKey -Stream $Stream -FieldNumber $FieldNumber -WireType 1 + $Stream.Write($Value, 0, $Value.Length) +} + +function New-ProtobufMessage { + param([scriptblock] $WriteFields) + + $stream = [System.IO.MemoryStream]::new() + try { + & $WriteFields $stream + return ,$stream.ToArray() + } + finally { + $stream.Dispose() + } +} + +function Write-Utf8JsonFile { + param( + [string] $Path, + [object] $Value, + [int] $Depth = 12 + ) + + $json = $Value | ConvertTo-Json -Depth $Depth + [System.IO.File]::WriteAllText( + $Path, + $json, + [System.Text.UTF8Encoding]::new($false) + ) +} + +function New-SyntheticTestData { + param( + [string] $Directory, + [string] $RunId, + [string] $ProjectId, + [string] $Entity + ) + + $testId = [guid]::NewGuid().ToString("N") + $traceId = [guid]::NewGuid().ToByteArray() + $spanId = [guid]::NewGuid().ToByteArray()[0..7] + $nowUnixNano = [uint64]( + [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() * 1000000 + ) + $endUnixNano = $nowUnixNano + [uint64]1000000 + + $serviceNameValue = New-ProtobufMessage { + param($stream) + Write-ProtobufStringField -Stream $stream -FieldNumber 1 ` + -Value "azd.ai.loom.smoke-test" + } + $serviceNameAttribute = New-ProtobufMessage { + param($stream) + Write-ProtobufStringField -Stream $stream -FieldNumber 1 -Value "service.name" + Write-ProtobufBytesField -Stream $stream -FieldNumber 2 -Value $serviceNameValue + } + $resource = New-ProtobufMessage { + param($stream) + Write-ProtobufBytesField -Stream $stream -FieldNumber 1 -Value $serviceNameAttribute + } + $scope = New-ProtobufMessage { + param($stream) + Write-ProtobufStringField -Stream $stream -FieldNumber 1 ` + -Value "azd.ai.loom.smoke-test" + } + + $metricDataPoint = New-ProtobufMessage { + param($stream) + Write-ProtobufFixed64Field -Stream $stream -FieldNumber 3 ` + -Value ([System.BitConverter]::GetBytes($nowUnixNano)) + Write-ProtobufFixed64Field -Stream $stream -FieldNumber 4 ` + -Value ([System.BitConverter]::GetBytes([double]1)) + } + $gauge = New-ProtobufMessage { + param($stream) + Write-ProtobufBytesField -Stream $stream -FieldNumber 1 -Value $metricDataPoint + } + $metric = New-ProtobufMessage { + param($stream) + Write-ProtobufStringField -Stream $stream -FieldNumber 1 -Value "azd.loom.synthetic" + Write-ProtobufStringField -Stream $stream -FieldNumber 2 -Value "Synthetic Loom ingestion smoke-test metric" + Write-ProtobufStringField -Stream $stream -FieldNumber 3 -Value "1" + Write-ProtobufBytesField -Stream $stream -FieldNumber 5 -Value $gauge + } + $scopeMetrics = New-ProtobufMessage { + param($stream) + Write-ProtobufBytesField -Stream $stream -FieldNumber 1 -Value $scope + Write-ProtobufBytesField -Stream $stream -FieldNumber 2 -Value $metric + } + $resourceMetrics = New-ProtobufMessage { + param($stream) + Write-ProtobufBytesField -Stream $stream -FieldNumber 1 -Value $resource + Write-ProtobufBytesField -Stream $stream -FieldNumber 2 -Value $scopeMetrics + } + $metricsRequest = New-ProtobufMessage { + param($stream) + Write-ProtobufBytesField -Stream $stream -FieldNumber 1 -Value $resourceMetrics + } + + $logBody = New-ProtobufMessage { + param($stream) + Write-ProtobufStringField -Stream $stream -FieldNumber 1 ` + -Value "Synthetic Loom smoke-test log $testId" + } + $logRecord = New-ProtobufMessage { + param($stream) + Write-ProtobufFixed64Field -Stream $stream -FieldNumber 1 ` + -Value ([System.BitConverter]::GetBytes($nowUnixNano)) + Write-ProtobufEnumField -Stream $stream -FieldNumber 2 -Value 9 + Write-ProtobufStringField -Stream $stream -FieldNumber 3 -Value "INFO" + Write-ProtobufBytesField -Stream $stream -FieldNumber 5 -Value $logBody + Write-ProtobufBytesField -Stream $stream -FieldNumber 9 -Value $traceId + Write-ProtobufBytesField -Stream $stream -FieldNumber 10 -Value $spanId + } + $scopeLogs = New-ProtobufMessage { + param($stream) + Write-ProtobufBytesField -Stream $stream -FieldNumber 1 -Value $scope + Write-ProtobufBytesField -Stream $stream -FieldNumber 2 -Value $logRecord + } + $resourceLogs = New-ProtobufMessage { + param($stream) + Write-ProtobufBytesField -Stream $stream -FieldNumber 1 -Value $resource + Write-ProtobufBytesField -Stream $stream -FieldNumber 2 -Value $scopeLogs + } + $logsRequest = New-ProtobufMessage { + param($stream) + Write-ProtobufBytesField -Stream $stream -FieldNumber 1 -Value $resourceLogs + } + + $span = New-ProtobufMessage { + param($stream) + Write-ProtobufBytesField -Stream $stream -FieldNumber 1 -Value $traceId + Write-ProtobufBytesField -Stream $stream -FieldNumber 2 -Value $spanId + Write-ProtobufStringField -Stream $stream -FieldNumber 5 ` + -Value "azd-loom-synthetic-span" + Write-ProtobufEnumField -Stream $stream -FieldNumber 6 -Value 1 + Write-ProtobufFixed64Field -Stream $stream -FieldNumber 7 ` + -Value ([System.BitConverter]::GetBytes($nowUnixNano)) + Write-ProtobufFixed64Field -Stream $stream -FieldNumber 8 ` + -Value ([System.BitConverter]::GetBytes($endUnixNano)) + } + $scopeSpans = New-ProtobufMessage { + param($stream) + Write-ProtobufBytesField -Stream $stream -FieldNumber 1 -Value $scope + Write-ProtobufBytesField -Stream $stream -FieldNumber 2 -Value $span + } + $resourceSpans = New-ProtobufMessage { + param($stream) + Write-ProtobufBytesField -Stream $stream -FieldNumber 1 -Value $resource + Write-ProtobufBytesField -Stream $stream -FieldNumber 2 -Value $scopeSpans + } + $tracesRequest = New-ProtobufMessage { + param($stream) + Write-ProtobufBytesField -Stream $stream -FieldNumber 1 -Value $resourceSpans + } + + $metricsFile = Join-Path $Directory "metrics.pb" + $logsFile = Join-Path $Directory "logs.pb" + $tracesFile = Join-Path $Directory "traces.pb" + [System.IO.File]::WriteAllBytes($metricsFile, $metricsRequest) + [System.IO.File]::WriteAllBytes($logsFile, $logsRequest) + [System.IO.File]::WriteAllBytes($tracesFile, $tracesRequest) + + $agentTracesFile = Join-Path $Directory "agent-traces.json" + Write-Utf8JsonFile -Path $agentTracesFile -Value @{ + run_id = $RunId + resourceSpans = @( + @{ + resource = @{ + attributes = @( + @{ + key = "service.name" + value = @{ stringValue = "azd.ai.loom.smoke-test" } + } + ) + } + scopeSpans = @( + @{ + scope = @{ name = "azd.ai.loom.smoke-test" } + spans = @( + @{ + traceId = [Convert]::ToHexString($traceId).ToLowerInvariant() + spanId = [Convert]::ToHexString($spanId).ToLowerInvariant() + name = "azd-loom-synthetic-agent-span" + kind = 1 + startTimeUnixNano = $nowUnixNano.ToString() + endTimeUnixNano = $endUnixNano.ToString() + } + ) + } + ) + } + ) + } + + $graphQLFile = Join-Path $Directory "graphql-request.json" + Write-Utf8JsonFile -Path $graphQLFile -Value @{ + query = "query Run(`$entity: String!, `$project: String!, `$name: String!) { project(name: `$project, entityName: `$entity) { run(name: `$name) { name displayName state summaryMetrics } } }" + variables = @{ + entity = $Entity + project = $ProjectId + name = $RunId + } + } + + $fileStreamFile = Join-Path $Directory "file-stream-request.json" + Write-Utf8JsonFile -Path $fileStreamFile -Value @{ + files = @{ + "output.log" = @{ + offset = 0 + content = @("Synthetic Loom smoke-test log $testId`n") + } + } + } + + return [pscustomobject]@{ + MetricsFile = $metricsFile + LogsFile = $logsFile + TracesFile = $tracesFile + AgentTracesFile = $agentTracesFile + GraphQLFile = $graphQLFile + FileStreamFile = $fileStreamFile + } } if ($MaxStep -lt $MinStep) { @@ -121,14 +399,17 @@ if ($null -eq $azdCommand) { throw "Could not find azd. Install azd 1.32.0 or later, or provide -AzdPath." } -if (-not $SkipWriteOperations) { - $MetricsFile = Resolve-RequiredFile -ParameterName "MetricsFile" -Path $MetricsFile - $LogsFile = Resolve-RequiredFile -ParameterName "LogsFile" -Path $LogsFile - $TracesFile = Resolve-RequiredFile -ParameterName "TracesFile" -Path $TracesFile - $AgentTracesFile = Resolve-RequiredFile -ParameterName "AgentTracesFile" -Path $AgentTracesFile - $GraphQLFile = Resolve-RequiredFile -ParameterName "GraphQLFile" -Path $GraphQLFile - $FileStreamFile = Resolve-RequiredFile -ParameterName "FileStreamFile" -Path $FileStreamFile +$projectUri = [uri]$ProjectEndpoint +$resolvedProjectId = $ProjectId +if ([string]::IsNullOrWhiteSpace($resolvedProjectId)) { + $segments = $projectUri.AbsolutePath.Trim("/").Split("/") + if ($segments.Length -lt 3 -or $segments[-2] -ne "projects") { + throw "Could not derive the project ID. Provide a standard project endpoint or -ProjectId." + } + $resolvedProjectId = [uri]::UnescapeDataString($segments[-1]) } +$resolvedWandBEntity = if ($WandBEntity) { $WandBEntity } else { $projectUri.Host.Split(".")[0] } +$resolvedWandBProject = if ($WandBProject) { $WandBProject } else { $resolvedProjectId } $commonArguments = @("--api-version", $ApiVersion, "--output", "json") if (-not [string]::IsNullOrWhiteSpace($ProjectId)) { @@ -138,8 +419,22 @@ if (-not [string]::IsNullOrWhiteSpace($ProjectId)) { $hadProjectEndpoint = Test-Path Env:FOUNDRY_PROJECT_ENDPOINT $previousProjectEndpoint = $env:FOUNDRY_PROJECT_ENDPOINT $env:FOUNDRY_PROJECT_ENDPOINT = $ProjectEndpoint +$syntheticDataDirectory = $null +$syntheticData = $null try { + if (-not $SkipWriteOperations) { + $syntheticDataDirectory = Join-Path ` + ([System.IO.Path]::GetTempPath()) ` + "azd-loom-smoke-$([guid]::NewGuid().ToString("N"))" + [System.IO.Directory]::CreateDirectory($syntheticDataDirectory) | Out-Null + $syntheticData = New-SyntheticTestData ` + -Directory $syntheticDataDirectory ` + -RunId $RunId ` + -ProjectId $resolvedWandBProject ` + -Entity $resolvedWandBEntity + } + if (-not $SkipBuild) { Push-Location $PSScriptRoot try { @@ -186,8 +481,8 @@ try { "ai", "loom", "run", "compare", "--run-id", $RunId, "--run-id", $SecondRunId, - "--min", $MinStep, - "--max", $MaxStep + "--min", $MinStep.ToString([System.Globalization.CultureInfo]::InvariantCulture), + "--max", $MaxStep.ToString([System.Globalization.CultureInfo]::InvariantCulture) ) foreach ($name in $MetricName) { $compareArguments += @("--metric", $name) @@ -246,40 +541,40 @@ try { -CommandArguments (@( "ai", "loom", "run", "ingest", "metrics", "--run-id", $RunId, - "--file", $MetricsFile + "--file", $syntheticData.MetricsFile ) + $commonArguments) Invoke-AzdTest -Name "Ingest OTLP logs" ` -CommandArguments (@( "ai", "loom", "run", "ingest", "logs", "--run-id", $RunId, - "--file", $LogsFile + "--file", $syntheticData.LogsFile ) + $commonArguments) Invoke-AzdTest -Name "Ingest OTLP traces" ` -CommandArguments (@( "ai", "loom", "run", "ingest", "traces", "--run-id", $RunId, - "--file", $TracesFile + "--file", $syntheticData.TracesFile ) + $commonArguments) Invoke-AzdTest -Name "Ingest agent traces" ` -CommandArguments (@( "ai", "loom", "run", "ingest", "agent-traces", "--run-id", $RunId, - "--file", $AgentTracesFile + "--file", $syntheticData.AgentTracesFile ) + $commonArguments) Invoke-AzdTest -Name "Execute W&B GraphQL request" ` -CommandArguments (@( "ai", "loom", "run", "wandb", "graphql", - "--file", $GraphQLFile + "--file", $syntheticData.GraphQLFile ) + $commonArguments) $fileStreamArguments = @( "ai", "loom", "run", "wandb", "file-stream", "--run-id", $RunId, - "--file", $FileStreamFile + "--file", $syntheticData.FileStreamFile ) if (-not [string]::IsNullOrWhiteSpace($WandBEntity)) { $fileStreamArguments += @("--entity", $WandBEntity) @@ -298,6 +593,18 @@ finally { else { Remove-Item Env:FOUNDRY_PROJECT_ENDPOINT -ErrorAction SilentlyContinue } + if ( + $syntheticDataDirectory -and + (Test-Path -LiteralPath $syntheticDataDirectory) -and + ([System.IO.Path]::GetFileName($syntheticDataDirectory) -like "azd-loom-smoke-*") + ) { + try { + [System.IO.Directory]::Delete($syntheticDataDirectory, $true) + } + catch { + Write-Warning "Could not remove temporary synthetic test data: $($_.Exception.Message)" + } + } } Write-Host "" From 29352e800a232883fa41ae0500e098a14b67f42e Mon Sep 17 00:00:00 2001 From: HarshaVardhan Babu Namburi Date: Tue, 1 Sep 2026 16:10:56 +0530 Subject: [PATCH 07/13] Address Loom extension review feedback Preserve authoritative run targeting, surface OTLP partial success, escape dot segments, and complete endpoint fallback documentation and coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/docs/environment-variables.md | 1 + cli/azd/extensions/azure.ai.loom/cspell.yaml | 3 + cli/azd/extensions/azure.ai.loom/go.mod | 5 +- cli/azd/extensions/azure.ai.loom/go.sum | 6 ++ .../internal/cmd/project_resolver.go | 7 +- .../internal/cmd/project_resolver_test.go | 29 +++++ .../azure.ai.loom/internal/cmd/run.go | 82 ++++++++++++-- .../azure.ai.loom/internal/cmd/run_test.go | 100 ++++++++++++++++++ .../internal/experimenttracking/client.go | 14 ++- .../experimenttracking/client_test.go | 32 ++++++ 10 files changed, 269 insertions(+), 10 deletions(-) diff --git a/cli/azd/docs/environment-variables.md b/cli/azd/docs/environment-variables.md index acfa066d2a3..599a772a93b 100644 --- a/cli/azd/docs/environment-variables.md +++ b/cli/azd/docs/environment-variables.md @@ -203,6 +203,7 @@ Metadata requests are unauthenticated when no matching token is set. | `AZURE_AI_PROJECT_ID` | The Microsoft Foundry project resource ID used by the `azure.ai.agents` extension. | | `AZURE_AI_PROJECT_API_KEY` | A Microsoft Foundry account API key accepted by the project data plane and used by `azure.ai.loom` experiment-tracking commands. When set in the host process, it takes precedence over bearer authentication. Do not persist this value in project files or source control. | | `FOUNDRY_PROJECT_ENDPOINT` | The Microsoft Foundry project endpoint used by `azure.ai.projects`, `azure.ai.loom`, and `azure.ai.agents`. The projects and Loom resolvers check the active azd environment before global project configuration and use the host shell environment as their final endpoint fallback. | +| `AZURE_AI_PROJECT_ENDPOINT` | Deprecated compatibility fallback for the Microsoft Foundry project endpoint. The Loom resolver checks it after `FOUNDRY_PROJECT_ENDPOINT` in both the active azd environment and the host shell. | | `AZURE_AI_PROJECT_PRINCIPAL_ID` | The principal ID associated with the Microsoft Foundry project identity. | | `AZURE_AI_ACCOUNT_NAME` | The Microsoft Foundry account name associated with the project. | | `AZURE_AI_PROJECT_NAME` | The Microsoft Foundry project name. | diff --git a/cli/azd/extensions/azure.ai.loom/cspell.yaml b/cli/azd/extensions/azure.ai.loom/cspell.yaml index 0cac30a3a28..5304711b171 100644 --- a/cli/azd/extensions/azure.ai.loom/cspell.yaml +++ b/cli/azd/extensions/azure.ai.loom/cspell.yaml @@ -1,5 +1,8 @@ import: ../../.vscode/cspell.yaml words: + - collectorlogs + - collectormetrics + - collectortrace - exterrors - experimenttracking - filestream diff --git a/cli/azd/extensions/azure.ai.loom/go.mod b/cli/azd/extensions/azure.ai.loom/go.mod index 1d3f52b3b2f..8536dca958d 100644 --- a/cli/azd/extensions/azure.ai.loom/go.mod +++ b/cli/azd/extensions/azure.ai.loom/go.mod @@ -8,7 +8,9 @@ require ( github.com/azure/azure-dev/cli/azd v1.32.0 github.com/spf13/cobra v1.10.1 github.com/stretchr/testify v1.11.1 + go.opentelemetry.io/proto/otlp v1.10.0 google.golang.org/grpc v1.82.1 + google.golang.org/protobuf v1.36.11 ) require ( @@ -51,6 +53,7 @@ require ( github.com/golobby/container/v3 v3.3.2 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/css v1.0.1 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/invopop/jsonschema v0.13.0 // indirect github.com/jmespath-community/go-jmespath v1.1.1 // indirect @@ -98,7 +101,7 @@ 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/api v0.0.0-20260414002931-afd174a4e478 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect - google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/cli/azd/extensions/azure.ai.loom/go.sum b/cli/azd/extensions/azure.ai.loom/go.sum index a84edd2f3ee..e4482103853 100644 --- a/cli/azd/extensions/azure.ai.loom/go.sum +++ b/cli/azd/extensions/azure.ai.loom/go.sum @@ -123,6 +123,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= @@ -243,6 +245,8 @@ go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfC go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -296,6 +300,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc= google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= diff --git a/cli/azd/extensions/azure.ai.loom/internal/cmd/project_resolver.go b/cli/azd/extensions/azure.ai.loom/internal/cmd/project_resolver.go index 2a850b8fd17..ba394c5488c 100644 --- a/cli/azd/extensions/azure.ai.loom/internal/cmd/project_resolver.go +++ b/cli/azd/extensions/azure.ai.loom/internal/cmd/project_resolver.go @@ -85,7 +85,12 @@ func resolveProjectEndpoint( if err != nil { return nil, err } - for _, candidate := range []string{sources.EnvValue, sources.Config.Endpoint, os.Getenv("FOUNDRY_PROJECT_ENDPOINT")} { + for _, candidate := range []string{ + sources.EnvValue, + sources.Config.Endpoint, + os.Getenv("FOUNDRY_PROJECT_ENDPOINT"), + os.Getenv("AZURE_AI_PROJECT_ENDPOINT"), + } { if candidate == "" { continue } diff --git a/cli/azd/extensions/azure.ai.loom/internal/cmd/project_resolver_test.go b/cli/azd/extensions/azure.ai.loom/internal/cmd/project_resolver_test.go index 6ce09572453..68c4731baa0 100644 --- a/cli/azd/extensions/azure.ai.loom/internal/cmd/project_resolver_test.go +++ b/cli/azd/extensions/azure.ai.loom/internal/cmd/project_resolver_test.go @@ -40,6 +40,35 @@ func TestResolveProjectEndpointUsesPersistedProjectContext(t *testing.T) { assert.Equal(t, testProjectEndpoint, resolved.Endpoint) } +func TestResolveProjectEndpointUsesAzureAIHostFallback(t *testing.T) { + t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "") + t.Setenv("AZURE_AI_PROJECT_ENDPOINT", testProjectEndpoint) + + resolved, err := resolveProjectEndpoint(t.Context(), resolveProjectEndpointOpts{ + ReadAzdHostedSources: func(context.Context) (azdHostedSources, error) { + return azdHostedSources{}, nil + }, + }) + + require.NoError(t, err) + assert.Equal(t, testProjectEndpoint, resolved.Endpoint) +} + +func TestResolveProjectEndpointPrefersFoundryHostVariable(t *testing.T) { + foundryEndpoint := "https://foundry.services.ai.azure.com/api/projects/project" + t.Setenv("FOUNDRY_PROJECT_ENDPOINT", foundryEndpoint) + t.Setenv("AZURE_AI_PROJECT_ENDPOINT", testProjectEndpoint) + + resolved, err := resolveProjectEndpoint(t.Context(), resolveProjectEndpointOpts{ + ReadAzdHostedSources: func(context.Context) (azdHostedSources, error) { + return azdHostedSources{}, nil + }, + }) + + require.NoError(t, err) + assert.Equal(t, foundryEndpoint, resolved.Endpoint) +} + func TestValidateProjectEndpointDoesNotDiscloseCredentials(t *testing.T) { credential := "user:secret" _, err := validateProjectEndpoint( diff --git a/cli/azd/extensions/azure.ai.loom/internal/cmd/run.go b/cli/azd/extensions/azure.ai.loom/internal/cmd/run.go index cbf0e830f40..df1cba8e5dc 100644 --- a/cli/azd/extensions/azure.ai.loom/internal/cmd/run.go +++ b/cli/azd/extensions/azure.ai.loom/internal/cmd/run.go @@ -22,6 +22,10 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/spf13/cobra" + collectorlogs "go.opentelemetry.io/proto/otlp/collector/logs/v1" + collectormetrics "go.opentelemetry.io/proto/otlp/collector/metrics/v1" + collectortrace "go.opentelemetry.io/proto/otlp/collector/trace/v1" + "google.golang.org/protobuf/proto" ) const maxExperimentInputBytes = 64 << 20 @@ -472,10 +476,11 @@ func newOTLPIngestCommand(extCtx *azdext.ExtensionContext, signal string) *cobra if err != nil { return classifyExperimentError(err) } - if isJSONObject(response) { - return writeExperimentResponse(cmd, response, nil) + formattedResponse, err := formatOTLPIngestResponse(signal, response) + if err != nil { + return err } - return writeExperimentResponse(cmd, json.RawMessage(`{"status":"accepted"}`), nil) + return writeExperimentResponse(cmd, formattedResponse, nil) }, } addRunFlags(cmd, flags, false) @@ -499,9 +504,7 @@ func newAgentTracesIngestCommand(extCtx *azdext.ExtensionContext) *cobra.Command if err != nil { return err } - if _, found := body["run_id"]; !found { - body["run_id"] = flags.runID - } + setAgentTracesRunID(body, flags.runID) client, err := newExperimentClient(cmd.Context(), flags.experimentFlags) if err != nil { return err @@ -812,6 +815,73 @@ func isJSONObject(data []byte) bool { return json.Unmarshal(data, &object) == nil && object != nil } +func formatOTLPIngestResponse(signal string, response []byte) (json.RawMessage, error) { + if isJSONObject(response) { + if bytes.Equal(bytes.TrimSpace(response), []byte("{}")) { + return json.RawMessage(`{"status":"accepted"}`), nil + } + return response, nil + } + + var partialSuccess map[string]any + switch signal { + case "metrics": + decoded := &collectormetrics.ExportMetricsServiceResponse{} + if err := proto.Unmarshal(response, decoded); err != nil { + return nil, fmt.Errorf("decode OTLP metrics response: %w", err) + } + if partial := decoded.GetPartialSuccess(); partial != nil && + (partial.GetRejectedDataPoints() != 0 || partial.GetErrorMessage() != "") { + partialSuccess = map[string]any{ + "rejected_data_points": partial.GetRejectedDataPoints(), + "error_message": partial.GetErrorMessage(), + } + } + case "logs": + decoded := &collectorlogs.ExportLogsServiceResponse{} + if err := proto.Unmarshal(response, decoded); err != nil { + return nil, fmt.Errorf("decode OTLP logs response: %w", err) + } + if partial := decoded.GetPartialSuccess(); partial != nil && + (partial.GetRejectedLogRecords() != 0 || partial.GetErrorMessage() != "") { + partialSuccess = map[string]any{ + "rejected_log_records": partial.GetRejectedLogRecords(), + "error_message": partial.GetErrorMessage(), + } + } + case "traces": + decoded := &collectortrace.ExportTraceServiceResponse{} + if err := proto.Unmarshal(response, decoded); err != nil { + return nil, fmt.Errorf("decode OTLP traces response: %w", err) + } + if partial := decoded.GetPartialSuccess(); partial != nil && + (partial.GetRejectedSpans() != 0 || partial.GetErrorMessage() != "") { + partialSuccess = map[string]any{ + "rejected_spans": partial.GetRejectedSpans(), + "error_message": partial.GetErrorMessage(), + } + } + default: + return nil, fmt.Errorf("unsupported OTLP signal %q", signal) + } + + if partialSuccess == nil { + return json.RawMessage(`{"status":"accepted"}`), nil + } + formatted, err := json.Marshal(map[string]any{ + "status": "partial_success", + "partial_success": partialSuccess, + }) + if err != nil { + return nil, fmt.Errorf("encode OTLP response: %w", err) + } + return formatted, nil +} + +func setAgentTracesRunID(body map[string]any, runID string) { + body["run_id"] = runID +} + func readNonEmptyExperimentInput(file string) ([]byte, error) { data, err := readExperimentInput(file) if err != nil { diff --git a/cli/azd/extensions/azure.ai.loom/internal/cmd/run_test.go b/cli/azd/extensions/azure.ai.loom/internal/cmd/run_test.go index 695f3402873..95dbfea147c 100644 --- a/cli/azd/extensions/azure.ai.loom/internal/cmd/run_test.go +++ b/cli/azd/extensions/azure.ai.loom/internal/cmd/run_test.go @@ -12,6 +12,10 @@ import ( "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + collectorlogs "go.opentelemetry.io/proto/otlp/collector/logs/v1" + collectormetrics "go.opentelemetry.io/proto/otlp/collector/metrics/v1" + collectortrace "go.opentelemetry.io/proto/otlp/collector/trace/v1" + "google.golang.org/protobuf/proto" ) func TestRootIncludesExperimentTrackingCommands(t *testing.T) { @@ -169,6 +173,102 @@ func TestReadNonEmptyExperimentInputRejectsEmptyPayload(t *testing.T) { require.Error(t, err) } +func TestSetAgentTracesRunIDOverridesPayload(t *testing.T) { + body := map[string]any{"run_id": "payload-run"} + + setAgentTracesRunID(body, "flag-run") + + assert.Equal(t, "flag-run", body["run_id"]) +} + +func TestFormatOTLPIngestResponseIncludesPartialSuccess(t *testing.T) { + tests := []struct { + name string + signal string + response proto.Message + expected string + }{ + { + name: "metrics", + signal: "metrics", + response: &collectormetrics.ExportMetricsServiceResponse{ + PartialSuccess: &collectormetrics.ExportMetricsPartialSuccess{ + RejectedDataPoints: 2, + ErrorMessage: "two points rejected", + }, + }, + expected: `{ + "status": "partial_success", + "partial_success": { + "rejected_data_points": 2, + "error_message": "two points rejected" + } + }`, + }, + { + name: "logs", + signal: "logs", + response: &collectorlogs.ExportLogsServiceResponse{ + PartialSuccess: &collectorlogs.ExportLogsPartialSuccess{ + RejectedLogRecords: 3, + ErrorMessage: "three records rejected", + }, + }, + expected: `{ + "status": "partial_success", + "partial_success": { + "rejected_log_records": 3, + "error_message": "three records rejected" + } + }`, + }, + { + name: "traces", + signal: "traces", + response: &collectortrace.ExportTraceServiceResponse{ + PartialSuccess: &collectortrace.ExportTracePartialSuccess{ + RejectedSpans: 4, + ErrorMessage: "four spans rejected", + }, + }, + expected: `{ + "status": "partial_success", + "partial_success": { + "rejected_spans": 4, + "error_message": "four spans rejected" + } + }`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + response, err := proto.Marshal(test.response) + require.NoError(t, err) + + formatted, err := formatOTLPIngestResponse(test.signal, response) + require.NoError(t, err) + assert.JSONEq(t, test.expected, string(formatted)) + }) + } +} + +func TestFormatOTLPIngestResponseReportsAccepted(t *testing.T) { + formatted, err := formatOTLPIngestResponse("metrics", json.RawMessage(`{}`)) + + require.NoError(t, err) + assert.JSONEq(t, `{"status":"accepted"}`, string(formatted)) + + response, err := proto.Marshal(&collectormetrics.ExportMetricsServiceResponse{ + PartialSuccess: &collectormetrics.ExportMetricsPartialSuccess{}, + }) + require.NoError(t, err) + + formatted, err = formatOTLPIngestResponse("metrics", response) + require.NoError(t, err) + assert.JSONEq(t, `{"status":"accepted"}`, string(formatted)) +} + func TestExperimentCommandsUseJSONOutput(t *testing.T) { commands := []*cobra.Command{ newRunListCommand(nil), diff --git a/cli/azd/extensions/azure.ai.loom/internal/experimenttracking/client.go b/cli/azd/extensions/azure.ai.loom/internal/experimenttracking/client.go index ef516807605..ddeb2ddd9bd 100644 --- a/cli/azd/extensions/azure.ai.loom/internal/experimenttracking/client.go +++ b/cli/azd/extensions/azure.ai.loom/internal/experimenttracking/client.go @@ -13,7 +13,6 @@ import ( "io" "net/http" "net/url" - "path" "strings" "time" @@ -273,7 +272,18 @@ func (c *Client) requestURL(apiPath string, query url.Values) (string, error) { return "", fmt.Errorf("parse project endpoint: %w", err) } - rawPath := path.Join(base.EscapedPath(), "experiment_tracking", apiPath) + escapedAPIPath := strings.TrimPrefix(apiPath, "/") + segments := strings.Split(escapedAPIPath, "/") + for i, segment := range segments { + switch segment { + case ".": + segments[i] = "%2E" + case "..": + segments[i] = "%2E%2E" + } + } + rawPath := strings.TrimRight(base.EscapedPath(), "/") + + "/experiment_tracking/" + strings.Join(segments, "/") decodedPath, err := url.PathUnescape(rawPath) if err != nil { return "", fmt.Errorf("decode request path: %w", err) diff --git a/cli/azd/extensions/azure.ai.loom/internal/experimenttracking/client_test.go b/cli/azd/extensions/azure.ai.loom/internal/experimenttracking/client_test.go index e535e58b2f6..fbaf8187fb6 100644 --- a/cli/azd/extensions/azure.ai.loom/internal/experimenttracking/client_test.go +++ b/cli/azd/extensions/azure.ai.loom/internal/experimenttracking/client_test.go @@ -200,3 +200,35 @@ func TestDoJSONPreservesEscapedPathSegments(t *testing.T) { ) require.NoError(t, err) } + +func TestDoJSONEscapesDotPathSegmentsWithoutCleaning(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal( + t, + "/api/projects/project/experiment_tracking/runs/%2E%2E/metrics", + r.URL.EscapedPath(), + ) + _, _ = io.WriteString(w, `{"ok":true}`) + })) + t.Cleanup(server.Close) + + client, err := newClient( + server.URL+"/api/projects/project", + "", + "", + nil, + "project-key", + server.Client(), + ) + require.NoError(t, err) + + _, err = client.DoJSON( + t.Context(), + http.MethodGet, + "runs/../metrics", + nil, + nil, + nil, + ) + require.NoError(t, err) +} From c067118eb7168d113721279d1a3b4df7663fae53 Mon Sep 17 00:00:00 2001 From: HarshaVardhan Babu Namburi Date: Wed, 2 Sep 2026 10:21:52 +0530 Subject: [PATCH 08/13] Address Loom API review feedback Prevent credential-bearing redirects, harden validation errors, and add command-level HTTP contract coverage for every experiment tracking surface. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/extensions/azure.ai.loom/README.md | 2 +- .../internal/cmd/project_endpoint.go | 2 +- .../internal/cmd/project_resolver_test.go | 11 + .../azure.ai.loom/internal/cmd/run.go | 7 + .../internal/cmd/run_contract_test.go | 350 ++++++++++++++++++ .../azure.ai.loom/internal/cmd/run_test.go | 31 ++ .../internal/experimenttracking/client.go | 13 +- .../experimenttracking/client_test.go | 26 ++ 8 files changed, 438 insertions(+), 4 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.loom/internal/cmd/run_contract_test.go diff --git a/cli/azd/extensions/azure.ai.loom/README.md b/cli/azd/extensions/azure.ai.loom/README.md index b933bded36a..21c34e85c8f 100644 --- a/cli/azd/extensions/azure.ai.loom/README.md +++ b/cli/azd/extensions/azure.ai.loom/README.md @@ -25,7 +25,7 @@ The project endpoint is resolved in this order: 1. `--project-endpoint` 2. `FOUNDRY_PROJECT_ENDPOINT` or `AZURE_AI_PROJECT_ENDPOINT` in the active azd environment 3. The endpoint saved by `azd ai project set ` -4. `FOUNDRY_PROJECT_ENDPOINT` in the host shell +4. `FOUNDRY_PROJECT_ENDPOINT` or `AZURE_AI_PROJECT_ENDPOINT` in the host shell The project ID is derived from `/api/projects/` in the endpoint. Use `--project-id` only when an API-compatible endpoint requires an override. diff --git a/cli/azd/extensions/azure.ai.loom/internal/cmd/project_endpoint.go b/cli/azd/extensions/azure.ai.loom/internal/cmd/project_endpoint.go index e9d36f0c5c5..d76573c6baf 100644 --- a/cli/azd/extensions/azure.ai.loom/internal/cmd/project_endpoint.go +++ b/cli/azd/extensions/azure.ai.loom/internal/cmd/project_endpoint.go @@ -25,7 +25,7 @@ func validateProjectEndpoint(raw string) (string, error) { if err != nil { return "", exterrors.Validation( exterrors.CodeInvalidParameter, - fmt.Sprintf("invalid project endpoint URL: %s", err), + "invalid project endpoint URL", "provide a valid https:// Foundry project endpoint URL", ) } diff --git a/cli/azd/extensions/azure.ai.loom/internal/cmd/project_resolver_test.go b/cli/azd/extensions/azure.ai.loom/internal/cmd/project_resolver_test.go index 68c4731baa0..77ca9630607 100644 --- a/cli/azd/extensions/azure.ai.loom/internal/cmd/project_resolver_test.go +++ b/cli/azd/extensions/azure.ai.loom/internal/cmd/project_resolver_test.go @@ -79,3 +79,14 @@ func TestValidateProjectEndpointDoesNotDiscloseCredentials(t *testing.T) { assert.NotContains(t, err.Error(), credential) assert.NotContains(t, err.Error(), "sensitive") } + +func TestValidateProjectEndpointDoesNotDiscloseMalformedURL(t *testing.T) { + secret := "sensitive" + _, err := validateProjectEndpoint( + "https://account.services.ai.azure.com/api/projects/project%zz?sig=" + secret, + ) + + require.Error(t, err) + assert.NotContains(t, err.Error(), secret) + assert.NotContains(t, err.Error(), "%zz") +} diff --git a/cli/azd/extensions/azure.ai.loom/internal/cmd/run.go b/cli/azd/extensions/azure.ai.loom/internal/cmd/run.go index df1cba8e5dc..b99f6cce9fc 100644 --- a/cli/azd/extensions/azure.ai.loom/internal/cmd/run.go +++ b/cli/azd/extensions/azure.ai.loom/internal/cmd/run.go @@ -10,6 +10,7 @@ import ( "errors" "fmt" "io" + "math" "net/http" "net/url" "os" @@ -332,6 +333,12 @@ func newRunCompareCommand(extCtx *azdext.ExtensionContext) *cobra.Command { if len(metricNames) == 0 { return invalidExperimentParameter("metric", "provide at least one metric name") } + if math.IsNaN(minStep) || math.IsInf(minStep, 0) { + return invalidExperimentParameter("min", "--min must be a finite number") + } + if math.IsNaN(maxStep) || math.IsInf(maxStep, 0) { + return invalidExperimentParameter("max", "--max must be a finite number") + } if maxStep < minStep { return invalidExperimentParameter("max", "--max must be greater than or equal to --min") } diff --git a/cli/azd/extensions/azure.ai.loom/internal/cmd/run_contract_test.go b/cli/azd/extensions/azure.ai.loom/internal/cmd/run_contract_test.go new file mode 100644 index 00000000000..a314054c39d --- /dev/null +++ b/cli/azd/extensions/azure.ai.loom/internal/cmd/run_contract_test.go @@ -0,0 +1,350 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "bytes" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "slices" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return f(request) +} + +type capturedExperimentRequest struct { + method string + path string + query url.Values + headers http.Header + body []byte +} + +func TestExperimentCommandHTTPContracts(t *testing.T) { + t.Setenv(experimentAPIKeyEnv, "test-api-key") + t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "") + t.Setenv("AZURE_AI_PROJECT_ENDPOINT", "") + + testDir := t.TempDir() + protobufFile := filepath.Join(testDir, "payload.pb") + agentTracesFile := filepath.Join(testDir, "agent-traces.json") + graphQLFile := filepath.Join(testDir, "graphql.json") + fileStreamFile := filepath.Join(testDir, "file-stream.json") + require.NoError(t, os.WriteFile(protobufFile, []byte{0x0a, 0x00}, 0o600)) + require.NoError(t, os.WriteFile( + agentTracesFile, + []byte(`{"run_id":"payload-run","resourceSpans":[]}`), + 0o600, + )) + require.NoError(t, os.WriteFile( + graphQLFile, + []byte(`{"query":"query { viewer { id } }"}`), + 0o600, + )) + require.NoError(t, os.WriteFile( + fileStreamFile, + []byte(`{"files":{"output.log":{"offset":0,"content":["hello"]}}}`), + 0o600, + )) + + tests := []struct { + name string + args []string + method string + path string + query url.Values + headers http.Header + jsonBody string + protobufBody []byte + }{ + { + name: "list runs", + args: []string{"ai", "loom", "run", "list", "--take", "5"}, + method: http.MethodGet, + path: "/api/projects/project/experiment_tracking/runs", + query: url.Values{"take": {"5"}}, + }, + { + name: "history keys", + args: []string{"ai", "loom", "run", "history-keys", "--run-id", "run-one"}, + method: http.MethodGet, + path: "/api/projects/project/experiment_tracking/runs/run-one/history/keys", + }, + { + name: "summary", + args: []string{"ai", "loom", "run", "summary", "--run-id", "run-one", "--take", "5"}, + method: http.MethodGet, + path: "/api/projects/project/experiment_tracking/runs/run-one/summary", + query: url.Values{"take": {"5"}}, + }, + { + name: "metrics", + args: []string{"ai", "loom", "run", "metrics", "--run-id", "run-one", "--take", "5"}, + method: http.MethodGet, + path: "/api/projects/project/experiment_tracking/runs/run-one/metrics", + query: url.Values{"take": {"5"}}, + headers: runContractHeaders("run-one"), + }, + { + name: "system metrics", + args: []string{ + "ai", "loom", "run", "system-metrics", + "--run-id", "run-one", + "--name", "cpu", + "--name", "memory", + "--take", "5", + }, + method: http.MethodGet, + path: "/api/projects/project/experiment_tracking/runs/run-one/system-metrics", + query: url.Values{"names": {"cpu", "memory"}, "take": {"5"}}, + }, + { + name: "logs", + args: []string{"ai", "loom", "run", "logs", "--run-id", "run-one", "--take", "5"}, + method: http.MethodGet, + path: "/api/projects/project/experiment_tracking/runs/run-one/logs", + query: url.Values{"take": {"5"}}, + }, + { + name: "log records", + args: []string{"ai", "loom", "run", "log-records", "--run-id", "run-one", "--take", "5"}, + method: http.MethodGet, + path: "/api/projects/project/experiment_tracking/runs/run-one/log-records", + query: url.Values{"take": {"5"}}, + }, + { + name: "compare", + args: []string{ + "ai", "loom", "run", "compare", + "--run-id", "run-one", + "--run-id", "run-two", + "--metric", "loss", + "--min", "1", + "--max", "5", + }, + method: http.MethodPost, + path: "/api/projects/project/experiment_tracking/runs/compare", + jsonBody: `{"runIds":["run-one","run-two"],"metricNames":["loss"],"min":1,"max":5}`, + }, + { + name: "list traces", + args: []string{"ai", "loom", "run", "trace", "list", "--run-id", "run-one", "--take", "5"}, + method: http.MethodGet, + path: "/api/projects/project/experiment_tracking/runs/run-one/traces", + query: url.Values{"take": {"5"}}, + }, + { + name: "show trace", + args: []string{ + "ai", "loom", "run", "trace", "show", + "--run-id", "run-one", + "--trace-id", "trace-one", + }, + method: http.MethodGet, + path: "/api/projects/project/experiment_tracking/runs/run-one/traces/trace-one", + }, + { + name: "trace chat", + args: []string{ + "ai", "loom", "run", "trace", "chat", + "--run-id", "run-one", + "--trace-id", "trace-one", + }, + method: http.MethodPost, + path: "/api/projects/project/experiment_tracking/runs/run-one/agents/traces/chat", + headers: runContractHeaders("run-one"), + jsonBody: `{"project_id":"project","trace_id":"trace-one"}`, + }, + { + name: "query spans", + args: []string{ + "ai", "loom", "run", "span", "query", + "--run-id", "run-one", + "--filter", `{"$expr":true}`, + "--include-details", + "--limit", "7", + }, + method: http.MethodPost, + path: "/api/projects/project/experiment_tracking/runs/run-one/agents/spans/query", + headers: runContractHeaders("run-one"), + jsonBody: `{ + "project_id":"project", + "query":{"$expr":true}, + "include_details":true, + "limit":7 + }`, + }, + { + name: "ingest metrics", + args: []string{ + "ai", "loom", "run", "ingest", "metrics", + "--run-id", "run-one", + "--file", protobufFile, + }, + method: http.MethodPost, + path: "/api/projects/project/experiment_tracking/protocols/otlp/v1/metrics", + headers: protobufContractHeaders("run-one"), + protobufBody: []byte{0x0a, 0x00}, + }, + { + name: "ingest logs", + args: []string{ + "ai", "loom", "run", "ingest", "logs", + "--run-id", "run-one", + "--file", protobufFile, + }, + method: http.MethodPost, + path: "/api/projects/project/experiment_tracking/protocols/otlp/v1/logs", + headers: protobufContractHeaders("run-one"), + protobufBody: []byte{0x0a, 0x00}, + }, + { + name: "ingest traces", + args: []string{ + "ai", "loom", "run", "ingest", "traces", + "--run-id", "run-one", + "--file", protobufFile, + }, + method: http.MethodPost, + path: "/api/projects/project/experiment_tracking/protocols/otlp/v1/traces", + headers: protobufContractHeaders("run-one"), + protobufBody: []byte{0x0a, 0x00}, + }, + { + name: "ingest agent traces", + args: []string{ + "ai", "loom", "run", "ingest", "agent-traces", + "--run-id", "run-one", + "--file", agentTracesFile, + }, + method: http.MethodPost, + path: "/api/projects/project/experiment_tracking/agents/otel/v1/traces", + jsonBody: `{"run_id":"run-one","resourceSpans":[]}`, + }, + { + name: "W&B GraphQL", + args: []string{ + "ai", "loom", "run", "wandb", "graphql", + "--file", graphQLFile, + }, + method: http.MethodPost, + path: "/api/projects/project/experiment_tracking/graphql", + jsonBody: `{"query":"query { viewer { id } }"}`, + }, + { + name: "W&B file stream", + args: []string{ + "ai", "loom", "run", "wandb", "file-stream", + "--run-id", "run-one", + "--entity", "entity-one", + "--wandb-project", "wandb-project", + "--file", fileStreamFile, + }, + method: http.MethodPost, + path: "/api/projects/project/experiment_tracking/files/entity-one/wandb-project/run-one/file_stream", + jsonBody: `{"files":{"output.log":{"offset":0,"content":["hello"]}}}`, + }, + } + + originalTransport := http.DefaultTransport + t.Cleanup(func() { + http.DefaultTransport = originalTransport + }) + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var captured capturedExperimentRequest + http.DefaultTransport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + var body []byte + if request.Body != nil { + var err error + body, err = io.ReadAll(request.Body) + require.NoError(t, err) + } + captured = capturedExperimentRequest{ + method: request.Method, + path: request.URL.EscapedPath(), + query: request.URL.Query(), + headers: request.Header.Clone(), + body: body, + } + + responseBody := []byte(`{}`) + if test.protobufBody != nil { + responseBody = nil + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(bytes.NewReader(responseBody)), + Request: request, + }, nil + }) + + command := NewRootCommand() + command.SetArgs(append( + test.args[2:], + "--project-endpoint", + "https://account.services.ai.azure.com/api/projects/project", + )) + command.SetOut(&bytes.Buffer{}) + command.SetErr(&bytes.Buffer{}) + + require.NoError(t, command.Execute()) + assert.Equal(t, test.method, captured.method) + assert.Equal(t, test.path, captured.path) + + expectedQuery := make(url.Values, len(test.query)+1) + for key, values := range test.query { + expectedQuery[key] = slices.Clone(values) + } + expectedQuery.Set("api-version", "v1") + assert.Equal(t, expectedQuery, captured.query) + assert.Equal(t, "test-api-key", captured.headers.Get("api-key")) + assert.Empty(t, captured.headers.Get("Authorization")) + + for key, expected := range test.headers { + assert.Equal(t, expected, captured.headers.Values(key), "header %s", key) + } + if test.jsonBody != "" { + assert.Equal(t, "application/json", captured.headers.Get("Accept")) + assert.Equal(t, "application/json", captured.headers.Get("Content-Type")) + assert.JSONEq(t, test.jsonBody, string(captured.body)) + } else if test.protobufBody != nil { + assert.Equal(t, "application/x-protobuf", captured.headers.Get("Accept")) + assert.Equal(t, "application/x-protobuf", captured.headers.Get("Content-Type")) + assert.Equal(t, test.protobufBody, captured.body) + } else { + assert.Equal(t, "application/json", captured.headers.Get("Accept")) + assert.Empty(t, captured.headers.Get("Content-Type")) + assert.Empty(t, captured.body) + } + }) + } +} + +func runContractHeaders(runID string) http.Header { + return http.Header{ + "X-Wandb-Username": {"account"}, + "X-Helios-Project-Id": {"project"}, + "X-Helios-Run-Id": {runID}, + } +} + +func protobufContractHeaders(runID string) http.Header { + headers := runContractHeaders(runID) + headers.Set("Accept", "application/x-protobuf") + headers.Set("Content-Type", "application/x-protobuf") + return headers +} diff --git a/cli/azd/extensions/azure.ai.loom/internal/cmd/run_test.go b/cli/azd/extensions/azure.ai.loom/internal/cmd/run_test.go index 95dbfea147c..6721eb3169f 100644 --- a/cli/azd/extensions/azure.ai.loom/internal/cmd/run_test.go +++ b/cli/azd/extensions/azure.ai.loom/internal/cmd/run_test.go @@ -5,6 +5,7 @@ package cmd import ( "encoding/json" + "io" "os" "path/filepath" "testing" @@ -133,6 +134,36 @@ func TestBuildSpanQueryBody(t *testing.T) { }`, string(data)) } +func TestRunCompareRejectsNonFiniteBounds(t *testing.T) { + for _, test := range []struct { + name string + flag string + value string + }{ + {name: "minimum NaN", flag: "min", value: "NaN"}, + {name: "minimum infinity", flag: "min", value: "Inf"}, + {name: "maximum NaN", flag: "max", value: "NaN"}, + {name: "maximum infinity", flag: "max", value: "-Inf"}, + } { + t.Run(test.name, func(t *testing.T) { + command := newRunCompareCommand(nil) + command.SetOut(io.Discard) + command.SetErr(io.Discard) + command.SetArgs([]string{ + "--run-id", "run-one", + "--run-id", "run-two", + "--metric", "loss", + "--" + test.flag, test.value, + }) + + err := command.Execute() + + require.Error(t, err) + assert.Contains(t, err.Error(), "finite number") + }) + } +} + func TestReadJSONObjectPreservesLargeInteger(t *testing.T) { requestPath := filepath.Join(t.TempDir(), "request.json") require.NoError(t, os.WriteFile( diff --git a/cli/azd/extensions/azure.ai.loom/internal/experimenttracking/client.go b/cli/azd/extensions/azure.ai.loom/internal/experimenttracking/client.go index ddeb2ddd9bd..da1cc38c4d1 100644 --- a/cli/azd/extensions/azure.ai.loom/internal/experimenttracking/client.go +++ b/cli/azd/extensions/azure.ai.loom/internal/experimenttracking/client.go @@ -52,7 +52,7 @@ func NewClient( apiVersion, credential, "", - &http.Client{Timeout: defaultTimeout}, + newHTTPClient(), ) } @@ -70,10 +70,19 @@ func NewClientWithAPIKey( apiVersion, nil, apiKey, - &http.Client{Timeout: defaultTimeout}, + newHTTPClient(), ) } +func newHTTPClient() *http.Client { + return &http.Client{ + Timeout: defaultTimeout, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + } +} + func newClient( projectEndpoint string, projectIDOverride string, diff --git a/cli/azd/extensions/azure.ai.loom/internal/experimenttracking/client_test.go b/cli/azd/extensions/azure.ai.loom/internal/experimenttracking/client_test.go index fbaf8187fb6..8b0d9def4ae 100644 --- a/cli/azd/extensions/azure.ai.loom/internal/experimenttracking/client_test.go +++ b/cli/azd/extensions/azure.ai.loom/internal/experimenttracking/client_test.go @@ -169,6 +169,32 @@ func TestDoJSONUsesAPIKeyAuthentication(t *testing.T) { require.NoError(t, err) } +func TestDoJSONDoesNotFollowRedirectsWithAPIKey(t *testing.T) { + redirectTargetCalled := false + redirectTarget := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + redirectTargetCalled = true + })) + t.Cleanup(redirectTarget.Close) + + redirectSource := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "project-key", r.Header.Get("api-key")) + http.Redirect(w, r, redirectTarget.URL, http.StatusFound) + })) + t.Cleanup(redirectSource.Close) + + client, err := NewClientWithAPIKey( + redirectSource.URL+"/api/projects/project", + "", + "", + "project-key", + ) + require.NoError(t, err) + + _, err = client.DoJSON(t.Context(), http.MethodGet, "runs", nil, nil, nil) + require.Error(t, err) + assert.False(t, redirectTargetCalled) +} + func TestDoJSONPreservesEscapedPathSegments(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal( From f87775140bbc7de7f60a1f9338f9f6b1153a6c02 Mon Sep 17 00:00:00 2001 From: HarshaVardhan Babu Namburi Date: Wed, 2 Sep 2026 10:58:43 +0530 Subject: [PATCH 09/13] Harden Loom validation and cancellation Reject endpoint dot segments, use payload-format-neutral guidance, and classify canceled experiment requests as user cancellations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../internal/cmd/project_endpoint.go | 9 ++++++ .../internal/cmd/project_resolver_test.go | 15 +++++++++ .../azure.ai.loom/internal/cmd/run.go | 5 ++- .../azure.ai.loom/internal/cmd/run_test.go | 19 +++++++++++ .../azure.ai.loom/internal/exterrors/codes.go | 1 + .../internal/exterrors/errors.go | 31 ++++++++++++++++++ .../internal/exterrors/errors_test.go | 32 +++++++++++++++++++ 7 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 cli/azd/extensions/azure.ai.loom/internal/exterrors/errors_test.go diff --git a/cli/azd/extensions/azure.ai.loom/internal/cmd/project_endpoint.go b/cli/azd/extensions/azure.ai.loom/internal/cmd/project_endpoint.go index d76573c6baf..5548040d6b2 100644 --- a/cli/azd/extensions/azure.ai.loom/internal/cmd/project_endpoint.go +++ b/cli/azd/extensions/azure.ai.loom/internal/cmd/project_endpoint.go @@ -51,6 +51,15 @@ func validateProjectEndpoint(raw string) (string, error) { "provide the base Foundry project endpoint", ) } + for segment := range strings.SplitSeq(endpoint.Path, "/") { + if segment == "." || segment == ".." { + return "", exterrors.Validation( + exterrors.CodeInvalidParameter, + "project endpoint path must not contain dot segments", + "provide a standard Foundry project endpoint path", + ) + } + } path := strings.TrimRight(endpoint.EscapedPath(), "/") return fmt.Sprintf("https://%s%s", host, path), nil diff --git a/cli/azd/extensions/azure.ai.loom/internal/cmd/project_resolver_test.go b/cli/azd/extensions/azure.ai.loom/internal/cmd/project_resolver_test.go index 77ca9630607..78277bf98c3 100644 --- a/cli/azd/extensions/azure.ai.loom/internal/cmd/project_resolver_test.go +++ b/cli/azd/extensions/azure.ai.loom/internal/cmd/project_resolver_test.go @@ -90,3 +90,18 @@ func TestValidateProjectEndpointDoesNotDiscloseMalformedURL(t *testing.T) { assert.NotContains(t, err.Error(), secret) assert.NotContains(t, err.Error(), "%zz") } + +func TestValidateProjectEndpointRejectsDotSegments(t *testing.T) { + for _, endpoint := range []string{ + "https://account.services.ai.azure.com/api/projects/.", + "https://account.services.ai.azure.com/api/projects/..", + "https://account.services.ai.azure.com/api/projects/%2e%2e", + } { + t.Run(endpoint, func(t *testing.T) { + _, err := validateProjectEndpoint(endpoint) + + require.Error(t, err) + assert.Contains(t, err.Error(), "dot segments") + }) + } +} diff --git a/cli/azd/extensions/azure.ai.loom/internal/cmd/run.go b/cli/azd/extensions/azure.ai.loom/internal/cmd/run.go index b99f6cce9fc..ad443449c90 100644 --- a/cli/azd/extensions/azure.ai.loom/internal/cmd/run.go +++ b/cli/azd/extensions/azure.ai.loom/internal/cmd/run.go @@ -714,6 +714,9 @@ func classifyExperimentError(err error) error { if err == nil { return nil } + if exterrors.IsCancellation(err) { + return exterrors.Cancelled("experiment request was cancelled") + } if strings.Contains(strings.ToLower(err.Error()), "access token") { return exterrors.Auth( exterrors.CodeAuthenticationFailed, @@ -962,6 +965,6 @@ func invalidExperimentPayload(message string) error { return exterrors.Validation( exterrors.CodeInvalidExperimentPayload, message, - "provide a valid JSON object using the documented request schema", + "provide a valid payload using the format and schema documented by the command", ) } diff --git a/cli/azd/extensions/azure.ai.loom/internal/cmd/run_test.go b/cli/azd/extensions/azure.ai.loom/internal/cmd/run_test.go index 6721eb3169f..fda6caa2b90 100644 --- a/cli/azd/extensions/azure.ai.loom/internal/cmd/run_test.go +++ b/cli/azd/extensions/azure.ai.loom/internal/cmd/run_test.go @@ -4,12 +4,18 @@ package cmd import ( + "context" "encoding/json" + "errors" + "fmt" "io" "os" "path/filepath" "testing" + "azure.ai.loom/internal/exterrors" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -202,6 +208,19 @@ func TestReadNonEmptyExperimentInputRejectsEmptyPayload(t *testing.T) { _, err := readNonEmptyExperimentInput(payloadPath) require.Error(t, err) + localErr, ok := errors.AsType[*azdext.LocalError](err) + require.True(t, ok) + assert.NotContains(t, localErr.Suggestion, "JSON") + assert.Contains(t, localErr.Suggestion, "format") +} + +func TestClassifyExperimentErrorPreservesCancellation(t *testing.T) { + err := classifyExperimentError(fmt.Errorf("acquire Foundry access token: %w", context.Canceled)) + + localErr, ok := errors.AsType[*azdext.LocalError](err) + require.True(t, ok) + assert.Equal(t, azdext.LocalErrorCategoryUser, localErr.Category) + assert.Equal(t, exterrors.CodeCancelled, localErr.Code) } func TestSetAgentTracesRunIDOverridesPayload(t *testing.T) { diff --git a/cli/azd/extensions/azure.ai.loom/internal/exterrors/codes.go b/cli/azd/extensions/azure.ai.loom/internal/exterrors/codes.go index 5d23936986e..5f623a91569 100644 --- a/cli/azd/extensions/azure.ai.loom/internal/exterrors/codes.go +++ b/cli/azd/extensions/azure.ai.loom/internal/exterrors/codes.go @@ -4,6 +4,7 @@ package exterrors const ( + CodeCancelled = "cancelled" CodeInvalidParameter = "invalid_parameter" CodeInvalidExperimentPayload = "invalid_experiment_payload" CodeMissingProjectEndpoint = "missing_project_endpoint" diff --git a/cli/azd/extensions/azure.ai.loom/internal/exterrors/errors.go b/cli/azd/extensions/azure.ai.loom/internal/exterrors/errors.go index ee58ae9f8c5..03371161867 100644 --- a/cli/azd/extensions/azure.ai.loom/internal/exterrors/errors.go +++ b/cli/azd/extensions/azure.ai.loom/internal/exterrors/errors.go @@ -5,11 +5,14 @@ package exterrors import ( + "context" "errors" "fmt" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) // Validation returns an input-validation error. @@ -51,6 +54,20 @@ func Internal(code, message string) error { } } +// User returns an error caused by a user action. +func User(code, message string) error { + return &azdext.LocalError{ + Message: message, + Code: code, + Category: azdext.LocalErrorCategoryUser, + } +} + +// Cancelled returns a user cancellation error. +func Cancelled(message string) error { + return User(CodeCancelled, message) +} + // ServiceFromAzure converts an Azure response error into an extension service error. func ServiceFromAzure(err error, operation string) error { if responseErr, ok := errors.AsType[*azcore.ResponseError](err); ok { @@ -69,5 +86,19 @@ func ServiceFromAzure(err error, operation string) error { ServiceName: serviceName, } } + if IsCancellation(err) { + return Cancelled(fmt.Sprintf("%s was cancelled", operation)) + } return Internal(operation, fmt.Sprintf("%s: %s", operation, err)) } + +// IsCancellation reports whether err represents user cancellation. +func IsCancellation(err error) bool { + if errors.Is(err, context.Canceled) { + return true + } + if grpcStatus, ok := status.FromError(err); ok { + return grpcStatus.Code() == codes.Canceled + } + return false +} diff --git a/cli/azd/extensions/azure.ai.loom/internal/exterrors/errors_test.go b/cli/azd/extensions/azure.ai.loom/internal/exterrors/errors_test.go new file mode 100644 index 00000000000..e12d24ae5e4 --- /dev/null +++ b/cli/azd/extensions/azure.ai.loom/internal/exterrors/errors_test.go @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package exterrors + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestServiceFromAzureClassifiesCancellation(t *testing.T) { + for _, err := range []error{ + context.Canceled, + fmt.Errorf("send request: %w", context.Canceled), + status.Error(codes.Canceled, "cancelled"), + } { + result := ServiceFromAzure(err, OpExperimentRequest) + + localErr, ok := errors.AsType[*azdext.LocalError](result) + require.True(t, ok) + assert.Equal(t, azdext.LocalErrorCategoryUser, localErr.Category) + assert.Equal(t, CodeCancelled, localErr.Code) + } +} From 64eb3fd72250b2e39e7558f445cba7204af9da88 Mon Sep 17 00:00:00 2001 From: HarshaVardhan Babu Namburi Date: Wed, 2 Sep 2026 12:00:18 +0530 Subject: [PATCH 10/13] Address Loom comparison and ingestion feedback Preserve optional comparison bounds, extend ingestion timeouts, make required-flag validation deterministic, and remove an orphaned projects error code. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azure.ai.loom/internal/cmd/run.go | 73 ++++++++++++------ .../internal/cmd/run_contract_test.go | 25 ++++++ .../azure.ai.loom/internal/cmd/run_test.go | 25 ++++++ .../internal/experimenttracking/client.go | 60 ++++++++++++++- .../experimenttracking/client_test.go | 77 +++++++++++++++++++ .../internal/exterrors/codes.go | 1 - 6 files changed, 234 insertions(+), 27 deletions(-) diff --git a/cli/azd/extensions/azure.ai.loom/internal/cmd/run.go b/cli/azd/extensions/azure.ai.loom/internal/cmd/run.go index ad443449c90..0e0c4920bde 100644 --- a/cli/azd/extensions/azure.ai.loom/internal/cmd/run.go +++ b/cli/azd/extensions/azure.ai.loom/internal/cmd/run.go @@ -237,7 +237,10 @@ func newRunTraceShowCommand(extCtx *azdext.ExtensionContext) *cobra.Command { Short: "Get one trace and its details.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - if err := requireValues(map[string]string{"run-id": flags.runID, "trace-id": traceID}); err != nil { + if err := requireValue("run-id", flags.runID); err != nil { + return err + } + if err := requireValue("trace-id", traceID); err != nil { return err } client, err := newExperimentClient(cmd.Context(), flags.experimentFlags) @@ -333,13 +336,15 @@ func newRunCompareCommand(extCtx *azdext.ExtensionContext) *cobra.Command { if len(metricNames) == 0 { return invalidExperimentParameter("metric", "provide at least one metric name") } - if math.IsNaN(minStep) || math.IsInf(minStep, 0) { + minChanged := cmd.Flags().Changed("min") + maxChanged := cmd.Flags().Changed("max") + if minChanged && (math.IsNaN(minStep) || math.IsInf(minStep, 0)) { return invalidExperimentParameter("min", "--min must be a finite number") } - if math.IsNaN(maxStep) || math.IsInf(maxStep, 0) { + if maxChanged && (math.IsNaN(maxStep) || math.IsInf(maxStep, 0)) { return invalidExperimentParameter("max", "--max must be a finite number") } - if maxStep < minStep { + if minChanged && maxChanged && maxStep < minStep { return invalidExperimentParameter("max", "--max must be greater than or equal to --min") } client, err := newExperimentClient(cmd.Context(), *flags) @@ -349,8 +354,12 @@ func newRunCompareCommand(extCtx *azdext.ExtensionContext) *cobra.Command { body := map[string]any{ "runIds": runIDs, "metricNames": metricNames, - "min": minStep, - "max": maxStep, + } + if minChanged { + body["min"] = minStep + } + if maxChanged { + body["max"] = maxStep } return executeExperimentJSON(cmd, client, http.MethodPost, "runs/compare", nil, nil, body) }, @@ -460,7 +469,10 @@ func newOTLPIngestCommand(extCtx *azdext.ExtensionContext, signal string) *cobra Short: fmt.Sprintf("Ingest OTLP %s from a protobuf file or stdin.", signal), Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - if err := requireValues(map[string]string{"run-id": flags.runID, "file": file}); err != nil { + if err := requireValue("run-id", flags.runID); err != nil { + return err + } + if err := requireValue("file", file); err != nil { return err } payload, err := readNonEmptyExperimentInput(file) @@ -504,7 +516,10 @@ func newAgentTracesIngestCommand(extCtx *azdext.ExtensionContext) *cobra.Command Short: "Ingest agent OTEL traces from a JSON file or stdin.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - if err := requireValues(map[string]string{"run-id": flags.runID, "file": file}); err != nil { + if err := requireValue("run-id", flags.runID); err != nil { + return err + } + if err := requireValue("file", file); err != nil { return err } body, err := readJSONObject(file) @@ -516,7 +531,15 @@ func newAgentTracesIngestCommand(extCtx *azdext.ExtensionContext) *cobra.Command if err != nil { return err } - return executeExperimentJSON(cmd, client, http.MethodPost, "agents/otel/v1/traces", nil, nil, body) + return executeExperimentIngestionJSON( + cmd, + client, + http.MethodPost, + "agents/otel/v1/traces", + nil, + nil, + body, + ) }, } addRunFlags(cmd, flags, false) @@ -574,10 +597,10 @@ func newWandBFileStreamCommand(extCtx *azdext.ExtensionContext) *cobra.Command { Short: "Send a W&B-compatible FileStream payload.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - if err := requireValues(map[string]string{ - "run-id": flags.runID, - "file": file, - }); err != nil { + if err := requireValue("run-id", flags.runID); err != nil { + return err + } + if err := requireValue("file", file); err != nil { return err } body, err := readJSONObject(file) @@ -600,7 +623,7 @@ func newWandBFileStreamCommand(extCtx *azdext.ExtensionContext) *cobra.Command { url.PathEscape(project), url.PathEscape(flags.runID), ) - return executeExperimentJSON(cmd, client, http.MethodPost, apiPath, nil, nil, body) + return executeExperimentIngestionJSON(cmd, client, http.MethodPost, apiPath, nil, nil, body) }, } addRunFlags(cmd, flags, false) @@ -710,6 +733,19 @@ func executeExperimentJSON( return writeExperimentResponse(cmd, response, classifyExperimentError(err)) } +func executeExperimentIngestionJSON( + cmd *cobra.Command, + client *experimenttracking.Client, + method string, + apiPath string, + query url.Values, + headers http.Header, + body any, +) error { + response, err := client.DoJSONIngestion(cmd.Context(), method, apiPath, query, headers, body) + return writeExperimentResponse(cmd, response, classifyExperimentError(err)) +} + func classifyExperimentError(err error) error { if err == nil { return nil @@ -944,15 +980,6 @@ func requireValue(name string, value string) error { return nil } -func requireValues(values map[string]string) error { - for name, value := range values { - if err := requireValue(name, value); err != nil { - return err - } - } - return nil -} - func invalidExperimentParameter(name string, message string) error { return exterrors.Validation( exterrors.CodeInvalidParameter, diff --git a/cli/azd/extensions/azure.ai.loom/internal/cmd/run_contract_test.go b/cli/azd/extensions/azure.ai.loom/internal/cmd/run_contract_test.go index a314054c39d..b092f9b621c 100644 --- a/cli/azd/extensions/azure.ai.loom/internal/cmd/run_contract_test.go +++ b/cli/azd/extensions/azure.ai.loom/internal/cmd/run_contract_test.go @@ -137,6 +137,31 @@ func TestExperimentCommandHTTPContracts(t *testing.T) { path: "/api/projects/project/experiment_tracking/runs/compare", jsonBody: `{"runIds":["run-one","run-two"],"metricNames":["loss"],"min":1,"max":5}`, }, + { + name: "compare without bounds", + args: []string{ + "ai", "loom", "run", "compare", + "--run-id", "run-one", + "--run-id", "run-two", + "--metric", "loss", + }, + method: http.MethodPost, + path: "/api/projects/project/experiment_tracking/runs/compare", + jsonBody: `{"runIds":["run-one","run-two"],"metricNames":["loss"]}`, + }, + { + name: "compare with minimum only", + args: []string{ + "ai", "loom", "run", "compare", + "--run-id", "run-one", + "--run-id", "run-two", + "--metric", "loss", + "--min", "1", + }, + method: http.MethodPost, + path: "/api/projects/project/experiment_tracking/runs/compare", + jsonBody: `{"runIds":["run-one","run-two"],"metricNames":["loss"],"min":1}`, + }, { name: "list traces", args: []string{"ai", "loom", "run", "trace", "list", "--run-id", "run-one", "--take", "5"}, diff --git a/cli/azd/extensions/azure.ai.loom/internal/cmd/run_test.go b/cli/azd/extensions/azure.ai.loom/internal/cmd/run_test.go index fda6caa2b90..78307152d4c 100644 --- a/cli/azd/extensions/azure.ai.loom/internal/cmd/run_test.go +++ b/cli/azd/extensions/azure.ai.loom/internal/cmd/run_test.go @@ -170,6 +170,31 @@ func TestRunCompareRejectsNonFiniteBounds(t *testing.T) { } } +func TestRequiredFlagsAreValidatedInOrder(t *testing.T) { + tests := []struct { + name string + args []string + missing string + }{ + {name: "all missing", missing: "--run-id"}, + {name: "second missing", args: []string{"--run-id", "run-one"}, missing: "--trace-id"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + command := newRunTraceShowCommand(nil) + command.SetOut(io.Discard) + command.SetErr(io.Discard) + command.SetArgs(test.args) + + err := command.Execute() + + require.Error(t, err) + assert.Contains(t, err.Error(), test.missing) + }) + } +} + func TestReadJSONObjectPreservesLargeInteger(t *testing.T) { requestPath := filepath.Join(t.TempDir(), "request.json") require.NoError(t, os.WriteFile( diff --git a/cli/azd/extensions/azure.ai.loom/internal/experimenttracking/client.go b/cli/azd/extensions/azure.ai.loom/internal/experimenttracking/client.go index da1cc38c4d1..5ee5779d4b9 100644 --- a/cli/azd/extensions/azure.ai.loom/internal/experimenttracking/client.go +++ b/cli/azd/extensions/azure.ai.loom/internal/experimenttracking/client.go @@ -26,6 +26,7 @@ const ( foundryScope = "https://ai.azure.com/.default" maxResponseBytes = 64 << 20 defaultTimeout = 30 * time.Second + ingestionTimeout = 5 * time.Minute ) // Client calls the experiment-tracking APIs for a Foundry project. @@ -182,6 +183,30 @@ func (c *Client) DoJSON( query url.Values, headers http.Header, body any, +) (json.RawMessage, error) { + return c.doJSON(ctx, method, apiPath, query, headers, body, c.httpClient) +} + +// DoJSONIngestion sends an authenticated JSON ingestion request with the longer upload timeout. +func (c *Client) DoJSONIngestion( + ctx context.Context, + method string, + apiPath string, + query url.Values, + headers http.Header, + body any, +) (json.RawMessage, error) { + return c.doJSON(ctx, method, apiPath, query, headers, body, c.httpClientWithTimeout(ingestionTimeout)) +} + +func (c *Client) doJSON( + ctx context.Context, + method string, + apiPath string, + query url.Values, + headers http.Header, + body any, + httpClient *http.Client, ) (json.RawMessage, error) { var reader io.Reader if body != nil { @@ -200,7 +225,7 @@ func (c *Client) DoJSON( headers.Set("Content-Type", "application/json") } - return c.do(ctx, method, apiPath, query, headers, reader) + return c.doWithHTTPClient(ctx, method, apiPath, query, headers, reader, httpClient) } // DoBytes sends an authenticated request with an arbitrary content type. @@ -218,7 +243,24 @@ func (c *Client) DoBytes( } headers.Set("Accept", contentType) headers.Set("Content-Type", contentType) - return c.do(ctx, method, apiPath, query, headers, bytes.NewReader(body)) + return c.doWithHTTPClient( + ctx, + method, + apiPath, + query, + headers, + bytes.NewReader(body), + c.httpClientWithTimeout(ingestionTimeout), + ) +} + +func (c *Client) httpClientWithTimeout(timeout time.Duration) *http.Client { + return &http.Client{ + Transport: c.httpClient.Transport, + CheckRedirect: c.httpClient.CheckRedirect, + Jar: c.httpClient.Jar, + Timeout: timeout, + } } func (c *Client) do( @@ -228,6 +270,18 @@ func (c *Client) do( query url.Values, headers http.Header, body io.Reader, +) (json.RawMessage, error) { + return c.doWithHTTPClient(ctx, method, apiPath, query, headers, body, c.httpClient) +} + +func (c *Client) doWithHTTPClient( + ctx context.Context, + method string, + apiPath string, + query url.Values, + headers http.Header, + body io.Reader, + httpClient *http.Client, ) (json.RawMessage, error) { requestURL, err := c.requestURL(apiPath, query) if err != nil { @@ -251,7 +305,7 @@ func (c *Client) do( req.Header.Set("Authorization", "Bearer "+token.Token) } - resp, err := c.httpClient.Do(req) + resp, err := httpClient.Do(req) if err != nil { return nil, fmt.Errorf("send request: %w", err) } diff --git a/cli/azd/extensions/azure.ai.loom/internal/experimenttracking/client_test.go b/cli/azd/extensions/azure.ai.loom/internal/experimenttracking/client_test.go index 8b0d9def4ae..535bdbfd938 100644 --- a/cli/azd/extensions/azure.ai.loom/internal/experimenttracking/client_test.go +++ b/cli/azd/extensions/azure.ai.loom/internal/experimenttracking/client_test.go @@ -21,6 +21,12 @@ import ( type staticCredential struct{} +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return f(request) +} + func (staticCredential) GetToken( context.Context, policy.TokenRequestOptions, @@ -31,6 +37,77 @@ func (staticCredential) GetToken( }, nil } +func TestIngestionRequestsUseExtendedTimeout(t *testing.T) { + tests := []struct { + name string + send func(context.Context, *Client) error + }{ + { + name: "protobuf", + send: func(ctx context.Context, client *Client) error { + _, err := client.DoBytes( + ctx, + http.MethodPost, + "protocols/otlp/v1/traces", + nil, + nil, + "application/x-protobuf", + []byte{0x0a, 0x00}, + ) + return err + }, + }, + { + name: "JSON", + send: func(ctx context.Context, client *Client) error { + _, err := client.DoJSONIngestion( + ctx, + http.MethodPost, + "agents/otel/v1/traces", + nil, + nil, + map[string]any{"resourceSpans": []any{}}, + ) + return err + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var deadline time.Time + httpClient := &http.Client{ + Timeout: defaultTimeout, + Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + var ok bool + deadline, ok = request.Context().Deadline() + require.True(t, ok) + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{}`)), + Request: request, + }, nil + }), + } + client, err := newClient( + "https://account.services.ai.azure.com/api/projects/project", + "", + "", + nil, + "project-key", + httpClient, + ) + require.NoError(t, err) + + started := time.Now() + require.NoError(t, test.send(t.Context(), client)) + assert.WithinDuration(t, started.Add(ingestionTimeout), deadline, time.Second) + assert.Equal(t, defaultTimeout, httpClient.Timeout) + }) + } +} + func TestNewClientDerivesProjectAndAccountIDs(t *testing.T) { client, err := newClient( "https://sample.services.ai.azure.com/api/projects/my%20project", diff --git a/cli/azd/extensions/azure.ai.projects/internal/exterrors/codes.go b/cli/azd/extensions/azure.ai.projects/internal/exterrors/codes.go index 91cf2b6b485..97020c74a8e 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/exterrors/codes.go +++ b/cli/azd/extensions/azure.ai.projects/internal/exterrors/codes.go @@ -38,7 +38,6 @@ const ( const ( //nolint:gosec // error code, not a credential CodeCredentialCreationFailed = "credential_creation_failed" - CodeAuthenticationFailed = "authentication_failed" CodeTenantLookupFailed = "tenant_lookup_failed" ) From 3f58ca4bf16bf54f06573f156448a2f08888e7b1 Mon Sep 17 00:00:00 2001 From: HarshaVardhan Babu Namburi Date: Wed, 2 Sep 2026 12:21:49 +0530 Subject: [PATCH 11/13] Remove unused Loom HTTP wrapper Delete the dead Client.do method left by the operation-specific timeout refactor. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../internal/experimenttracking/client.go | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/cli/azd/extensions/azure.ai.loom/internal/experimenttracking/client.go b/cli/azd/extensions/azure.ai.loom/internal/experimenttracking/client.go index 5ee5779d4b9..b6d7006dab1 100644 --- a/cli/azd/extensions/azure.ai.loom/internal/experimenttracking/client.go +++ b/cli/azd/extensions/azure.ai.loom/internal/experimenttracking/client.go @@ -263,17 +263,6 @@ func (c *Client) httpClientWithTimeout(timeout time.Duration) *http.Client { } } -func (c *Client) do( - ctx context.Context, - method string, - apiPath string, - query url.Values, - headers http.Header, - body io.Reader, -) (json.RawMessage, error) { - return c.doWithHTTPClient(ctx, method, apiPath, query, headers, body, c.httpClient) -} - func (c *Client) doWithHTTPClient( ctx context.Context, method string, From 13f8b0b1e2ad126f4bda10a7006ed346af40a63d Mon Sep 17 00:00:00 2001 From: HarshaVardhan Babu Namburi Date: Wed, 2 Sep 2026 13:29:05 +0530 Subject: [PATCH 12/13] Harden Loom error and flag handling Bound error response bodies, use typed token acquisition errors, preserve host endpoint fallback, and reject complete-body flag conflicts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../internal/cmd/project_resolver.go | 8 +- .../internal/cmd/project_resolver_test.go | 29 +++++ .../azure.ai.loom/internal/cmd/run.go | 31 ++++-- .../azure.ai.loom/internal/cmd/run_test.go | 104 +++++++++++++++++- .../internal/experimenttracking/client.go | 36 +++++- .../experimenttracking/client_test.go | 25 +++++ .../azure.ai.loom/internal/exterrors/codes.go | 1 + 7 files changed, 218 insertions(+), 16 deletions(-) diff --git a/cli/azd/extensions/azure.ai.loom/internal/cmd/project_resolver.go b/cli/azd/extensions/azure.ai.loom/internal/cmd/project_resolver.go index ba394c5488c..f258a82742f 100644 --- a/cli/azd/extensions/azure.ai.loom/internal/cmd/project_resolver.go +++ b/cli/azd/extensions/azure.ai.loom/internal/cmd/project_resolver.go @@ -81,10 +81,7 @@ func resolveProjectEndpoint( if readSources == nil { readSources = readAzdHostedSources } - sources, err := readSources(ctx) - if err != nil { - return nil, err - } + sources, sourcesErr := readSources(ctx) for _, candidate := range []string{ sources.EnvValue, sources.Config.Endpoint, @@ -100,6 +97,9 @@ func resolveProjectEndpoint( } return &resolvedEndpoint{Endpoint: endpoint}, nil } + if sourcesErr != nil { + return nil, sourcesErr + } return nil, noProjectEndpointError() } diff --git a/cli/azd/extensions/azure.ai.loom/internal/cmd/project_resolver_test.go b/cli/azd/extensions/azure.ai.loom/internal/cmd/project_resolver_test.go index 78277bf98c3..2feef6c8a11 100644 --- a/cli/azd/extensions/azure.ai.loom/internal/cmd/project_resolver_test.go +++ b/cli/azd/extensions/azure.ai.loom/internal/cmd/project_resolver_test.go @@ -5,6 +5,7 @@ package cmd import ( "context" + "errors" "testing" "github.com/stretchr/testify/assert" @@ -69,6 +70,34 @@ func TestResolveProjectEndpointPrefersFoundryHostVariable(t *testing.T) { assert.Equal(t, foundryEndpoint, resolved.Endpoint) } +func TestResolveProjectEndpointUsesHostFallbackWhenAzdSourcesFail(t *testing.T) { + t.Setenv("FOUNDRY_PROJECT_ENDPOINT", testProjectEndpoint) + t.Setenv("AZURE_AI_PROJECT_ENDPOINT", "") + + resolved, err := resolveProjectEndpoint(t.Context(), resolveProjectEndpointOpts{ + ReadAzdHostedSources: func(context.Context) (azdHostedSources, error) { + return azdHostedSources{}, errors.New("read persisted context") + }, + }) + + require.NoError(t, err) + assert.Equal(t, testProjectEndpoint, resolved.Endpoint) +} + +func TestResolveProjectEndpointReturnsAzdSourceErrorWithoutFallback(t *testing.T) { + t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "") + t.Setenv("AZURE_AI_PROJECT_ENDPOINT", "") + sourceErr := errors.New("read persisted context") + + _, err := resolveProjectEndpoint(t.Context(), resolveProjectEndpointOpts{ + ReadAzdHostedSources: func(context.Context) (azdHostedSources, error) { + return azdHostedSources{}, sourceErr + }, + }) + + require.ErrorIs(t, err, sourceErr) +} + func TestValidateProjectEndpointDoesNotDiscloseCredentials(t *testing.T) { credential := "user:secret" _, err := validateProjectEndpoint( diff --git a/cli/azd/extensions/azure.ai.loom/internal/cmd/run.go b/cli/azd/extensions/azure.ai.loom/internal/cmd/run.go index 0e0c4920bde..b912f6b4152 100644 --- a/cli/azd/extensions/azure.ai.loom/internal/cmd/run.go +++ b/cli/azd/extensions/azure.ai.loom/internal/cmd/run.go @@ -284,6 +284,11 @@ func newRunTraceChatCommand(extCtx *azdext.ExtensionContext) *cobra.Command { if err := requireValue("trace-id", traceID); err != nil { return err } + } else if cmd.Flags().Changed("trace-id") { + return conflictingExperimentArguments( + "--request-file cannot be combined with --trace-id", + "remove either --request-file or --trace-id", + ) } client, err := newExperimentClient(cmd.Context(), flags.experimentFlags) if err != nil { @@ -399,6 +404,16 @@ func newRunSpansQueryCommand(extCtx *azdext.ExtensionContext) *cobra.Command { if err := requireValue("run-id", flags.runID); err != nil { return err } + if requestFile != "" { + for _, flag := range []string{"filter", "filter-file", "include-details", "limit"} { + if cmd.Flags().Changed(flag) { + return conflictingExperimentArguments( + fmt.Sprintf("--request-file cannot be combined with --%s", flag), + fmt.Sprintf("remove either --request-file or --%s", flag), + ) + } + } + } client, err := newExperimentClient(cmd.Context(), flags.experimentFlags) if err != nil { return err @@ -406,12 +421,6 @@ func newRunSpansQueryCommand(extCtx *azdext.ExtensionContext) *cobra.Command { var body any if requestFile != "" { - if filter != "" || filterFile != "" { - return invalidExperimentParameter( - "request-file", - "--request-file cannot be combined with --filter or --filter-file", - ) - } body, err = readJSONObject(requestFile) } else { query, queryErr := readFilterExpression(filter, filterFile) @@ -753,7 +762,7 @@ func classifyExperimentError(err error) error { if exterrors.IsCancellation(err) { return exterrors.Cancelled("experiment request was cancelled") } - if strings.Contains(strings.ToLower(err.Error()), "access token") { + if errors.Is(err, experimenttracking.ErrTokenAcquisition) { return exterrors.Auth( exterrors.CodeAuthenticationFailed, fmt.Sprintf("authenticate to Foundry experiment tracking: %s", err), @@ -988,6 +997,14 @@ func invalidExperimentParameter(name string, message string) error { ) } +func conflictingExperimentArguments(message string, suggestion string) error { + return exterrors.Validation( + exterrors.CodeConflictingArguments, + message, + suggestion, + ) +} + func invalidExperimentPayload(message string) error { return exterrors.Validation( exterrors.CodeInvalidExperimentPayload, diff --git a/cli/azd/extensions/azure.ai.loom/internal/cmd/run_test.go b/cli/azd/extensions/azure.ai.loom/internal/cmd/run_test.go index 78307152d4c..0b3e442825d 100644 --- a/cli/azd/extensions/azure.ai.loom/internal/cmd/run_test.go +++ b/cli/azd/extensions/azure.ai.loom/internal/cmd/run_test.go @@ -9,12 +9,15 @@ import ( "errors" "fmt" "io" + "net/http" "os" "path/filepath" "testing" + "azure.ai.loom/internal/experimenttracking" "azure.ai.loom/internal/exterrors" + "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" @@ -195,6 +198,81 @@ func TestRequiredFlagsAreValidatedInOrder(t *testing.T) { } } +func TestCompleteRequestFilesRejectIgnoredFlags(t *testing.T) { + tests := []struct { + name string + newCommand func() *cobra.Command + args []string + }{ + { + name: "trace ID", + newCommand: func() *cobra.Command { return newRunTraceChatCommand(nil) }, + args: []string{"--run-id", "run-one", "--request-file", "request.json", "--trace-id", "trace-one"}, + }, + { + name: "empty trace ID", + newCommand: func() *cobra.Command { return newRunTraceChatCommand(nil) }, + args: []string{"--run-id", "run-one", "--request-file", "request.json", "--trace-id", ""}, + }, + { + name: "span filter", + newCommand: func() *cobra.Command { return newRunSpansQueryCommand(nil) }, + args: []string{"--run-id", "run-one", "--request-file", "request.json", "--filter", `{}`}, + }, + { + name: "empty span filter", + newCommand: func() *cobra.Command { return newRunSpansQueryCommand(nil) }, + args: []string{"--run-id", "run-one", "--request-file", "request.json", "--filter", ""}, + }, + { + name: "span filter file", + newCommand: func() *cobra.Command { return newRunSpansQueryCommand(nil) }, + args: []string{"--run-id", "run-one", "--request-file", "request.json", "--filter-file", "filter.json"}, + }, + { + name: "empty span filter file", + newCommand: func() *cobra.Command { return newRunSpansQueryCommand(nil) }, + args: []string{"--run-id", "run-one", "--request-file", "request.json", "--filter-file", ""}, + }, + { + name: "span include details", + newCommand: func() *cobra.Command { return newRunSpansQueryCommand(nil) }, + args: []string{"--run-id", "run-one", "--request-file", "request.json", "--include-details"}, + }, + { + name: "span include details false", + newCommand: func() *cobra.Command { return newRunSpansQueryCommand(nil) }, + args: []string{"--run-id", "run-one", "--request-file", "request.json", "--include-details=false"}, + }, + { + name: "span limit", + newCommand: func() *cobra.Command { return newRunSpansQueryCommand(nil) }, + args: []string{"--run-id", "run-one", "--request-file", "request.json", "--limit", "5"}, + }, + { + name: "default span limit", + newCommand: func() *cobra.Command { return newRunSpansQueryCommand(nil) }, + args: []string{"--run-id", "run-one", "--request-file", "request.json", "--limit", "10"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + command := test.newCommand() + command.SetOut(io.Discard) + command.SetErr(io.Discard) + command.SetArgs(test.args) + + err := command.Execute() + + localErr, ok := errors.AsType[*azdext.LocalError](err) + require.True(t, ok) + assert.Equal(t, exterrors.CodeConflictingArguments, localErr.Code) + assert.Contains(t, localErr.Message, "--request-file") + }) + } +} + func TestReadJSONObjectPreservesLargeInteger(t *testing.T) { requestPath := filepath.Join(t.TempDir(), "request.json") require.NoError(t, os.WriteFile( @@ -240,7 +318,9 @@ func TestReadNonEmptyExperimentInputRejectsEmptyPayload(t *testing.T) { } func TestClassifyExperimentErrorPreservesCancellation(t *testing.T) { - err := classifyExperimentError(fmt.Errorf("acquire Foundry access token: %w", context.Canceled)) + err := classifyExperimentError( + fmt.Errorf("%w: %w", experimenttracking.ErrTokenAcquisition, context.Canceled), + ) localErr, ok := errors.AsType[*azdext.LocalError](err) require.True(t, ok) @@ -248,6 +328,28 @@ func TestClassifyExperimentErrorPreservesCancellation(t *testing.T) { assert.Equal(t, exterrors.CodeCancelled, localErr.Code) } +func TestClassifyExperimentErrorUsesTypedTokenAcquisitionError(t *testing.T) { + err := classifyExperimentError( + fmt.Errorf("%w: %w", experimenttracking.ErrTokenAcquisition, assert.AnError), + ) + + localErr, ok := errors.AsType[*azdext.LocalError](err) + require.True(t, ok) + assert.Equal(t, azdext.LocalErrorCategoryAuth, localErr.Category) + assert.Equal(t, exterrors.CodeAuthenticationFailed, localErr.Code) +} + +func TestClassifyExperimentErrorPreservesServiceResponse(t *testing.T) { + err := classifyExperimentError(&azcore.ResponseError{ + StatusCode: http.StatusForbidden, + ErrorCode: "AccessTokenDenied", + }) + + serviceErr, ok := errors.AsType[*azdext.ServiceError](err) + require.True(t, ok) + assert.Equal(t, http.StatusForbidden, serviceErr.StatusCode) +} + func TestSetAgentTracesRunIDOverridesPayload(t *testing.T) { body := map[string]any{"run_id": "payload-run"} diff --git a/cli/azd/extensions/azure.ai.loom/internal/experimenttracking/client.go b/cli/azd/extensions/azure.ai.loom/internal/experimenttracking/client.go index b6d7006dab1..031594ccc73 100644 --- a/cli/azd/extensions/azure.ai.loom/internal/experimenttracking/client.go +++ b/cli/azd/extensions/azure.ai.loom/internal/experimenttracking/client.go @@ -9,6 +9,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -29,6 +30,9 @@ const ( ingestionTimeout = 5 * time.Minute ) +// ErrTokenAcquisition identifies failures acquiring a Foundry access token. +var ErrTokenAcquisition = errors.New("acquire Foundry access token") + // Client calls the experiment-tracking APIs for a Foundry project. type Client struct { projectEndpoint string @@ -289,7 +293,7 @@ func (c *Client) doWithHTTPClient( Scopes: []string{foundryScope}, }) if tokenErr != nil { - return nil, fmt.Errorf("acquire Foundry access token: %w", tokenErr) + return nil, fmt.Errorf("%w: %w", ErrTokenAcquisition, tokenErr) } req.Header.Set("Authorization", "Bearer "+token.Token) } @@ -301,14 +305,14 @@ func (c *Client) doWithHTTPClient( defer resp.Body.Close() if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { - return nil, runtime.NewResponseError(resp) + return nil, newLimitedResponseError(resp, maxResponseBytes) } - data, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1)) + data, truncated, err := readLimitedBody(resp.Body, maxResponseBytes) if err != nil { return nil, fmt.Errorf("read response body: %w", err) } - if len(data) > maxResponseBytes { + if truncated { return nil, fmt.Errorf("response body exceeds %d bytes", maxResponseBytes) } if len(bytes.TrimSpace(data)) == 0 { @@ -318,6 +322,30 @@ func (c *Client) doWithHTTPClient( return json.RawMessage(data), nil } +func newLimitedResponseError(resp *http.Response, maxBytes int64) error { + data, truncated, err := readLimitedBody(resp.Body, maxBytes) + if err != nil { + return fmt.Errorf("read error response body: %w", err) + } + resp.Body = io.NopCloser(bytes.NewReader(data)) + responseErr := runtime.NewResponseError(resp) + if truncated { + return fmt.Errorf("error response body exceeds %d bytes: %w", maxBytes, responseErr) + } + return responseErr +} + +func readLimitedBody(reader io.Reader, maxBytes int64) ([]byte, bool, error) { + data, err := io.ReadAll(io.LimitReader(reader, maxBytes+1)) + if err != nil { + return nil, false, err + } + if int64(len(data)) > maxBytes { + return data[:maxBytes], true, nil + } + return data, false, nil +} + func (c *Client) requestURL(apiPath string, query url.Values) (string, error) { base, err := url.Parse(c.projectEndpoint) if err != nil { diff --git a/cli/azd/extensions/azure.ai.loom/internal/experimenttracking/client_test.go b/cli/azd/extensions/azure.ai.loom/internal/experimenttracking/client_test.go index 535bdbfd938..f0fcb7327d4 100644 --- a/cli/azd/extensions/azure.ai.loom/internal/experimenttracking/client_test.go +++ b/cli/azd/extensions/azure.ai.loom/internal/experimenttracking/client_test.go @@ -6,9 +6,11 @@ package experimenttracking import ( "context" "encoding/json" + "errors" "io" "net/http" "net/http/httptest" + "net/url" "strings" "testing" "time" @@ -194,6 +196,29 @@ func TestDoJSONReturnsResponseError(t *testing.T) { assert.Contains(t, err.Error(), "BadFilter") } +func TestNewLimitedResponseErrorBoundsBody(t *testing.T) { + const maxBytes = 8 + resp := &http.Response{ + StatusCode: http.StatusBadRequest, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"error":"response body is too large"}`)), + Request: &http.Request{ + Method: http.MethodGet, + URL: new(url.URL), + }, + } + + err := newLimitedResponseError(resp, maxBytes) + + require.Error(t, err) + assert.Contains(t, err.Error(), "exceeds 8 bytes") + responseErr, ok := errors.AsType[*azcore.ResponseError](err) + require.True(t, ok) + body, readErr := io.ReadAll(responseErr.RawResponse.Body) + require.NoError(t, readErr) + assert.Len(t, body, maxBytes) +} + func TestRunHeaders(t *testing.T) { client, err := newClient( "https://account.services.ai.azure.com/api/projects/project", diff --git a/cli/azd/extensions/azure.ai.loom/internal/exterrors/codes.go b/cli/azd/extensions/azure.ai.loom/internal/exterrors/codes.go index 5f623a91569..14b55e8abf2 100644 --- a/cli/azd/extensions/azure.ai.loom/internal/exterrors/codes.go +++ b/cli/azd/extensions/azure.ai.loom/internal/exterrors/codes.go @@ -6,6 +6,7 @@ package exterrors const ( CodeCancelled = "cancelled" CodeInvalidParameter = "invalid_parameter" + CodeConflictingArguments = "conflicting_arguments" CodeInvalidExperimentPayload = "invalid_experiment_payload" CodeMissingProjectEndpoint = "missing_project_endpoint" CodeExperimentInputReadFailed = "experiment_input_read_failed" From faf1c5887c17f365b77faa9c151e53f06f8a6614 Mon Sep 17 00:00:00 2001 From: HarshaVardhanBabu Date: Wed, 2 Sep 2026 14:45:34 +0530 Subject: [PATCH 13/13] Change code owners for azure.ai.loom Updated code owners for the azure.ai.loom extension. --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 59a8b4e4404..0ebef95c6f3 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -21,7 +21,7 @@ /cli/azd/extensions/azure.ai.connections/ @JeffreyCA @glharper @trangevi @trrwilson @therealjohn @huimiu @hund030 @m5i-work @v1212 /cli/azd/extensions/azure.ai.finetune/ @JeffreyCA @trangevi @achauhan-scc @kingernupur @saanikaguptamicrosoft /cli/azd/extensions/azure.ai.inspector/ @JeffreyCA @glharper @trangevi @trrwilson @therealjohn @anchenyi @XiaofuHuang -/cli/azd/extensions/azure.ai.loom/ @JeffreyCA @glharper @trangevi @trrwilson @therealjohn @huimiu @hund030 @m5i-work @v1212 +/cli/azd/extensions/azure.ai.loom/ @hnamburi @savitam /cli/azd/extensions/azure.ai.models/ @JeffreyCA @trangevi @achauhan-scc @kingernupur @saanikaguptamicrosoft /cli/azd/extensions/azure.ai.projects/ @JeffreyCA @glharper @trangevi @trrwilson @therealjohn @huimiu @hund030 @m5i-work @v1212 /cli/azd/extensions/azure.ai.rle/ @JeffreyCA @glharper @trangevi @trrwilson @therealjohn @huimiu @hund030 @m5i-work @v1212