Skip to content
Open
3 changes: 3 additions & 0 deletions cli/azd/.vscode/cspell.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,9 @@ dictionaryDefinitions:
dictionaries:
- azdProjectDictionary
overrides:
- filename: "**/internal/agent/types.go"
words:
- billionized
- filename: internal/appdetect/aspire_polyglot.go
words:
- pylock
Expand Down
1 change: 1 addition & 0 deletions cli/azd/cmd/telemetry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ func TestTelemetryFieldConstants(t *testing.T) {

measurementFields := []fields.AttributeKey{
fields.AgentFixAttempts,
fields.CopilotMessageAICredits,
fields.ExeGraphDeployConcurrencyKey,
fields.ExeGraphMaxConcurrencyKey,
fields.ExeGraphPackageConcurrencyKey,
Expand Down
9 changes: 5 additions & 4 deletions cli/azd/docs/extensions/extension-framework.md
Original file line number Diff line number Diff line change
Expand Up @@ -3128,8 +3128,9 @@ Returns cumulative usage metrics cached for a session.
- `input_tokens` (double): Total input tokens consumed
- `output_tokens` (double): Total output tokens consumed
- `total_tokens` (double): Sum of input + output tokens
- `billing_rate` (double): Per-request cost multiplier (e.g., 1.0x, 2.0x)
- `premium_requests` (double): Number of premium requests used
- `billing_rate` (double, deprecated): Legacy per-request cost multiplier; use `ai_credits` instead
- `premium_requests` (double, deprecated): Legacy premium request count; use `ai_credits` instead
- `ai_credits` (double): Total AI credits consumed
- `duration_ms` (double): Total API duration in milliseconds

#### GetFileChanges
Expand Down Expand Up @@ -3223,8 +3224,8 @@ metricsResp, err := copilot.GetUsageMetrics(ctx, &v1beta.GetCopilotUsageMetricsR
if err != nil {
return fmt.Errorf("failed to get metrics: %w", err)
}
fmt.Printf("Total tokens: %.0f, Premium requests: %.0f\n",
metricsResp.Usage.TotalTokens, metricsResp.Usage.PremiumRequests)
fmt.Printf("Total tokens: %.0f, AI credits: %.2f AIC\n",
metricsResp.Usage.TotalTokens, metricsResp.Usage.AiCredits)

// Retrieve file changes
changesResp, err := copilot.GetFileChanges(ctx, &v1beta.GetCopilotFileChangesRequest{
Expand Down
14 changes: 8 additions & 6 deletions cli/azd/extensions/microsoft.azd.demo/internal/cmd/copilot.go
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ func showCumulativeMetrics(
}

usage := metricsResp.Usage
if usage == nil || (usage.InputTokens == 0 && usage.OutputTokens == 0) {
if !hasUsageMetrics(usage) {
return
}

Expand All @@ -294,18 +294,20 @@ func showCumulativeMetrics(
color.HiBlackString("•"), formatTokens(usage.OutputTokens))
fmt.Printf(" %s Total tokens: %s\n",
color.HiBlackString("•"), formatTokens(usage.TotalTokens))
if usage.BillingRate > 0 {
fmt.Printf(" %s Billing rate: %.0fx per request\n",
color.HiBlackString("•"), usage.BillingRate)
if usage.AiCredits > 0 {
fmt.Printf(" %s AI credits: %s\n",
color.HiBlackString("•"), fmt.Sprintf("%.2f", usage.AiCredits))
}
fmt.Printf(" %s Premium requests: %.0f\n",
color.HiBlackString("•"), usage.PremiumRequests)
if usage.DurationMs > 0 {
fmt.Printf(" %s API duration: %s\n",
color.HiBlackString("•"), formatDuration(usage.DurationMs))
}
}

func hasUsageMetrics(usage *v1beta.CopilotUsageMetrics) bool {
return usage != nil && (usage.InputTokens != 0 || usage.OutputTokens != 0 || usage.AiCredits != 0)
}

// showFileChanges displays accumulated file changes from the session.
func showFileChanges(
ctx context.Context,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package cmd

import (
"testing"

"github.com/stretchr/testify/require"

v1beta "github.com/azure/azure-dev/cli/azd/pkg/azdext/contracts/v1beta"
)

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

tests := []struct {
name string
usage *v1beta.CopilotUsageMetrics
expected bool
}{
{name: "Nil", expected: false},
{name: "Empty", usage: &v1beta.CopilotUsageMetrics{}, expected: false},
{name: "InputTokens", usage: &v1beta.CopilotUsageMetrics{InputTokens: 1}, expected: true},
{name: "OutputTokens", usage: &v1beta.CopilotUsageMetrics{OutputTokens: 1}, expected: true},
{name: "AICredits", usage: &v1beta.CopilotUsageMetrics{AiCredits: 0.25}, expected: true},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, test.expected, hasUsageMetrics(test.usage))
})
}
}
3 changes: 2 additions & 1 deletion cli/azd/grpc/proto/azd/extensions/v1/ai_model.proto
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ service AiModelService {
message AiModel {
string name = 1; // e.g. "gpt-4o"
string format = 2; // e.g. "OpenAI"
string lifecycle_status = 3 [deprecated = true]; // deprecated; always empty; use AiModelVersion.lifecycle_status
// Always empty. Use AiModelVersion.lifecycle_status instead.
string lifecycle_status = 3 [deprecated = true];
repeated string capabilities = 4; // e.g. ["chat", "embeddings"]
repeated AiModelVersion versions = 5;
repeated string locations = 6; // canonical locations where available
Expand Down
7 changes: 5 additions & 2 deletions cli/azd/grpc/proto/azd/extensions/v1beta/copilot.proto
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,12 @@ message CopilotUsageMetrics {
double input_tokens = 2; // Total input tokens consumed.
double output_tokens = 3; // Total output tokens consumed.
double total_tokens = 4; // Sum of input + output tokens.
double billing_rate = 5; // Per-request cost multiplier (e.g., 1.0x, 2.0x).
double premium_requests = 6; // Number of premium requests used.
// Legacy per-request cost multiplier. Use ai_credits instead.
double billing_rate = 5 [deprecated = true];
// Legacy premium request count. Use ai_credits instead.
double premium_requests = 6 [deprecated = true];
double duration_ms = 7; // Total API duration in milliseconds.
double ai_credits = 8; // Total AI credits consumed.
}

// --- File change messages ---
Expand Down
180 changes: 155 additions & 25 deletions cli/azd/internal/agent/copilot/copilot_sdk_e2e_test.go
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package copilot
package copilot_test

import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"slices"
"strings"
"sync"
"testing"
"time"

copilot "github.com/github/copilot-sdk/go"
"github.com/stretchr/testify/require"

"github.com/azure/azure-dev/cli/azd/internal/agent"
agentcopilot "github.com/azure/azure-dev/cli/azd/internal/agent/copilot"
"github.com/azure/azure-dev/cli/azd/test/mocks/mockinput"
)

Expand All @@ -39,8 +45,8 @@ func TestCopilotSDK_E2E(t *testing.T) {
t.Setenv("AZD_COPILOT_CLI_PATH", "")

// 1. Download the pinned CLI and start it through azd's client manager.
cli := NewCopilotCLI(mockinput.NewMockConsole(), nil, http.DefaultClient)
clientManager := NewCopilotClientManager(&CopilotClientOptions{
cli := agentcopilot.NewCopilotCLI(mockinput.NewMockConsole(), nil, http.DefaultClient)
clientManager := agentcopilot.NewCopilotClientManager(&agentcopilot.CopilotClientOptions{
LogLevel: "error",
}, cli)

Expand All @@ -58,7 +64,7 @@ func TestCopilotSDK_E2E(t *testing.T) {
// 2. Check auth
auth, err := clientManager.GetAuthStatus(ctx)
require.NoError(t, err)
t.Logf("Auth: authenticated=%v, login=%v", auth.IsAuthenticated, auth.Login)
t.Logf("Auth: authenticated=%v", auth.IsAuthenticated)
require.True(t, auth.IsAuthenticated, "not authenticated with GitHub Copilot")

// 3. List models
Expand All @@ -81,58 +87,182 @@ func TestCopilotSDK_E2E(t *testing.T) {
OnPermissionRequest: copilot.PermissionHandler.ApproveAll,
})
require.NoError(t, err, "CreateSession failed")
t.Logf("Session created: %s", session.WorkspacePath())
t.Log("Session created")
defer func() {
if disconnectErr := session.Disconnect(); disconnectErr != nil {
t.Logf("session.Destroy error: %v", disconnectErr)
}
}()

// 5. Collect events
var events []copilot.SessionEvent
collector := agent.NewHeadlessCollector()
captured := &capturedEvents{}
unsubscribe := session.On(func(event copilot.SessionEvent) {
events = append(events, event)
t.Logf("Event: type=%s", event.Type())
aiuFields, err := formatAIUFields(event)
captured.Add(event, err)
if aiuFields != "" {
t.Logf("AIU event: type=%s %s", event.Type(), aiuFields)
}
collector.HandleEvent(event)
})
defer unsubscribe()

// 6. Send message and wait for response
t.Log("Sending prompt...")
response, err := session.SendAndWait(ctx, copilot.MessageOptions{
// 6. Send two messages in the same session and wait for each turn to become idle.
t.Log("Sending first prompt...")
firstEvent := captured.Len()
firstResponse, err := session.SendAndWait(ctx, copilot.MessageOptions{
Prompt: "What is 2+2? Reply with just the number.",
})
require.NoError(t, err, "SendAndWait failed")
require.NoError(t, err, "first SendAndWait failed")
require.NoError(t, collector.WaitForIdle(ctx), "collector did not observe first session idle")
require.NoError(t, captured.Err())
requireResponseContains(t, firstResponse, captured.Since(firstEvent), "4")

firstUsage := collector.GetUsageMetrics()
require.Positive(t, firstUsage.AICredits, "expected positive AI credit usage after first turn")

t.Log("Sending follow-up prompt...")
secondEvent := captured.Len()
secondResponse, err := session.SendAndWait(ctx, copilot.MessageOptions{
Prompt: "Add 3 to the number you answered in the previous turn. Reply with just the result.",
})
require.NoError(t, err, "second SendAndWait failed")
require.NoError(t, collector.WaitForIdle(ctx), "collector did not observe second session idle")
require.NoError(t, captured.Err())
requireResponseContains(t, secondResponse, captured.Since(secondEvent), "7")

usage := collector.GetUsageMetrics()
require.Greater(t, usage.InputTokens, firstUsage.InputTokens)
require.Greater(t, usage.OutputTokens, firstUsage.OutputTokens)
require.Greater(t, usage.AICredits, firstUsage.AICredits)
t.Logf("Usage: input=%v output=%v AI credits=%v", usage.InputTokens, usage.OutputTokens, usage.AICredits)

// 7. Validate response
t.Logf("Received %d events total", len(events))
t.Logf("Received %d events total", captured.Len())
}

type capturedEvents struct {
mu sync.Mutex
events []copilot.SessionEvent
err error
}

func (c *capturedEvents) Add(event copilot.SessionEvent, err error) {
c.mu.Lock()
defer c.mu.Unlock()

c.events = append(c.events, event)
if c.err == nil {
c.err = err
}
}

func (c *capturedEvents) Len() int {
c.mu.Lock()
defer c.mu.Unlock()

return len(c.events)
}

func (c *capturedEvents) Since(index int) []copilot.SessionEvent {
c.mu.Lock()
defer c.mu.Unlock()

return slices.Clone(c.events[index:])
}

func (c *capturedEvents) Err() error {
c.mu.Lock()
defer c.mu.Unlock()

return c.err
}

func requireResponseContains(
t *testing.T,
response *copilot.SessionEvent,
events []copilot.SessionEvent,
expected string,
) {
t.Helper()

if response != nil {
data, ok := response.Data.(*copilot.AssistantMessageData)
require.True(t, ok, "expected response.Data to be *copilot.AssistantMessageData, got %T", response.Data)
t.Logf("Response content: %s", data.Content)
require.Contains(t, data.Content, "4",
"expected response to contain '4'")
require.Contains(t, data.Content, expected)
} else {
// If SendAndWait returned nil, check events for assistant message
var found bool
for _, e := range events {
if e.Type() == copilot.SessionEventTypeAssistantMessage {
if data, ok := e.Data.(*copilot.AssistantMessageData); ok {
for _, event := range events {
if event.Type() == copilot.SessionEventTypeAssistantMessage {
if data, ok := event.Data.(*copilot.AssistantMessageData); ok {
t.Logf("Found assistant message in events: %s", data.Content)
found = true
break
if strings.Contains(data.Content, expected) {
found = true
break
}
}
}
}
if !found {
// Log all event types for debugging
for _, e := range events {
for _, event := range events {
detail := ""
if data, ok := e.Data.(*copilot.AssistantMessageData); ok {
if data, ok := event.Data.(*copilot.AssistantMessageData); ok {
detail = fmt.Sprintf(" content=%s", truncateForLog(data.Content, 100))
}
t.Logf(" event: type=%s%s", e.Type(), detail)
t.Logf(" event: type=%s%s", event.Type(), detail)
}
t.Fatalf("no assistant message containing %q received", expected)
}
}
}

func formatAIUFields(event copilot.SessionEvent) (string, error) {
encoded, err := json.Marshal(event.Data)
if err != nil {
return "", fmt.Errorf("marshaling %s event data: %w", event.Type(), err)
}

var data any
if err := json.Unmarshal(encoded, &data); err != nil {
return "", fmt.Errorf("unmarshaling %s event data: %w", event.Type(), err)
}

var fields []string
collectAIUFields(data, "data", &fields)
if len(fields) == 0 {
return "", nil
}
if usage, ok := event.Data.(*copilot.AssistantUsageData); ok {
if usage.InputTokens != nil {
fields = append(fields, fmt.Sprintf("data.inputTokens=%d", *usage.InputTokens))
}
if usage.OutputTokens != nil {
fields = append(fields, fmt.Sprintf("data.outputTokens=%d", *usage.OutputTokens))
}
}

slices.Sort(fields)
return strings.Join(fields, ", "), nil
}

func collectAIUFields(value any, path string, fields *[]string) {
switch value := value.(type) {
case map[string]any:
for name, child := range value {
childPath := path + "." + name
if name == "totalNanoAiu" {
if number, ok := child.(float64); ok {
*fields = append(*fields, fmt.Sprintf("%s=%g", childPath, number))
}
continue
}
t.Fatal("no assistant message received")
collectAIUFields(child, childPath, fields)
}
case []any:
for _, child := range value {
collectAIUFields(child, path+"[]", fields)
}
}
}
Expand Down
Loading
Loading