diff --git a/cli/azd/extensions/azure.ai.finetune/README.md b/cli/azd/extensions/azure.ai.finetune/README.md new file mode 100644 index 00000000000..09d2eb6b8e8 --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/README.md @@ -0,0 +1,3 @@ +# `azd` Demo Extension + +An AZD Demo extension diff --git a/cli/azd/extensions/azure.ai.finetune/build.ps1 b/cli/azd/extensions/azure.ai.finetune/build.ps1 new file mode 100644 index 00000000000..5ceb60a8bbc --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/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.finetune/build.sh b/cli/azd/extensions/azure.ai.finetune/build.sh new file mode 100644 index 00000000000..f1a995ec5e9 --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/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.finetune/changelog.md b/cli/azd/extensions/azure.ai.finetune/changelog.md new file mode 100644 index 00000000000..b88d613cce0 --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/changelog.md @@ -0,0 +1,3 @@ +# Release History + +## 0.0.1 - Initial Version \ No newline at end of file diff --git a/cli/azd/extensions/azure.ai.finetune/design/IMPLEMENTATION_SUMMARY.md b/cli/azd/extensions/azure.ai.finetune/design/IMPLEMENTATION_SUMMARY.md new file mode 100644 index 00000000000..a0aea7735fa --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/design/IMPLEMENTATION_SUMMARY.md @@ -0,0 +1,284 @@ +# Architecture Implementation - Folder Structure & Interfaces + +## Created Folder Structure + +``` +azure.ai.finetune/ +├── pkg/ +│ └── models/ # Domain Models (Shared Foundation) +│ ├── finetune.go # FineTuningJob, JobStatus, CreateFineTuningRequest +│ ├── deployment.go # Deployment, DeploymentStatus, DeploymentRequest +│ ├── errors.go # ErrorDetail, Error codes +│ └── requests.go # All request/response DTOs +│ +├── internal/ +│ ├── services/ # Service Layer (Business Logic) +│ │ ├── interface.go # FineTuningService, DeploymentService interfaces +│ │ ├── state_store.go # StateStore, ErrorTransformer interfaces +│ │ ├── finetune_service.go # FineTuningService implementation (stub) +│ │ └── deployment_service.go # DeploymentService implementation (stub) +│ │ +│ └── providers/ # Provider Layer (SDK Adapters) +│ ├── interface.go # FineTuningProvider, ModelDeploymentProvider interfaces +│ ├── openai/ +│ │ └── provider.go # OpenAI provider implementation (stub) +│ └── azure/ +│ └── provider.go # Azure provider implementation (stub) +│ +├── design/ +│ └── architecture.md # Architecture documentation +└── [existing files unchanged] +``` + +## Files Created + +### 1. Domain Models (pkg/models/) + +#### finetune.go +- `JobStatus` enum: pending, queued, running, succeeded, failed, cancelled, paused +- `FineTuningJob` - main domain model for jobs +- `CreateFineTuningRequest` - request DTO +- `Hyperparameters` - hyperparameter configuration +- `ListFineTuningJobsRequest` - pagination request +- `FineTuningJobDetail` - detailed job info +- `JobEvent` - event information +- `JobCheckpoint` - checkpoint data + +#### deployment.go +- `DeploymentStatus` enum: pending, active, updating, failed, deleting +- `Deployment` - main domain model for deployments +- `DeploymentRequest` - request DTO +- `DeploymentConfig` - configuration for deployments +- `BaseModel` - base model information + +#### errors.go +- `ErrorDetail` - standardized error structure +- Error code constants: INVALID_REQUEST, NOT_FOUND, UNAUTHORIZED, RATE_LIMITED, etc. +- Error method implementation + +#### requests.go +- All request DTOs: PauseJobRequest, ResumeJobRequest, CancelJobRequest, etc. +- ListDeploymentsRequest, GetDeploymentRequest, UpdateDeploymentRequest, etc. + +--- + +### 2. Provider Layer (internal/providers/) + +#### interface.go +Defines two main interfaces: + +**FineTuningProvider Interface** +- `CreateFineTuningJob()` +- `GetFineTuningStatus()` +- `ListFineTuningJobs()` +- `GetFineTuningJobDetails()` +- `GetJobEvents()` +- `GetJobCheckpoints()` +- `PauseJob()` +- `ResumeJob()` +- `CancelJob()` +- `UploadFile()` +- `GetUploadedFile()` + +**ModelDeploymentProvider Interface** +- `DeployModel()` +- `GetDeploymentStatus()` +- `ListDeployments()` +- `UpdateDeployment()` +- `DeleteDeployment()` + +#### openai/provider.go (Stub Implementation) +- `OpenAIProvider` struct +- Implements both `FineTuningProvider` and `ModelDeploymentProvider` +- All methods have TODO comments (ready for implementation) +- Constructor: `NewOpenAIProvider(apiKey, endpoint)` + +#### azure/provider.go (Stub Implementation) +- `AzureProvider` struct +- Implements both `FineTuningProvider` and `ModelDeploymentProvider` +- All methods have TODO comments (ready for implementation) +- Constructor: `NewAzureProvider(endpoint, apiKey)` + +--- + +### 3. Service Layer (internal/services/) + +#### interface.go +Defines two service interfaces: + +**FineTuningService Interface** +- `CreateFineTuningJob()` - with business validation +- `GetFineTuningStatus()` +- `ListFineTuningJobs()` +- `GetFineTuningJobDetails()` +- `GetJobEvents()` - with filtering +- `GetJobCheckpoints()` - with pagination +- `PauseJob()` - with state validation +- `ResumeJob()` - with state validation +- `CancelJob()` - with proper validation +- `UploadTrainingFile()` - with validation +- `UploadValidationFile()` - with validation +- `PollJobUntilCompletion()` - async polling + +**DeploymentService Interface** +- `DeployModel()` - with validation +- `GetDeploymentStatus()` +- `ListDeployments()` +- `UpdateDeployment()` - with validation +- `DeleteDeployment()` - with validation +- `WaitForDeployment()` - timeout support + +#### state_store.go +Defines persistence interfaces: + +**StateStore Interface** +- Job persistence: SaveJob, GetJob, ListJobs, UpdateJobStatus, DeleteJob +- Deployment persistence: SaveDeployment, GetDeployment, ListDeployments, UpdateDeploymentStatus, DeleteDeployment + +**ErrorTransformer Interface** +- `TransformError()` - converts vendor errors to standardized ErrorDetail + +#### finetune_service.go (Stub Implementation) +- `fineTuningServiceImpl` struct +- Implements `FineTuningService` interface +- Constructor: `NewFineTuningService(provider, stateStore)` +- All methods have TODO comments (ready for implementation) +- Takes `FineTuningProvider` and `StateStore` as dependencies + +#### deployment_service.go (Stub Implementation) +- `deploymentServiceImpl` struct +- Implements `DeploymentService` interface +- Constructor: `NewDeploymentService(provider, stateStore)` +- All methods have TODO comments (ready for implementation) +- Takes `ModelDeploymentProvider` and `StateStore` as dependencies + +--- + +## Architecture Verification + +### Import Rules Enforced + +✅ **pkg/models/** - No imports from other layers +- Pure data structures only + +✅ **internal/providers/interface.go** - Only imports models +- Vendor-agnostic interface definitions + +✅ **internal/providers/openai/provider.go** - Can import: +- `pkg/models` (domain models) +- OpenAI SDK (when implemented) + +✅ **internal/providers/azure/provider.go** - Can import: +- `pkg/models` (domain models) +- Azure SDK (when implemented) + +✅ **internal/services/interface.go** - Only imports: +- `pkg/models` +- `context` + +✅ **internal/services/finetune_service.go** - Only imports: +- `pkg/models` +- `internal/providers` (interface, not concrete) +- `internal/services` (own package for StateStore) + +✅ **internal/services/deployment_service.go** - Only imports: +- `pkg/models` +- `internal/providers` (interface, not concrete) +- `internal/services` (own package for StateStore) + +--- + +## Next Steps + +### To Implement Provider Layer: + +1. **OpenAI Provider** (`internal/providers/openai/provider.go`) + - Add OpenAI SDK imports + - Implement domain ↔ SDK conversions + - Fill in method bodies + - Add error transformation logic + +2. **Azure Provider** (`internal/providers/azure/provider.go`) + - Add Azure SDK imports + - Implement domain ↔ SDK conversions + - Fill in method bodies + - Add error transformation logic + +### To Implement Service Layer: + +1. **FineTuningService** (`internal/services/finetune_service.go`) + - Implement validation logic + - Add state persistence calls + - Error transformation + - Fill in method bodies + +2. **DeploymentService** (`internal/services/deployment_service.go`) + - Implement validation logic + - Add state persistence calls + - Error transformation + - Fill in method bodies + +3. **StateStore Implementation** + - File-based storage (JSON files) + - Or in-memory with persistence + +### To Refactor CLI Layer: + +1. Update `internal/cmd/operations.go` + - Remove direct SDK calls + - Use service layer instead + - Inject services via DI + - Format output only + +2. Create command factory + - Initialize providers + - Initialize services + - Pass to command constructors + +--- + +## Key Benefits of This Structure + +✅ **No Existing Files Modified** +- All new files +- Extension to existing code without breaking changes + +✅ **Clear Separation of Concerns** +- Models: Pure data +- Providers: SDK integration +- Services: Business logic +- CLI: User interface + +✅ **Multi-Vendor Ready** +- Add new vendor: Just implement provider interface +- No CLI or service changes needed + +✅ **Testable** +- Mock provider at interface level +- Test services independently +- Integration tests for providers + +✅ **Future Proof** +- Easy to add Anthropic, Cohere, etc. +- Easy to swap implementations +- Easy to add new features + +--- + +## File Summary + +| File | Lines | Purpose | +|------|-------|---------| +| pkg/models/finetune.go | ~100 | Fine-tuning domain models | +| pkg/models/deployment.go | ~80 | Deployment domain models | +| pkg/models/errors.go | ~40 | Error handling models | +| pkg/models/requests.go | ~60 | Request DTOs | +| internal/providers/interface.go | ~70 | Provider interfaces | +| internal/providers/openai/provider.go | ~150 | OpenAI stub (TODO) | +| internal/providers/azure/provider.go | ~150 | Azure stub (TODO) | +| internal/services/interface.go | ~100 | Service interfaces | +| internal/services/state_store.go | ~60 | Persistence interfaces | +| internal/services/finetune_service.go | ~120 | Fine-tuning service stub | +| internal/services/deployment_service.go | ~90 | Deployment service stub | +| **Total** | **~920** | **Complete stub structure** | + diff --git a/cli/azd/extensions/azure.ai.finetune/design/architecture.md b/cli/azd/extensions/azure.ai.finetune/design/architecture.md new file mode 100644 index 00000000000..724bc83e39e --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/design/architecture.md @@ -0,0 +1,677 @@ +# Azure AI Fine-Tune Extension - Low Level Design + +## 1. Overview + +This document describes the proposed three-layer architecture for the Azure AI Fine-Tune CLI extension. The design emphasizes vendor abstraction, separation of concerns, and multi-vendor extensibility. + +### Key Objectives + +- **Phase 1**: Support OpenAI fine-tuning and Azure Cognitive Services model deployment +- **Future Phases**: Onboard additional vendors without refactoring CLI or service layer +- **Testability**: Enable unit testing of business logic independently from SDK implementations +- **Maintainability**: Clear boundaries between layers for easier debugging and feature development + +--- + +## 2. Architecture Overview + +### Complete Layered Architecture with Entities + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ DOMAIN MODELS / ENTITIES │ +│ (pkg/models/ - Shared Foundation) │ +│ │ +│ ├─ FineTuningJob ← All layers read/write these │ +│ ├─ Deployment │ +│ ├─ BaseModel │ +│ ├─ StandardError │ +│ ├─ CreateFineTuningRequest │ +│ └─ DeploymentRequest │ +│ │ +│ (No SDK imports! Pure data structures) │ +└──────────────────────────────────────────────────────────────────┘ + ↑ ↑ ↑ + │ (imports) │ (imports) │ (imports) + │ │ │ +┌───┴──────────────────┐ ┌──────────────┴──────┐ ┌─────────┴───────────┐ +│ CLI Layer │ │ Service Layer │ │ Provider Layer │ +│ (cmd/) │ │ (services/) │ │ (providers/) │ +│ │ │ │ │ │ +│ Uses: │ │ Uses: │ │ Uses: │ +│ - FineTuningJob ✅ │ │ - FineTuningJob ✅ │ │ - FineTuningJob ✅ │ +│ - Deployment ✅ │ │ - Deployment ✅ │ │ - Deployment ✅ │ +│ - Request DTOs ✅ │ │ - Request DTOs ✅ │ │ - Request DTOs ✅ │ +│ │ │ - StandardError ✅ │ │ - StandardError ✅ │ +│ Does: │ │ │ │ │ +│ - Parse input │ │ Does: │ │ Does: │ +│ - Format output │ │ - Validate │ │ - IMPORT SDK ⚠️ │ +│ - Call Service ↓ │ │ - Orchestrate │ │ - Convert domain → │ +│ │ │ - Call Provider ↓ │ │ SDK models │ +│ │ │ - State management │ │ - Call SDK │ +│ │ │ - Error transform │ │ - Convert SDK → │ +│ │ │ │ │ domain models │ +└──────────────────────┘ └─────────────────────┘ └─────────────────────┘ + ↓ + ┌────────────────────────────┴─────────┐ + │ SDK Layer (External) │ + │ │ + │ - OpenAI SDK │ + │ - Azure Cognitive Services SDK │ + │ - Future Vendor SDKs │ + └───────────────────────────────────────┘ +``` + +--- + +## 3. Layer Responsibilities + +### 3.1 Domain Models Layer (pkg/models/) + +**Responsibility**: Define vendor-agnostic data structures used across all layers. + +**Characteristics**: +- Zero SDK imports +- Pure data structures (Go structs) +- Single source of truth for data contracts +- Includes request/response DTOs and error types + +**What it Contains**: +- `FineTuningJob` - represents a fine-tuning job +- `Deployment` - represents a model deployment +- `CreateFineTuningRequest` - request to create a job +- `Hyperparameters` - training hyperparameters +- `ErrorDetail` - standardized error response +- `JobStatus`, `DeploymentStatus` - enums + +**Who Uses It**: All layers (CLI, Service, Provider) + +**Example Structure**: +```go +package models + +type FineTuningJob struct { + ID string + Status JobStatus + BaseModel string + FineTunedModel string + CreatedAt time.Time + CompletedAt *time.Time + VendorJobID string // Vendor-specific ID + VendorMetadata map[string]interface{} // Vendor-specific details + ErrorDetails *ErrorDetail +} + +type JobStatus string +const ( + StatusPending JobStatus = "pending" + StatusTraining JobStatus = "training" + StatusSucceeded JobStatus = "succeeded" + StatusFailed JobStatus = "failed" +) +``` + +--- + +### 3.2 CLI Layer (cmd/) + +**Responsibility**: Handle command parsing, user input validation, output formatting, and orchestration of user interactions. + +**Characteristics**: +- Does NOT import vendor SDKs +- Does NOT contain business logic +- Calls only the Service layer +- Responsible for presentation (table formatting, JSON output, etc.) + +**What it Does**: +- Parse command-line arguments and flags +- Validate user input format and constraints +- Call service methods to perform business logic +- Format responses for terminal output (tables, JSON, etc.) +- Handle error presentation to users +- Support multiple output formats (human-readable, JSON) + +**What it Does NOT Do**: +- Call SDK methods directly +- Implement business logic (validation, state management) +- Transform between vendor models +- Manage long-running operations (polling is in Service layer) + +**Imports**: +```go +import ( + "azure.ai.finetune/pkg/models" + "azure.ai.finetune/internal/services" + "github.com/spf13/cobra" // CLI framework +) +``` + +**Example**: +```go +func newOperationSubmitCommand(svc services.FineTuningService) *cobra.Command { + return &cobra.Command{ + Use: "submit", + Short: "Submit fine tuning job", + RunE: func(cmd *cobra.Command, args []string) error { + // 1. Parse input + req := &models.CreateFineTuningRequest{ + BaseModel: parseBaseModel(args), + TrainingDataID: parseTrainingFile(args), + } + + // 2. Call service (business logic) + job, err := svc.CreateFineTuningJob(cmd.Context(), req) + if err != nil { + return err + } + + // 3. Format output + printFineTuningJobTable(job) + return nil + }, + } +} +``` + +--- + +### 3.3 Service Layer (internal/services/) + +**Responsibility**: Implement business logic, orchestration, state management, and error standardization. + +**Characteristics**: +- Does NOT import vendor SDKs +- Imports Provider interface (abstraction, not concrete implementations) +- Central location for business rules +- Handles cross-vendor concerns +- Manages job lifecycle and state persistence + +**What it Does**: +- Validate business constraints (e.g., model limits, file sizes) +- Orchestrate multi-step operations +- Call provider methods to perform vendor-specific operations +- Transform vendor-specific errors to standardized `ErrorDetail` +- Manage job state persistence (local storage) +- Implement polling logic for long-running operations +- Handle retries and resilience patterns +- Manage job lifecycle state transitions + +**What it Does NOT Do**: +- Import SDK packages +- Format output for CLI +- Parse command-line arguments +- Call SDK methods directly + +**Key Interfaces**: +```go +type FineTuningProvider interface { + CreateFineTuningJob(ctx context.Context, req *CreateFineTuningRequest) (*FineTuningJob, error) + GetFineTuningStatus(ctx context.Context, jobID string) (*FineTuningJob, error) + ListFineTuningJobs(ctx context.Context) ([]*FineTuningJob, error) +} + +type StateStore interface { + SaveJob(job *FineTuningJob) error + GetJob(id string) (*FineTuningJob, error) + ListJobs() ([]*FineTuningJob, error) + UpdateJobStatus(id string, status JobStatus) error +} +``` + +**Imports**: +```go +import ( + "azure.ai.finetune/pkg/models" + "azure.ai.finetune/internal/providers" + "context" + "fmt" +) +``` + +**Example**: +```go +type FineTuningService struct { + provider providers.FineTuningProvider + stateStore StateStore +} + +func (s *FineTuningService) CreateFineTuningJob( + ctx context.Context, + req *models.CreateFineTuningRequest, +) (*models.FineTuningJob, error) { + // Business logic: validation + if err := s.validateRequest(req); err != nil { + return nil, fmt.Errorf("invalid request: %w", err) + } + + // Call abstracted provider (could be OpenAI, Azure, etc.) + job, err := s.provider.CreateFineTuningJob(ctx, req) + if err != nil { + // Transform vendor error to standard error + return nil, s.transformError(err) + } + + // State management: persist job + s.stateStore.SaveJob(job) + + return job, nil +} +``` + +--- + +### 3.4 Provider Layer (internal/providers/) + +**Responsibility**: Adapter pattern implementation. Bridge between domain models and vendor SDKs. + +**Characteristics**: +- **ONLY layer that imports vendor SDKs** +- Implements vendor-agnostic provider interface +- Converts between domain models and SDK models +- Handles vendor-specific error semantics +- No business logic (pure technical adaptation) + +**What it Does**: +- Import and instantiate vendor SDKs +- Convert domain models → SDK-specific request formats +- Call SDK methods +- Convert SDK response models → domain models +- Handle SDK-specific error codes and map to standard errors +- Manage SDK client lifecycle (initialization, auth) + +**What it Does NOT Do**: +- Implement business logic +- Manage state or persistence +- Format output for CLI +- Make decisions about retry logic or state transitions + +**Provider Interface** (in `internal/providers/interface.go` - No SDK imports!): +```go +package providers + +import ( + "context" + "azure.ai.finetune/pkg/models" +) + +type FineTuningProvider interface { + CreateFineTuningJob(ctx context.Context, req *models.CreateFineTuningRequest) (*models.FineTuningJob, error) + GetFineTuningStatus(ctx context.Context, jobID string) (*models.FineTuningJob, error) + ListFineTuningJobs(ctx context.Context) ([]*models.FineTuningJob, error) +} + +type ModelDeploymentProvider interface { + DeployModel(ctx context.Context, req *models.DeploymentRequest) (*models.Deployment, error) + GetDeploymentStatus(ctx context.Context, deploymentID string) (*models.Deployment, error) + DeleteDeployment(ctx context.Context, deploymentID string) error +} +``` + +**OpenAI Provider Example** (imports OpenAI SDK): +```go +package openai + +import ( + "context" + openaisdk "github.com/openai/openai-go" // ⚠️ SDK import! + "azure.ai.finetune/pkg/models" +) + +type OpenAIProvider struct { + client *openaisdk.Client +} + +func (p *OpenAIProvider) CreateFineTuningJob( + ctx context.Context, + req *models.CreateFineTuningRequest, +) (*models.FineTuningJob, error) { + // 1. Convert domain → SDK format + sdkReq := &openaisdk.FineTuningJobCreateParams{ + Model: openaisdk.F(req.BaseModel), + TrainingFile: openaisdk.F(req.TrainingDataID), + } + + // 2. Call SDK + sdkJob, err := p.client.FineTuning.Jobs.Create(ctx, sdkReq) + if err != nil { + return nil, err + } + + // 3. Convert SDK response → domain format + return p.sdkJobToDomain(sdkJob), nil +} + +// Helper: SDK model → domain model +func (p *OpenAIProvider) sdkJobToDomain(sdkJob *openaisdk.FineTuningJob) *models.FineTuningJob { + return &models.FineTuningJob{ + ID: sdkJob.ID, + Status: p.mapStatus(sdkJob.Status), + BaseModel: sdkJob.Model, + FineTunedModel: sdkJob.FineTunedModel, + VendorJobID: sdkJob.ID, + VendorMetadata: p.extractMetadata(sdkJob), + } +} +``` + +**Azure Provider Example** (imports Azure SDK): +```go +package azure + +import ( + "context" + cognitiveservices "github.com/Azure/azure-sdk-for-go/sdk/cognitiveservices" // Different SDK! + "azure.ai.finetune/pkg/models" +) + +type AzureProvider struct { + client *cognitiveservices.Client +} + +func (p *AzureProvider) CreateFineTuningJob( + ctx context.Context, + req *models.CreateFineTuningRequest, +) (*models.FineTuningJob, error) { + // 1. Convert domain → Azure SDK format + sdkReq := p.domainRequestToAzureSDK(req) + + // 2. Call Azure SDK (different from OpenAI!) + sdkJob, err := p.client.CreateFineTuningJob(ctx, sdkReq) + if err != nil { + return nil, err + } + + // 3. Convert Azure SDK response → SAME domain model as OpenAI! + return p.azureJobToDomain(sdkJob), nil +} +``` + +--- + +## 4. Import Dependencies + +### Valid Imports by Layer + +``` +pkg/models/ + ↑ ↑ ↑ + │ imports │ imports │ imports + │ (only) │ (only) │ (only) + │ │ │ +cmd/ services/ providers/ +├─ pkg/models ├─ pkg/models ├─ pkg/models +├─ services/ ├─ providers/ ├─ vendor SDKs ✅ +├─ pkg/config │ interface only └─ Azure SDK +└─ github.com/ │ OpenAI SDK + spf13/cobra └─ github.com/ etc. + context +``` + +### Strict Rules + +| Layer | CAN Import | CANNOT Import | +|-------|---|---| +| **cmd/** | `pkg/models`, `services/`, `pkg/config`, `github.com/spf13/cobra` | Any SDK (openai, azure), `providers/` concrete impl | +| **services/** | `pkg/models`, `providers/` (interface only), `context` | Any SDK, cmd, concrete provider implementations | +| **providers/** | `pkg/models`, vendor SDKs ✅ | cmd, services, other providers | +| **pkg/models/** | Nothing | Anything | + +--- + +## 5. Directory Structure + +``` +azure.ai.finetune/ +├── internal/ +│ ├── cmd/ # CLI Layer +│ │ ├── root.go # Root command +│ │ ├── operations.go # Finetune operations (submit, list, etc.) +│ │ ├── deployment.go # Deployment operations +│ │ └── output.go # Output formatting (tables, JSON) +│ │ +│ ├── services/ # Service Layer +│ │ ├── finetune_service.go # FineTuningService implementation +│ │ ├── deployment_service.go # DeploymentService implementation +│ │ ├── state_store.go # State persistence interface +│ │ └── error_transform.go # Error transformation logic +│ │ +│ ├── providers/ # Provider Layer +│ │ ├── interface.go # FineTuningProvider, ModelDeploymentProvider interfaces +│ │ │ # (NO SDK imports here!) +│ │ ├── openai/ +│ │ │ ├── provider.go # OpenAI implementation (SDK import!) +│ │ │ └── converters.go # Domain ↔ OpenAI SDK conversion +│ │ └── azure/ +│ │ ├── provider.go # Azure implementation (SDK import!) +│ │ └── converters.go # Domain ↔ Azure SDK conversion +│ │ +│ ├── project/ # Project utilities +│ ├── tools/ # Misc utilities +│ └── fine_tuning_yaml/ # YAML parsing +│ +├── pkg/ +│ └── models/ # Domain Models (Shared) +│ ├── finetune.go # FineTuningJob, JobStatus, etc. +│ ├── deployment.go # Deployment, DeploymentStatus, etc. +│ ├── requests.go # Request DTOs (Create, Update, etc.) +│ ├── errors.go # ErrorDetail, StandardError types +│ └── base_model.go # BaseModel, ModelInfo, etc. +│ +├── design/ +│ ├── architecture.md # This file +│ └── sequence_diagrams.md # Interaction flows (future) +│ +├── main.go +├── go.mod +└── README.md +``` + +--- + +## 6. Data Flow Examples + +### 6.1 Create Fine-Tuning Job Flow + +``` +User Command: + azd finetune jobs submit -f config.yaml + + ↓ + +CLI Layer (cmd/operations.go): + 1. Parse arguments + 2. Read config.yaml → CreateFineTuningRequest {BaseModel, TrainingDataID} + 3. Call service.CreateFineTuningJob(ctx, req) + + ↓ + +Service Layer (services/finetune_service.go): + 1. Validate request (model exists, data size valid, etc.) + 2. Get provider from config (OpenAI vs Azure) + 3. Call provider.CreateFineTuningJob(ctx, req) + 4. Transform any errors + 5. Persist job to state store + 6. Return FineTuningJob + + ↓ + +Provider Layer (providers/openai/provider.go): + 1. Convert CreateFineTuningRequest → OpenAI SDK format + 2. Call: client.FineTuning.Jobs.Create(ctx, sdkReq) + 3. Convert OpenAI response → FineTuningJob domain model + 4. Return FineTuningJob + + ↓ + +Service Layer: + Gets FineTuningJob back + Saves to state store + Returns to CLI + + ↓ + +CLI Layer: + Receives FineTuningJob + Formats for output (table or JSON) + Prints: "Job created: ftjob-abc123" + Exit +``` + +### 6.2 Switch Provider (OpenAI → Azure) + +``` +Code Change Needed: + ✅ internal/providers/azure/provider.go (new file) + ✅ internal/config/config.yaml (provider: azure) + ❌ internal/services/finetune_service.go (NO changes!) + ❌ cmd/operations.go (NO changes!) + +Why? + Service layer uses FineTuningProvider interface (abstracted) + CLI doesn't know about providers at all + Only provider layer imports SDK +``` + +### 6.3 Error Flow + +``` +User submits invalid data: + azd finetune jobs submit -f config.yaml + + ↓ + +CLI Layer: + Creates CreateFineTuningRequest from YAML + + ↓ + +Service Layer: + Validates: model not supported + Returns: &ErrorDetail{ + Code: "INVALID_MODEL", + Message: "Model 'gpt-5' not supported", + Retryable: false, + } + + ↓ + +CLI Layer: + Receives ErrorDetail + Prints user-friendly message + Exit with error code +``` + +--- + +## 7. Benefits of This Architecture + +### 7.1 Vendor Abstraction +- **Add new vendor**: Create `internal/providers/{vendor}/provider.go` +- **CLI changes**: None +- **Service changes**: None +- **Dependencies**: Only provider layer implementation + +### 7.2 Testability +- **Test business logic**: Mock provider at interface level +- **Test CLI**: Mock service +- **Test provider**: Use SDK directly (integration tests) + +### 7.3 Separation of Concerns +- **CLI**: What to show and how +- **Service**: What to do and how to do it (business rules) +- **Provider**: How to talk to vendor SDKs + +### 7.4 Maintainability +- **Vendor SDK updates**: Changes only in provider layer +- **Business logic changes**: Changes in service layer +- **Output format changes**: Changes in CLI layer + +### 7.5 Future Flexibility +- **Support multiple vendors simultaneously**: Multiple provider implementations +- **Provider selection at runtime**: Config-driven +- **A/B testing different implementations**: Easy switching + +--- + +## 8. Design Patterns Used + +### 8.1 Strategy Pattern +**Where**: Provider interface +``` +FineTuningProvider interface (strategy) +├── OpenAIProvider (concrete strategy) +├── AzureProvider (concrete strategy) +└── AnthropicProvider (future strategy) + +Service uses any strategy without knowing which +``` + +### 8.2 Adapter Pattern +**Where**: Provider implementations +- Convert domain models ↔ SDK models +- Standardize error responses + +### 8.3 Dependency Injection +**Where**: Service receives provider via constructor +```go +type FineTuningService struct { + provider providers.FineTuningProvider // Injected +} +``` + +### 8.4 Repository Pattern +**Where**: State persistence +```go +type StateStore interface { + SaveJob(job *FineTuningJob) error + GetJob(id string) (*FineTuningJob, error) +} +``` + +--- + +## 9. Phase 1 Implementation Checklist + +- [ ] Create `pkg/models/` with all domain models +- [ ] Create `internal/services/finetune_service.go` with interfaces +- [ ] Create `internal/services/deployment_service.go` with interfaces +- [ ] Create `internal/providers/interface.go` with provider interfaces +- [ ] Create `internal/providers/openai/provider.go` (OpenAI SDK) +- [ ] Create `internal/providers/azure/provider.go` (Azure SDK) +- [ ] Refactor `cmd/operations.go` to use service layer +- [ ] Create state store implementation (file or in-memory) +- [ ] Create unit tests for service layer +- [ ] Create integration tests for providers + +--- + +## 10. Future Considerations + +### 10.1 Phase 2: Additional Vendors +- Add `internal/providers/anthropic/provider.go` +- Add `internal/providers/cohere/provider.go` +- Service and CLI remain unchanged + +### 10.2 Async Job Tracking +- Service layer implements polling logic +- CLI supports `azd finetune jobs status ` +- Long-running operations tracked across sessions + +### 10.3 Webhook Support +- Service layer could support push notifications +- Provider layer handles webhook registration with vendor + +### 10.4 Cost Tracking +- Service layer accumulates cost metadata from providers +- CLI displays cost information + +--- + +## Questions for Team Discussion + +1. **State Persistence**: File-based or database-backed state store? +2. **Configuration**: YAML in project root or environment variables? +3. **Async Polling**: Should it run in background or user-initiated? +4. **Error Handling**: Retry logic - exponential backoff or fixed intervals? +5. **Testing**: Unit test requirements for service and provider layers? + diff --git a/cli/azd/extensions/azure.ai.finetune/examples/fine-tuning-dpo.yaml b/cli/azd/extensions/azure.ai.finetune/examples/fine-tuning-dpo.yaml new file mode 100644 index 00000000000..045abcf2fc5 --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/examples/fine-tuning-dpo.yaml @@ -0,0 +1,24 @@ +# Example: Direct Preference Optimization (DPO) Configuration +# Use this for preference-based fine-tuning with preferred vs non-preferred outputs + +model: gpt-4o-mini +training_file: "local:./dpo_training_data.jsonl" + +# Optional: Validation data for monitoring +validation_file: "local:./dpo_validation_data.jsonl" + +suffix: "dpo-optimized" + +# DPO method configuration +method: + type: dpo + dpo: + hyperparameters: + epochs: 2 + batch_size: 16 + learning_rate_multiplier: 0.5 + beta: 0.1 # Temperature parameter for DPO (can be float or "auto") + +metadata: + project: "preference-tuning" + model-type: "dpo" diff --git a/cli/azd/extensions/azure.ai.finetune/examples/fine-tuning-reinforcement.yaml b/cli/azd/extensions/azure.ai.finetune/examples/fine-tuning-reinforcement.yaml new file mode 100644 index 00000000000..db9df5b261f --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/examples/fine-tuning-reinforcement.yaml @@ -0,0 +1,35 @@ +# Example: Reinforcement Learning Fine-Tuning Configuration +# Use for reinforcement learning with reward model or grader-based evaluation + +model: gpt-4o-mini +training_file: "local:./rl_training_data.jsonl" + +# Optional: Validation data +validation_file: "local:./rl_validation_data.jsonl" + +suffix: "rl-trained" +seed: 42 + +# Reinforcement learning method configuration +method: + type: reinforcement + reinforcement: + hyperparameters: + epochs: 3 + batch_size: 8 + learning_rate_multiplier: 1.0 + beta: 0.5 # Weighting for RL reward signal + compute_multiplier: 1.0 # Training computation budget multiplier + reasoning_effort: high # Can be: low, medium, high + + # Grader configuration for reward evaluation + grader: + type: string_check # Grader type for string-based criteria + grader_config: + criteria: "answer contains correct chemical formula" + expected_pattern: "formula_pattern" + +metadata: + project: "reinforcement-learning" + training-type: "reward-based" + grader-version: "v1" diff --git a/cli/azd/extensions/azure.ai.finetune/examples/fine-tuning-supervised.yaml b/cli/azd/extensions/azure.ai.finetune/examples/fine-tuning-supervised.yaml new file mode 100644 index 00000000000..b009e51a815 --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/examples/fine-tuning-supervised.yaml @@ -0,0 +1,34 @@ +# Example: Supervised Fine-Tuning Configuration +# Use this for standard supervised learning tasks + +model: gpt-4o-mini +training_file: "local:./training_data.jsonl" +validation_file: "local:./validation_data.jsonl" + +# Optional: Custom suffix for fine-tuned model name +suffix: "my-custom-model" + +# Optional: Seed for reproducibility +seed: 42 + +# Fine-tuning method configuration +method: + type: supervised + supervised: + hyperparameters: + epochs: 3 # Number of training epochs + batch_size: 8 # Batch size (or "auto") + learning_rate_multiplier: 1.0 # Learning rate multiplier (or "auto") + +# Optional: Custom metadata +metadata: + project: "customer-support" + team: "ml-engineering" + version: "v1.0" + +# Optional: Integration with Weights & Biases for monitoring +integrations: + - type: wandb + config: + project: "fine-tuning-experiments" + name: "supervised-training" diff --git a/cli/azd/extensions/azure.ai.finetune/extension.yaml b/cli/azd/extensions/azure.ai.finetune/extension.yaml new file mode 100644 index 00000000000..700b34a44b3 --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/extension.yaml @@ -0,0 +1,28 @@ +id: azure.ai.finetune +namespace: ai.finetuning +displayName: Foundry Fine Tuning (Preview) +description: Extension for Foundry Fine Tuning. (Preview) +usage: azd ai finetuning [options] +version: 0.0.6-preview +language: go +capabilities: + - custom-commands + - lifecycle-events + - service-target-provider +providers: + - name: azure.ai.finetune + type: service-target + description: Deploys fine-tuning jobs to Azure Foundry +examples: + - name: init + description: Initialize a new AI fine-tuning project. + usage: azd ai finetuning init + - name: deploy + description: Deploy AI fine-tuning job to Azure. + usage: azd ai finetuning deploy + + + + + + diff --git a/cli/azd/extensions/azure.ai.finetune/go.mod b/cli/azd/extensions/azure.ai.finetune/go.mod new file mode 100644 index 00000000000..199acd61bbd --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/go.mod @@ -0,0 +1,91 @@ +module azure.ai.finetune + +go 1.25 + +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-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices v1.8.0 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 + github.com/azure/azure-dev/cli/azd v0.0.0-20251121010829-d5e0a142e813 + github.com/fatih/color v1.18.0 + github.com/openai/openai-go/v3 v3.2.0 + github.com/spf13/cobra v1.10.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/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/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/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/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/eiannone/keyboard v0.0.0-20220611211555-0d226195f203 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // 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/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/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/sethvargo/go-retry v0.3.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/theckman/yacspin v0.13.12 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.2.0 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // 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.38.0 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect + go.opentelemetry.io/otel/sdk v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + golang.org/x/crypto v0.43.0 // indirect + golang.org/x/net v0.46.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/term v0.36.0 // indirect + golang.org/x/text v0.30.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff // indirect + google.golang.org/grpc v1.76.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/cli/azd/extensions/azure.ai.finetune/go.sum b/cli/azd/extensions/azure.ai.finetune/go.sum new file mode 100644 index 00000000000..b82a7c3a7f1 --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/go.sum @@ -0,0 +1,289 @@ +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/cognitiveservices/armcognitiveservices v1.8.0 h1:ZMGAqCZov8+7iFUPWKVcTaLgNXUeTlz20sIuWkQWNfg= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices v1.8.0/go.mod h1:BElPQ/GZtrdQ2i5uDZw3OKLE1we75W0AEWyeBR1TWQA= +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/managementgroups/armmanagementgroups v1.0.0 h1:pPvTJ1dY0sA35JOeFq6TsY2xj6Z85Yo23Pj4wCCvu4o= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/managementgroups/armmanagementgroups v1.0.0/go.mod h1:mLfWfj8v3jfWKsL9G4eoBoXVcsqcIUTapmdKy7uGOp0= +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/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 v0.0.0-20251121010829-d5e0a142e813 h1:6RgPxlo9PsEc4q/IDkompYhL7U0+XdW0V4iP+1tpoKc= +github.com/azure/azure-dev/cli/azd v0.0.0-20251121010829-d5e0a142e813/go.mod h1:k86H7K6vCw8UmimYs0/gDTilxQwXUZDaikRYfDweB/U= +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/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/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/eiannone/keyboard v0.0.0-20220611211555-0d226195f203 h1:XBBHcIb256gUJtLmY22n99HaZTz+r2Z51xUPi01m3wg= +github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203/go.mod h1:E1jcSv8FaEny+OP/5k9UxZVw9YFWGj7eI4KR/iOBqCg= +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/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/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/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/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/openai/openai-go/v3 v3.2.0 h1:2AbqFUCsoW2pm/2pUtPRuwK89dnoGHaQokzWsfoQO/U= +github.com/openai/openai-go/v3 v3.2.0/go.mod h1:UOpNxkqC9OdNXNUfpNByKOtB4jAL0EssQXq5p8gO0Xs= +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/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +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/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= +github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +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/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.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +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.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +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.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +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/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.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +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.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= +golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= +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.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +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.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff h1:A90eA31Wq6HOMIQlLfzFwzqGKBTuaVztYu/g8sn+8Zc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= +google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/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.finetune/internal/cmd/converter.go b/cli/azd/extensions/azure.ai.finetune/internal/cmd/converter.go new file mode 100644 index 00000000000..e6cbdbef2c5 --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/internal/cmd/converter.go @@ -0,0 +1,206 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "github.com/openai/openai-go/v3" + + FTYaml "azure.ai.finetune/internal/fine_tuning_yaml" +) + +// ConvertYAMLToJobParams converts a YAML fine-tuning configuration to OpenAI job parameters +func ConvertYAMLToJobParams(config *FTYaml.FineTuningConfig, trainingFileID, validationFileID string) (openai.FineTuningJobNewParams, error) { + jobParams := openai.FineTuningJobNewParams{ + Model: openai.FineTuningJobNewParamsModel(config.Model), + TrainingFile: trainingFileID, + } + + if validationFileID != "" { + jobParams.ValidationFile = openai.String(validationFileID) + } + + // Set optional fields + if config.Suffix != nil { + jobParams.Suffix = openai.String(*config.Suffix) + } + + if config.Seed != nil { + jobParams.Seed = openai.Int(*config.Seed) + } + + // Set metadata if provided + if config.Metadata != nil && len(config.Metadata) > 0 { + jobParams.Metadata = make(map[string]string) + for k, v := range config.Metadata { + jobParams.Metadata[k] = v + } + } + + // Set hyperparameters if provided + if config.Method.Type == "supervised" && config.Method.Supervised != nil { + hp := config.Method.Supervised.Hyperparameters + supervisedMethod := openai.SupervisedMethodParam{ + Hyperparameters: openai.SupervisedHyperparameters{}, + } + + if hp.BatchSize != nil { + if batchSize := convertHyperparameterToInt(hp.BatchSize); batchSize != nil { + supervisedMethod.Hyperparameters.BatchSize = openai.SupervisedHyperparametersBatchSizeUnion{ + OfInt: openai.Int(*batchSize), + } + } + } + + if hp.LearningRateMultiplier != nil { + if lr := convertHyperparameterToFloat(hp.LearningRateMultiplier); lr != nil { + supervisedMethod.Hyperparameters.LearningRateMultiplier = openai.SupervisedHyperparametersLearningRateMultiplierUnion{ + OfFloat: openai.Float(*lr), + } + } + } + + if hp.Epochs != nil { + if epochs := convertHyperparameterToInt(hp.Epochs); epochs != nil { + supervisedMethod.Hyperparameters.NEpochs = openai.SupervisedHyperparametersNEpochsUnion{ + OfInt: openai.Int(*epochs), + } + } + } + + jobParams.Method = openai.FineTuningJobNewParamsMethod{ + Type: "supervised", + Supervised: supervisedMethod, + } + } else if config.Method.Type == "dpo" && config.Method.DPO != nil { + hp := config.Method.DPO.Hyperparameters + dpoMethod := openai.DpoMethodParam{ + Hyperparameters: openai.DpoHyperparameters{}, + } + + if hp.BatchSize != nil { + if batchSize := convertHyperparameterToInt(hp.BatchSize); batchSize != nil { + dpoMethod.Hyperparameters.BatchSize = openai.DpoHyperparametersBatchSizeUnion{ + OfInt: openai.Int(*batchSize), + } + } + } + + if hp.LearningRateMultiplier != nil { + if lr := convertHyperparameterToFloat(hp.LearningRateMultiplier); lr != nil { + dpoMethod.Hyperparameters.LearningRateMultiplier = openai.DpoHyperparametersLearningRateMultiplierUnion{ + OfFloat: openai.Float(*lr), + } + } + } + + if hp.Epochs != nil { + if epochs := convertHyperparameterToInt(hp.Epochs); epochs != nil { + dpoMethod.Hyperparameters.NEpochs = openai.DpoHyperparametersNEpochsUnion{ + OfInt: openai.Int(*epochs), + } + } + } + + if hp.Beta != nil { + if beta := convertHyperparameterToFloat(hp.Beta); beta != nil { + dpoMethod.Hyperparameters.Beta = openai.DpoHyperparametersBetaUnion{ + OfFloat: openai.Float(*beta), + } + } + } + + jobParams.Method = openai.FineTuningJobNewParamsMethod{ + Type: "dpo", + Dpo: dpoMethod, + } + } else if config.Method.Type == "reinforcement" && config.Method.Reinforcement != nil { + hp := config.Method.Reinforcement.Hyperparameters + reinforcementMethod := openai.ReinforcementMethodParam{ + Hyperparameters: openai.ReinforcementHyperparameters{}, + } + + if hp.BatchSize != nil { + if batchSize := convertHyperparameterToInt(hp.BatchSize); batchSize != nil { + reinforcementMethod.Hyperparameters.BatchSize = openai.ReinforcementHyperparametersBatchSizeUnion{ + OfInt: openai.Int(*batchSize), + } + } + } + + if hp.LearningRateMultiplier != nil { + if lr := convertHyperparameterToFloat(hp.LearningRateMultiplier); lr != nil { + reinforcementMethod.Hyperparameters.LearningRateMultiplier = openai.ReinforcementHyperparametersLearningRateMultiplierUnion{ + OfFloat: openai.Float(*lr), + } + } + } + + if hp.Epochs != nil { + if epochs := convertHyperparameterToInt(hp.Epochs); epochs != nil { + reinforcementMethod.Hyperparameters.NEpochs = openai.ReinforcementHyperparametersNEpochsUnion{ + OfInt: openai.Int(*epochs), + } + } + } + + if hp.ComputeMultiplier != nil { + if compute := convertHyperparameterToFloat(hp.ComputeMultiplier); compute != nil { + reinforcementMethod.Hyperparameters.ComputeMultiplier = openai.ReinforcementHyperparametersComputeMultiplierUnion{ + OfFloat: openai.Float(*compute), + } + } + } + + jobParams.Method = openai.FineTuningJobNewParamsMethod{ + Type: "reinforcement", + Reinforcement: reinforcementMethod, + } + } + + return jobParams, nil +} + +// convertHyperparameterToInt converts interface{} hyperparameter to *int64 +func convertHyperparameterToInt(value interface{}) *int64 { + if value == nil { + return nil + } + switch v := value.(type) { + case int: + val := int64(v) + return &val + case int64: + return &v + case float64: + val := int64(v) + return &val + case string: + // "auto" string handled separately + return nil + default: + return nil + } +} + +// convertHyperparameterToFloat converts interface{} hyperparameter to *float64 +func convertHyperparameterToFloat(value interface{}) *float64 { + if value == nil { + return nil + } + switch v := value.(type) { + case int: + val := float64(v) + return &val + case int64: + val := float64(v) + return &val + case float64: + return &v + case string: + // "auto" string handled separately + return nil + default: + return nil + } +} diff --git a/cli/azd/extensions/azure.ai.finetune/internal/cmd/init.go b/cli/azd/extensions/azure.ai.finetune/internal/cmd/init.go new file mode 100644 index 00000000000..1db89449d9e --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/internal/cmd/init.go @@ -0,0 +1,827 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/exec" + "github.com/azure/azure-dev/cli/azd/pkg/input" + "github.com/azure/azure-dev/cli/azd/pkg/tools/github" + "github.com/fatih/color" + "github.com/spf13/cobra" +) + +type initFlags struct { + rootFlagsDefinition + projectResourceId string + manifestPointer string + src string + env string +} + +// AiProjectResourceConfig represents the configuration for an AI project resource +type AiProjectResourceConfig struct { + Models []map[string]interface{} `json:"models,omitempty"` +} + +type InitAction struct { + azdClient *azdext.AzdClient + //azureClient *azure.AzureClient + azureContext *azdext.AzureContext + //composedResources []*azdext.ComposedResource + console input.Console + credential azcore.TokenCredential + projectConfig *azdext.ProjectConfig + environment *azdext.Environment + flags *initFlags +} + +// GitHubUrlInfo holds parsed information from a GitHub URL +type GitHubUrlInfo struct { + RepoSlug string + Branch string + FilePath string + Hostname string +} + +const AiFineTuningHost = "azure.ai.finetune" + +func newInitCommand(rootFlags rootFlagsDefinition) *cobra.Command { + flags := &initFlags{ + rootFlagsDefinition: rootFlags, + } + + cmd := &cobra.Command{ + Use: "init [-m ] [-p ]", + Short: fmt.Sprintf("Initialize a new AI Fine-tuning project. %s", color.YellowString("(Preview)")), + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := azdext.WithAccessToken(cmd.Context()) + + azdClient, err := azdext.NewAzdClient() + if err != nil { + return fmt.Errorf("failed to create azd client: %w", err) + } + defer azdClient.Close() + + azureContext, projectConfig, environment, err := ensureAzureContext(ctx, flags, azdClient) + if err != nil { + return fmt.Errorf("failed to ground into a project context: %w", err) + } + + // getComposedResourcesResponse, err := azdClient.Compose().ListResources(ctx, &azdext.EmptyRequest{}) + // if err != nil { + // return fmt.Errorf("failed to get composed resources: %w", err) + // } + + credential, err := azidentity.NewAzureDeveloperCLICredential(&azidentity.AzureDeveloperCLICredentialOptions{ + TenantID: azureContext.Scope.TenantId, + AdditionallyAllowedTenants: []string{"*"}, + }) + if err != nil { + return fmt.Errorf("failed to create azure credential: %w", err) + } + + console := input.NewConsole( + false, // noPrompt + true, // isTerminal + input.Writers{Output: os.Stdout}, + input.ConsoleHandles{ + Stderr: os.Stderr, + Stdin: os.Stdin, + Stdout: os.Stdout, + }, + nil, // formatter + nil, // externalPromptCfg + ) + + action := &InitAction{ + azdClient: azdClient, + azureContext: azureContext, + console: console, + credential: credential, + projectConfig: projectConfig, + environment: environment, + flags: flags, + } + + if err := action.Run(ctx); err != nil { + return fmt.Errorf("failed to run start action: %w", err) + } + + return nil + }, + } + + cmd.Flags().StringVarP(&flags.projectResourceId, "project-id", "p", "", + "Existing Microsoft Foundry Project Id to initialize your azd environment with") + + cmd.Flags().StringVarP(&flags.manifestPointer, "manifest", "m", "", + "Path or URI to an fine-tuning configuration to add to your azd project") + + cmd.Flags().StringVarP(&flags.env, "environment", "e", "", "The name of the azd environment to use.") + + return cmd +} + +type FoundryProject struct { + SubscriptionId string `json:"subscriptionId"` + ResourceGroupName string `json:"resourceGroupName"` + AiAccountName string `json:"aiAccountName"` + AiProjectName string `json:"aiProjectName"` +} + +func extractProjectDetails(projectResourceId string) (*FoundryProject, error) { + /// Define the regex pattern for the project resource ID + pattern := `^/subscriptions/([^/]+)/resourceGroups/([^/]+)/providers/Microsoft\.CognitiveServices/accounts/([^/]+)/projects/([^/]+)$` + + regex, err := regexp.Compile(pattern) + if err != nil { + return nil, fmt.Errorf("failed to compile regex pattern: %w", err) + } + + matches := regex.FindStringSubmatch(projectResourceId) + if matches == nil || len(matches) != 5 { + return nil, fmt.Errorf("the given Microsoft Foundry project ID does not match expected format: /subscriptions/[SUBSCRIPTION_ID]/resourceGroups/[RESOURCE_GROUP]/providers/Microsoft.CognitiveServices/accounts/[ACCOUNT_NAME]/projects/[PROJECT_NAME]") + } + + // Extract the components + return &FoundryProject{ + SubscriptionId: matches[1], + ResourceGroupName: matches[2], + AiAccountName: matches[3], + AiProjectName: matches[4], + }, nil +} + +func getExistingEnvironment(ctx context.Context, flags *initFlags, azdClient *azdext.AzdClient) *azdext.Environment { + var env *azdext.Environment + if flags.env == "" { + if envResponse, err := azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}); err == nil { + env = envResponse.Environment + } + } else { + if envResponse, err := azdClient.Environment().Get(ctx, &azdext.GetEnvironmentRequest{ + Name: flags.env, + }); err == nil { + env = envResponse.Environment + } + } + + return env +} + +func ensureEnvironment(ctx context.Context, flags *initFlags, azdClient *azdext.AzdClient) (*azdext.Environment, error) { + var foundryProject *FoundryProject + var foundryProjectLocation string + var tenantId string + + if flags.projectResourceId != "" { + var err error + foundryProject, err = extractProjectDetails(flags.projectResourceId) + if err != nil { + return nil, fmt.Errorf("failed to parse Microsoft Foundry project ID: %w", err) + } + + // Get the tenant ID + tenantResponse, err := azdClient.Account().LookupTenant(ctx, &azdext.LookupTenantRequest{ + SubscriptionId: foundryProject.SubscriptionId, + }) + if err != nil { + return nil, fmt.Errorf("failed to get tenant ID: %w", err) + } + tenantId = tenantResponse.TenantId + credential, err := azidentity.NewAzureDeveloperCLICredential(&azidentity.AzureDeveloperCLICredentialOptions{ + TenantID: tenantResponse.TenantId, + AdditionallyAllowedTenants: []string{"*"}, + }) + if err != nil { + return nil, fmt.Errorf("failed to create Azure credential: %w", err) + } + + // Create Cognitive Services Projects client + projectsClient, err := armcognitiveservices.NewProjectsClient(foundryProject.SubscriptionId, credential, nil) + if err != nil { + return nil, fmt.Errorf("failed to create Cognitive Services Projects client: %w", err) + } + + // Get the Microsoft Foundry project + projectResp, err := projectsClient.Get(ctx, foundryProject.ResourceGroupName, foundryProject.AiAccountName, foundryProject.AiProjectName, nil) + if err != nil { + return nil, fmt.Errorf("failed to get Microsoft Foundry project: %w", err) + } + + foundryProjectLocation = *projectResp.Location + } + + // Get specified or current environment if it exists + existingEnv := getExistingEnvironment(ctx, flags, azdClient) + if existingEnv == nil { + // Dispatch `azd env new` to create a new environment with interactive flow + fmt.Println("Lets create a new default azd environment for your project.") + + envArgs := []string{"env", "new"} + if flags.env != "" { + envArgs = append(envArgs, flags.env) + } + + if flags.projectResourceId != "" { + envArgs = append(envArgs, "--subscription", foundryProject.SubscriptionId) + envArgs = append(envArgs, "--location", foundryProjectLocation) + } + + // Dispatch a workflow to create a new environment + // Handles both interactive and no-prompt flows + workflow := &azdext.Workflow{ + Name: "env new", + Steps: []*azdext.WorkflowStep{ + {Command: &azdext.WorkflowCommand{Args: envArgs}}, + }, + } + + _, err := azdClient.Workflow().Run(ctx, &azdext.RunWorkflowRequest{ + Workflow: workflow, + }) + if err != nil { + return nil, fmt.Errorf("failed to create new azd environment: %w", err) + } + + // Re-fetch the environment after creation + existingEnv = getExistingEnvironment(ctx, flags, azdClient) + if existingEnv == nil { + return nil, fmt.Errorf("azd environment not found, please create an environment (azd env new) and try again") + } + } + if flags.projectResourceId != "" { + currentResouceGroupName, err := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ + EnvName: existingEnv.Name, + Key: "AZURE_RESOURCE_GROUP_NAME", + }) + if err != nil { + return nil, fmt.Errorf("failed to get current AZURE_RESOURCE_GROUP_NAME from azd environment: %w", err) + } + + if currentResouceGroupName.Value != foundryProject.ResourceGroupName { + // Set the subscription ID in the environment + _, err = azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ + EnvName: existingEnv.Name, + Key: "AZURE_RESOURCE_GROUP_NAME", + Value: foundryProject.ResourceGroupName, + }) + if err != nil { + return nil, fmt.Errorf("failed to set AZURE_ACCOUNT_NAME in azd environment: %w", err) + } + } + + currentTenantId, err := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ + EnvName: existingEnv.Name, + Key: "AZURE_TENANT_ID", + }) + if err != nil { + return nil, fmt.Errorf("failed to get current AZURE_TENANT_ID from azd environment: %w", err) + } + if currentTenantId.Value == "" { + _, err = azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ + EnvName: existingEnv.Name, + Key: "AZURE_TENANT_ID", + Value: tenantId, + }) + } + + currentAccount, err := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ + EnvName: existingEnv.Name, + Key: "AZURE_ACCOUNT_NAME", + }) + if err != nil { + return nil, fmt.Errorf("failed to get current AZURE_ACCOUNT_NAME from azd environment: %w", err) + } + + if currentAccount.Value != foundryProject.AiAccountName { + // Set the subscription ID in the environment + _, err = azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ + EnvName: existingEnv.Name, + Key: "AZURE_ACCOUNT_NAME", + Value: foundryProject.AiAccountName, + }) + if err != nil { + return nil, fmt.Errorf("failed to set AZURE_ACCOUNT_NAME in azd environment: %w", err) + } + } + + currentSubscription, err := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ + EnvName: existingEnv.Name, + Key: "AZURE_SUBSCRIPTION_ID", + }) + if err != nil { + return nil, fmt.Errorf("failed to get current AZURE_SUBSCRIPTION_ID from azd environment: %w", err) + } + + if currentSubscription.Value == "" { + // Set the subscription ID in the environment + _, err = azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ + EnvName: existingEnv.Name, + Key: "AZURE_SUBSCRIPTION_ID", + Value: foundryProject.SubscriptionId, + }) + if err != nil { + return nil, fmt.Errorf("failed to set AZURE_SUBSCRIPTION_ID in azd environment: %w", err) + } + } else if currentSubscription.Value != foundryProject.SubscriptionId { + return nil, fmt.Errorf("the value for subscription ID (%s) stored in your azd environment does not match the provided Microsoft Foundry project subscription ID (%s), please update or recreate your environment (azd env new)", currentSubscription.Value, foundryProject.SubscriptionId) + } + + // Get current location from environment + currentLocation, err := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ + EnvName: existingEnv.Name, + Key: "AZURE_LOCATION", + }) + if err != nil { + return nil, fmt.Errorf("failed to get AZURE_LOCATION from azd environment: %w", err) + } + + if currentLocation.Value == "" { + // Set the location in the environment + _, err = azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ + EnvName: existingEnv.Name, + Key: "AZURE_LOCATION", + Value: foundryProjectLocation, + }) + if err != nil { + return nil, fmt.Errorf("failed to set AZURE_LOCATION in environment: %w", err) + } + } else if currentLocation.Value != foundryProjectLocation { + return nil, fmt.Errorf("the value for location (%s) stored in your azd environment does not match the provided Microsoft Foundry project location (%s), please update or recreate your environment (azd env new)", currentLocation.Value, foundryProjectLocation) + } + } + + return existingEnv, nil +} +func ensureProject(ctx context.Context, flags *initFlags, azdClient *azdext.AzdClient) (*azdext.ProjectConfig, error) { + projectResponse, err := azdClient.Project().Get(ctx, &azdext.EmptyRequest{}) + if err != nil { + fmt.Println("Lets get your project initialized.") + + initArgs := []string{"init"} + if flags.env != "" { + initArgs = append(initArgs, "-e", flags.env) + } + + // We don't have a project yet + // Dispatch a workflow to init the project + workflow := &azdext.Workflow{ + Name: "init", + Steps: []*azdext.WorkflowStep{ + {Command: &azdext.WorkflowCommand{Args: initArgs}}, + }, + } + + _, err := azdClient.Workflow().Run(ctx, &azdext.RunWorkflowRequest{ + Workflow: workflow, + }) + + if err != nil { + return nil, fmt.Errorf("failed to initialize project: %w", err) + } + + projectResponse, err = azdClient.Project().Get(ctx, &azdext.EmptyRequest{}) + if err != nil { + return nil, fmt.Errorf("failed to get project: %w", err) + } + + fmt.Println() + } + + if projectResponse.Project == nil { + return nil, fmt.Errorf("project not found") + } + + return projectResponse.Project, nil +} + +func ensureAzureContext( + ctx context.Context, + flags *initFlags, + azdClient *azdext.AzdClient, +) (*azdext.AzureContext, *azdext.ProjectConfig, *azdext.Environment, error) { + project, err := ensureProject(ctx, flags, azdClient) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to ensure project: %w", err) + } + + env, err := ensureEnvironment(ctx, flags, azdClient) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to ensure environment: %w", err) + } + + envValues, err := azdClient.Environment().GetValues(ctx, &azdext.GetEnvironmentRequest{ + Name: env.Name, + }) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to get environment values: %w", err) + } + + envValueMap := make(map[string]string) + for _, value := range envValues.KeyValues { + envValueMap[value.Key] = value.Value + } + + azureContext := &azdext.AzureContext{ + Scope: &azdext.AzureScope{ + TenantId: envValueMap["AZURE_TENANT_ID"], + SubscriptionId: envValueMap["AZURE_SUBSCRIPTION_ID"], + Location: envValueMap["AZURE_LOCATION"], + }, + Resources: []string{}, + } + + if azureContext.Scope.SubscriptionId == "" { + fmt.Print() + fmt.Println("It looks like we first need to connect to your Azure subscription.") + + subscriptionResponse, err := azdClient.Prompt().PromptSubscription(ctx, &azdext.PromptSubscriptionRequest{}) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to prompt for subscription: %w", err) + } + + azureContext.Scope.SubscriptionId = subscriptionResponse.Subscription.Id + azureContext.Scope.TenantId = subscriptionResponse.Subscription.TenantId + + // Set the subscription ID in the environment + _, err = azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ + EnvName: env.Name, + Key: "AZURE_TENANT_ID", + Value: azureContext.Scope.TenantId, + }) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to set AZURE_TENANT_ID in environment: %w", err) + } + + // Set the tenant ID in the environment + _, err = azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ + EnvName: env.Name, + Key: "AZURE_SUBSCRIPTION_ID", + Value: azureContext.Scope.SubscriptionId, + }) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to set AZURE_SUBSCRIPTION_ID in environment: %w", err) + } + } + + if azureContext.Scope.Location == "" { + fmt.Println() + fmt.Println( + "Next, we need to select a default Azure location that will be used as the target for your infrastructure.", + ) + + locationResponse, err := azdClient.Prompt().PromptLocation(ctx, &azdext.PromptLocationRequest{ + AzureContext: azureContext, + }) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to prompt for location: %w", err) + } + + azureContext.Scope.Location = locationResponse.Location.Name + + // Set the location in the environment + _, err = azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ + EnvName: env.Name, + Key: "AZURE_LOCATION", + Value: azureContext.Scope.Location, + }) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to set AZURE_LOCATION in environment: %w", err) + } + } + + return azureContext, project, env, nil +} + +func (a *InitAction) Run(ctx context.Context) error { + color.Green("Initializing Fine tuning project...") + time.Sleep(1 * time.Second) + color.Green("Downloading template files...") + time.Sleep(2 * time.Second) + + color.Green("Creating fine-tuning Job definition...") + defaultModel := "gpt-4o-mini" + defaultMethod := "supervised" + modelDeploymentInput, err := a.azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ + Options: &azdext.PromptOptions{ + Message: "Enter base model name for fine tuning (defaults to model name)", + IgnoreHintKeys: true, + DefaultValue: defaultModel, + }, + }) + ftMethodInput, err := a.azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ + Options: &azdext.PromptOptions{ + Message: "Enter fine-tuning method (defaults to supervised)", + IgnoreHintKeys: true, + DefaultValue: defaultMethod, + }, + }) + if err != nil { + return err + } + fmt.Printf("Base model : %s, Fine-tuning method: %s\n", modelDeploymentInput.Value, ftMethodInput.Value) + if a.flags.manifestPointer != "" { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get current working directory: %w", err) + } + if a.isGitHubUrl(a.flags.manifestPointer) { + // For container agents, download the entire parent directory + fmt.Println("Downloading full directory for fine-tuning configuration from GitHub...") + var ghCli *github.Cli + var console input.Console + var urlInfo *GitHubUrlInfo + // Create a simple console and command runner for GitHub CLI + commandRunner := exec.NewCommandRunner(&exec.RunnerOptions{ + Stdout: os.Stdout, + Stderr: os.Stderr, + }) + + console = input.NewConsole( + false, // noPrompt + true, // isTerminal + input.Writers{Output: os.Stdout}, + input.ConsoleHandles{ + Stderr: os.Stderr, + Stdin: os.Stdin, + Stdout: os.Stdout, + }, + nil, // formatter + nil, // externalPromptCfg + ) + ghCli, err = github.NewGitHubCli(ctx, console, commandRunner) + if err != nil { + return fmt.Errorf("creating GitHub CLI: %w", err) + } + + urlInfo, err = parseGitHubUrl(a.flags.manifestPointer) + if err != nil { + return err + } + + apiPath := fmt.Sprintf("/repos/%s/contents/%s", urlInfo.RepoSlug, urlInfo.FilePath) + if urlInfo.Branch != "" { + fmt.Printf("Downloaded manifest from branch: %s\n", urlInfo.Branch) + apiPath += fmt.Sprintf("?ref=%s", urlInfo.Branch) + } + err := downloadParentDirectory(ctx, urlInfo, cwd, ghCli, console) + if err != nil { + return fmt.Errorf("downloading parent directory: %w", err) + } + } else { + if err := copyDirectory(a.flags.manifestPointer, cwd); err != nil { + return fmt.Errorf("failed to copy directory: %w", err) + } + } + } + fmt.Println() + color.Green("Initialized fine-tuning Project.") + + return nil +} + +// parseGitHubUrl extracts repository information from various GitHub URL formats +// TODO: This will fail if the branch contains a slash. Update to handle that case if needed. +func parseGitHubUrl(manifestPointer string) (*GitHubUrlInfo, error) { + parsedURL, err := url.Parse(manifestPointer) + if err != nil { + return nil, fmt.Errorf("failed to parse URL: %w", err) + } + + hostname := parsedURL.Hostname() + var repoSlug, branch, filePath string + + if strings.HasPrefix(hostname, "raw.") { + // https://raw.githubusercontent.com///refs/heads//[...path]/.yaml + pathParts := strings.Split(parsedURL.Path, "/") + if len(pathParts) < 7 { + return nil, fmt.Errorf("invalid URL format using 'raw.'. Expected the form of " + + "'https://raw.///refs/heads//[...path]/.json'") + } + if pathParts[3] != "refs" || pathParts[4] != "heads" { + return nil, fmt.Errorf("invalid raw GitHub URL format. Expected 'refs/heads' in the URL path") + } + repoSlug = fmt.Sprintf("%s/%s", pathParts[1], pathParts[2]) + branch = pathParts[5] + filePath = strings.Join(pathParts[6:], "/") + } else if strings.HasPrefix(hostname, "api.") { + // https://api.github.com/repos///contents/[...path]/.yaml + pathParts := strings.Split(parsedURL.Path, "/") + if len(pathParts) < 6 { + return nil, fmt.Errorf("invalid URL format using 'api.'. Expected the form of " + + "'https://api./repos///contents/[...path]/.json[?ref=]'") + } + repoSlug = fmt.Sprintf("%s/%s", pathParts[2], pathParts[3]) + filePath = strings.Join(pathParts[5:], "/") + // For API URLs, branch is specified in the query parameter ref + branch = parsedURL.Query().Get("ref") + if branch == "" { + branch = "main" // default branch if not specified + } + } else if strings.HasPrefix(manifestPointer, "https://") { + // https://github.com///blob//[...path]/.yaml + pathParts := strings.Split(parsedURL.Path, "/") + if len(pathParts) < 6 { + return nil, fmt.Errorf("invalid URL format. Expected the form of " + + "'https://///blob//[...path]/.json'") + } + if pathParts[3] != "blob" { + return nil, fmt.Errorf("invalid GitHub URL format. Expected 'blob' in the URL path") + } + repoSlug = fmt.Sprintf("%s/%s", pathParts[1], pathParts[2]) + branch = pathParts[4] + filePath = strings.Join(pathParts[5:], "/") + } else { + return nil, fmt.Errorf( + "invalid URL format. Expected formats are:\n" + + " - 'https://raw.///refs/heads//[...path]/.json'\n" + + " - 'https://///blob//[...path]/.json'\n" + + " - 'https://api./repos///contents/[...path]/.json[?ref=]'", + ) + } + + // Normalize hostname for API calls + if hostname == "raw.githubusercontent.com" { + hostname = "github.com" + } + + return &GitHubUrlInfo{ + RepoSlug: repoSlug, + Branch: branch, + FilePath: filePath, + Hostname: hostname, + }, nil +} + +func (a *InitAction) isGitHubUrl(manifestPointer string) bool { + // Check if it's a GitHub URL based on the patterns from downloadGithubManifest + parsedURL, err := url.Parse(manifestPointer) + if err != nil { + return false + } + hostname := parsedURL.Hostname() + + // Check for GitHub URL patterns as defined in downloadGithubManifest + return strings.HasPrefix(hostname, "raw.githubusercontent") || + strings.HasPrefix(hostname, "api.github") || + strings.Contains(hostname, "github") +} + +func downloadParentDirectory( + ctx context.Context, urlInfo *GitHubUrlInfo, targetDir string, ghCli *github.Cli, console input.Console) error { + + // Get parent directory by removing the filename from the file path + pathParts := strings.Split(urlInfo.FilePath, "/") + if len(pathParts) <= 1 { + fmt.Println("The file agent.yaml is at repository root, no parent directory to download") + return nil + } + + parentDirPath := strings.Join(pathParts[:len(pathParts)-1], "/") + fmt.Printf("Downloading parent directory '%s' from repository '%s', branch '%s'\n", parentDirPath, urlInfo.RepoSlug, urlInfo.Branch) + + // Download directory contents + if err := downloadDirectoryContents(ctx, urlInfo.Hostname, urlInfo.RepoSlug, parentDirPath, urlInfo.Branch, targetDir, ghCli, console); err != nil { + return fmt.Errorf("failed to download directory contents: %w", err) + } + + fmt.Printf("Successfully downloaded parent directory to: %s\n", targetDir) + return nil +} + +func downloadDirectoryContents( + ctx context.Context, hostname string, repoSlug string, dirPath string, branch string, localPath string, ghCli *github.Cli, console input.Console) error { + + // Get directory contents using GitHub API + apiPath := fmt.Sprintf("/repos/%s/contents/%s", repoSlug, dirPath) + if branch != "" { + apiPath += fmt.Sprintf("?ref=%s", branch) + } + + dirContentsJson, err := ghCli.ApiCall(ctx, hostname, apiPath, github.ApiCallOptions{}) + if err != nil { + return fmt.Errorf("failed to get directory contents: %w", err) + } + + // Parse the directory contents JSON + var dirContents []map[string]interface{} + if err := json.Unmarshal([]byte(dirContentsJson), &dirContents); err != nil { + return fmt.Errorf("failed to parse directory contents JSON: %w", err) + } + + // Download each file and subdirectory + for _, item := range dirContents { + name, ok := item["name"].(string) + if !ok { + continue + } + + itemType, ok := item["type"].(string) + if !ok { + continue + } + + itemPath := fmt.Sprintf("%s/%s", dirPath, name) + itemLocalPath := filepath.Join(localPath, name) + + if itemType == "file" { + // Download file + fmt.Printf("Downloading file: %s\n", itemPath) + fileApiPath := fmt.Sprintf("/repos/%s/contents/%s", repoSlug, itemPath) + if branch != "" { + fileApiPath += fmt.Sprintf("?ref=%s", branch) + } + + fileContent, err := ghCli.ApiCall(ctx, hostname, fileApiPath, github.ApiCallOptions{ + Headers: []string{"Accept: application/vnd.github.v3.raw"}, + }) + if err != nil { + return fmt.Errorf("failed to download file %s: %w", itemPath, err) + } + + if err := os.WriteFile(itemLocalPath, []byte(fileContent), 0644); err != nil { + return fmt.Errorf("failed to write file %s: %w", itemLocalPath, err) + } + } else if itemType == "dir" { + // Recursively download subdirectory + fmt.Printf("Downloading directory: %s\n", itemPath) + if err := os.MkdirAll(itemLocalPath, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", itemLocalPath, err) + } + + // Recursively download directory contents + if err := downloadDirectoryContents(ctx, hostname, repoSlug, itemPath, branch, itemLocalPath, ghCli, console); err != nil { + return fmt.Errorf("failed to download subdirectory %s: %w", itemPath, err) + } + } + } + + return nil +} + +// copyDirectory recursively copies all files and directories from src to dst +func copyDirectory(src, dst string) error { + return filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + + // Calculate the destination path + relPath, err := filepath.Rel(src, path) + if err != nil { + return err + } + dstPath := filepath.Join(dst, relPath) + + if d.IsDir() { + // Create directory and continue processing its contents + return os.MkdirAll(dstPath, 0755) + } else { + // Copy file + return copyFile(path, dstPath) + } + }) +} + +// copyFile copies a single file from src to dst +func copyFile(src, dst string) error { + // Create the destination directory if it doesn't exist + if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { + return err + } + + // Open source file + srcFile, err := os.Open(src) + if err != nil { + return err + } + defer srcFile.Close() + + // Create destination file + dstFile, err := os.Create(dst) + if err != nil { + return err + } + defer dstFile.Close() + + // Copy file contents + _, err = srcFile.WriteTo(dstFile) + return err +} diff --git a/cli/azd/extensions/azure.ai.finetune/internal/cmd/listen.go b/cli/azd/extensions/azure.ai.finetune/internal/cmd/listen.go new file mode 100644 index 00000000000..ca78d5fe642 --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/internal/cmd/listen.go @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + "time" + + "azure.ai.finetune/internal/project" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +func newListenCommand() *cobra.Command { + return &cobra.Command{ + Use: "listen", + Short: "Starts the extension and listens for events.", + Hidden: true, + RunE: func(cmd *cobra.Command, args []string) error { + // Create a new context that includes the AZD access token. + ctx := azdext.WithAccessToken(cmd.Context()) + + // Create a new AZD client. + azdClient, err := azdext.NewAzdClient() + if err != nil { + return fmt.Errorf("failed to create azd client: %w", err) + } + defer azdClient.Close() + + // IMPORTANT: service target name here must match the name used in the extension manifest. + host := azdext.NewExtensionHost(azdClient). + WithServiceTarget(AiFineTuningHost, func() azdext.ServiceTargetProvider { + return project.NewFineTuneServiceTargetProvider(azdClient) + }). + WithProjectEventHandler("preprovision", func(ctx context.Context, args *azdext.ProjectEventArgs) error { + return preprovisionHandler(ctx, azdClient, args) + }). + WithProjectEventHandler("predeploy", func(ctx context.Context, args *azdext.ProjectEventArgs) error { + return predeployHandler(ctx, azdClient, args) + }). + WithProjectEventHandler("postdeploy", func(ctx context.Context, args *azdext.ProjectEventArgs) error { + return postdeployHandler(ctx, azdClient, args) + }) + + // Start listening for events + // This is a blocking call and will not return until the server connection is closed. + if err := host.Run(ctx); err != nil { + return fmt.Errorf("failed to run extension: %w", err) + } + + return nil + }, + } +} + +func preprovisionHandler(ctx context.Context, azdClient *azdext.AzdClient, args *azdext.ProjectEventArgs) error { + fmt.Println("preprovisionHandler: Starting pre-provision event handling") + time.Sleep(2 * time.Second) + return nil +} + +func predeployHandler(ctx context.Context, azdClient *azdext.AzdClient, args *azdext.ProjectEventArgs) error { + fmt.Println("predeployHandler: Starting pre-deploy event handling") + time.Sleep(2 * time.Second) + return nil +} + +func postdeployHandler(ctx context.Context, azdClient *azdext.AzdClient, args *azdext.ProjectEventArgs) error { + fmt.Println("postdeployHandler: Starting post-deploy event handling") + time.Sleep(2 * time.Second) + return nil +} diff --git a/cli/azd/extensions/azure.ai.finetune/internal/cmd/operations.go b/cli/azd/extensions/azure.ai.finetune/internal/cmd/operations.go new file mode 100644 index 00000000000..2c18f964002 --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/internal/cmd/operations.go @@ -0,0 +1,459 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "fmt" + "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/ux" + "github.com/fatih/color" + + "github.com/spf13/cobra" + + FTYaml "azure.ai.finetune/internal/fine_tuning_yaml" + JobWrapper "azure.ai.finetune/internal/tools" +) + +func newOperationCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "jobs", + Short: "Manage fine-tuning jobs", + } + + cmd.AddCommand(newOperationSubmitCommand()) + cmd.AddCommand(newOperationShowCommand()) + cmd.AddCommand(newOperationListCommand()) + cmd.AddCommand(newOperationActionCommand()) + cmd.AddCommand(newOperationDeployModelCommand()) + + return cmd +} + +// getStatusSymbol returns a symbol representation for job status +func getStatusSymbol(status string) string { + switch status { + case "pending": + return "⌛" + case "queued": + return "📚" + case "running": + return "🔄" + case "succeeded": + return "✅" + case "failed": + return "💥" + case "cancelled": + return "❌" + default: + return "❓" + } +} + +// formatFineTunedModel returns the model name or "NA" if blank +func formatFineTunedModel(model string) string { + if model == "" { + return "NA" + } + return model +} + +func newOperationSubmitCommand() *cobra.Command { + var filename string + cmd := &cobra.Command{ + Use: "submit", + Short: "Submit fine tuning job", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := azdext.WithAccessToken(cmd.Context()) + + // Validate filename is provided + if filename == "" { + return fmt.Errorf("config file is required, use -f or --file flag") + } + + azdClient, err := azdext.NewAzdClient() + if err != nil { + return fmt.Errorf("failed to create azd client: %w", err) + } + defer azdClient.Close() + + // Parse and validate the YAML configuration file + color.Green("Parsing configuration file...") + config, err := FTYaml.ParseFineTuningConfig(filename) + if err != nil { + return err + } + + // Upload training file + + trainingFileID, err := JobWrapper.UploadFileIfLocal(ctx, azdClient, config.TrainingFile) + if err != nil { + return fmt.Errorf("failed to upload training file: %w", err) + } + + // Upload validation file if provided + var validationFileID string + if config.ValidationFile != "" { + validationFileID, err = JobWrapper.UploadFileIfLocal(ctx, azdClient, config.ValidationFile) + if err != nil { + return fmt.Errorf("failed to upload validation file: %w", err) + } + } + + // Create fine-tuning job + // Convert YAML configuration to OpenAI job parameters + jobParams, err := ConvertYAMLToJobParams(config, trainingFileID, validationFileID) + if err != nil { + return fmt.Errorf("failed to convert configuration to job parameters: %w", err) + } + + // Submit the fine-tuning job using CreateJob from JobWrapper + job, err := JobWrapper.CreateJob(ctx, azdClient, jobParams) + if err != nil { + return err + } + + // Print success message + fmt.Println(strings.Repeat("=", 120)) + color.Green("\nSuccessfully submitted fine-tuning Job!\n") + fmt.Printf("Job ID: %s\n", job.Id) + fmt.Printf("Model: %s\n", job.Model) + fmt.Printf("Status: %s\n", job.Status) + fmt.Printf("Created: %s\n", job.CreatedAt) + if job.FineTunedModel != "" { + fmt.Printf("Fine-tuned: %s\n", job.FineTunedModel) + } + fmt.Println(strings.Repeat("=", 120)) + + return nil + }, + } + + cmd.Flags().StringVarP(&filename, "file", "f", "", "Path to the config file") + + return cmd +} + +func newOperationShowCommand() *cobra.Command { + var jobID string + + cmd := &cobra.Command{ + Use: "show", + Short: "Show the fine tuning job details", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := azdext.WithAccessToken(cmd.Context()) + azdClient, err := azdext.NewAzdClient() + if err != nil { + return fmt.Errorf("failed to create azd client: %w", err) + } + defer azdClient.Close() + // Show spinner while fetching jobs + spinner := ux.NewSpinner(&ux.SpinnerOptions{ + Text: fmt.Sprintf("Fetching fine-tuning job %s...", jobID), + }) + if err := spinner.Start(ctx); err != nil { + fmt.Printf("Failed to start spinner: %v\n", err) + } + + // Fetch fine-tuning job details using job wrapper + job, err := JobWrapper.GetJobDetails(ctx, azdClient, jobID) + _ = spinner.Stop(ctx) + + if err != nil { + return fmt.Errorf("failed to get fine-tuning job details: %w", err) + } + + // Print job details + color.Green("\nFine-Tuning Job Details\n") + fmt.Printf("Job ID: %s\n", job.Id) + fmt.Printf("Status: %s %s\n", getStatusSymbol(job.Status), job.Status) + fmt.Printf("Model: %s\n", job.Model) + fmt.Printf("Fine-tuned Model: %s\n", formatFineTunedModel(job.FineTunedModel)) + fmt.Printf("Created At: %s\n", job.CreatedAt) + if job.FinishedAt != "" { + fmt.Printf("Finished At: %s\n", job.FinishedAt) + } + fmt.Printf("Method: %s\n", job.Method) + fmt.Printf("Training File: %s\n", job.TrainingFile) + if job.ValidationFile != "" { + fmt.Printf("Validation File: %s\n", job.ValidationFile) + } + + // Print hyperparameters if available + if job.Hyperparameters != nil { + fmt.Println("\nHyperparameters:") + fmt.Printf(" Batch Size: %d\n", job.Hyperparameters.BatchSize) + fmt.Printf(" Learning Rate Multiplier: %f\n", job.Hyperparameters.LearningRateMultiplier) + fmt.Printf(" N Epochs: %d\n", job.Hyperparameters.NEpochs) + } + + // Fetch and print events + eventsSpinner := ux.NewSpinner(&ux.SpinnerOptions{ + Text: "Fetching job events...", + }) + if err := eventsSpinner.Start(ctx); err != nil { + fmt.Printf("Failed to start spinner: %v\n", err) + } + + events, err := JobWrapper.GetJobEvents(ctx, azdClient, jobID) + _ = eventsSpinner.Stop(ctx) + + if err != nil { + fmt.Printf("Warning: failed to fetch job events: %v\n", err) + } else if events != nil && len(events.Data) > 0 { + fmt.Println("\nJob Events:") + for i, event := range events.Data { + fmt.Printf(" %d. [%s] %s - %s\n", i+1, event.Level, event.CreatedAt, event.Message) + } + if events.HasMore { + fmt.Println(" ... (more events available)") + } + } + + // Fetch and print checkpoints if job is completed + if job.Status == "succeeded" { + checkpointsSpinner := ux.NewSpinner(&ux.SpinnerOptions{ + Text: "Fetching job checkpoints...", + }) + if err := checkpointsSpinner.Start(ctx); err != nil { + fmt.Printf("Failed to start spinner: %v\n", err) + } + + checkpoints, err := JobWrapper.GetJobCheckPoints(ctx, azdClient, jobID) + _ = checkpointsSpinner.Stop(ctx) + + if err != nil { + fmt.Printf("Warning: failed to fetch job checkpoints: %v\n", err) + } else if checkpoints != nil && len(checkpoints.Data) > 0 { + fmt.Println("\nJob Checkpoints:") + for i, checkpoint := range checkpoints.Data { + fmt.Printf(" %d. Checkpoint ID: %s\n", i+1, checkpoint.ID) + fmt.Printf(" Checkpoint Name: %s\n", checkpoint.FineTunedModelCheckpoint) + fmt.Printf(" Created On: %s\n", checkpoint.CreatedAt) + fmt.Printf(" Step Number: %d\n", checkpoint.StepNumber) + if checkpoint.Metrics != nil { + fmt.Printf(" Full Validation Loss: %.6f\n", checkpoint.Metrics.FullValidLoss) + } + } + if checkpoints.HasMore { + fmt.Println(" ... (more checkpoints available)") + } + } + } + + fmt.Println(strings.Repeat("=", 120)) + + return nil + }, + } + cmd.Flags().StringVarP(&jobID, "job-id", "i", "", "Fine-tuning job ID") + cmd.MarkFlagRequired("job-id") + return cmd +} + +func newOperationListCommand() *cobra.Command { + var top int + var after string + cmd := &cobra.Command{ + Use: "list", + Short: "List the fine tuning jobs", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := azdext.WithAccessToken(cmd.Context()) + azdClient, err := azdext.NewAzdClient() + if err != nil { + return fmt.Errorf("failed to create azd client: %w", err) + } + defer azdClient.Close() + + // Show spinner while fetching jobs + spinner := ux.NewSpinner(&ux.SpinnerOptions{ + Text: "Fetching fine-tuning jobs...", + }) + if err := spinner.Start(ctx); err != nil { + fmt.Printf("Failed to start spinner: %v\n", err) + } + + // List fine-tuning jobs using job wrapper + jobs, err := JobWrapper.ListJobs(ctx, azdClient, top, after) + _ = spinner.Stop(ctx) + + if err != nil { + return fmt.Errorf("failed to list fine-tuning jobs: %w", err) + } + + for i, job := range jobs { + fmt.Printf("\n%d. Job ID: %s | Status: %s %s | Model: %s | Fine-tuned: %s | Created: %s", + i+1, job.Id, getStatusSymbol(job.Status), job.Status, job.Model, formatFineTunedModel(job.FineTunedModel), job.CreatedAt) + } + + fmt.Printf("\nTotal jobs: %d\n", len(jobs)) + + return nil + }, + } + cmd.Flags().IntVarP(&top, "top", "t", 50, "Number of fine-tuning jobs to list") + cmd.Flags().StringVarP(&after, "after", "a", "", "Cursor for pagination") + return cmd +} + +func newOperationActionCommand() *cobra.Command { + var jobID string + var action string + + cmd := &cobra.Command{ + Use: "action", + Short: "Perform an action on a fine-tuning job (pause, resume, cancel)", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := azdext.WithAccessToken(cmd.Context()) + azdClient, err := azdext.NewAzdClient() + if err != nil { + return fmt.Errorf("failed to create azd client: %w", err) + } + defer azdClient.Close() + + // Validate job ID is provided + if jobID == "" { + return fmt.Errorf("job-id is required") + } + + // Validate action is provided and valid + if action == "" { + return fmt.Errorf("action is required (pause, resume, or cancel)") + } + + action = strings.ToLower(action) + if action != "pause" && action != "resume" && action != "cancel" { + return fmt.Errorf("invalid action '%s'. Allowed values: pause, resume, cancel", action) + } + + var job *JobWrapper.JobContract + var err2 error + + // Execute the requested action + switch action { + case "pause": + job, err2 = JobWrapper.PauseJob(ctx, azdClient, jobID) + case "resume": + job, err2 = JobWrapper.ResumeJob(ctx, azdClient, jobID) + case "cancel": + job, err2 = JobWrapper.CancelJob(ctx, azdClient, jobID) + } + + if err2 != nil { + return err2 + } + + // Print success message + fmt.Println() + fmt.Println(strings.Repeat("=", 120)) + color.Green(fmt.Sprintf("\nSuccessfully %sd fine-tuning Job!\n", action)) + fmt.Printf("Job ID: %s\n", job.Id) + fmt.Printf("Model: %s\n", job.Model) + fmt.Printf("Status: %s %s\n", getStatusSymbol(job.Status), job.Status) + fmt.Printf("Created: %s\n", job.CreatedAt) + if job.FineTunedModel != "" { + fmt.Printf("Fine-tuned: %s\n", job.FineTunedModel) + } + fmt.Println(strings.Repeat("=", 120)) + + return nil + }, + } + + cmd.Flags().StringVarP(&jobID, "job-id", "i", "", "Fine-tuning job ID") + cmd.Flags().StringVarP(&action, "action", "a", "", "Action to perform: pause, resume, or cancel") + cmd.MarkFlagRequired("job-id") + cmd.MarkFlagRequired("action") + + return cmd +} + +func newOperationDeployModelCommand() *cobra.Command { + var jobID string + var deploymentName string + var modelFormat string + var sku string + var version string + var capacity int32 + + cmd := &cobra.Command{ + Use: "deploy", + Short: "Deploy a fine-tuned model to Azure Cognitive Services", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := azdext.WithAccessToken(cmd.Context()) + azdClient, err := azdext.NewAzdClient() + if err != nil { + return fmt.Errorf("failed to create azd client: %w", err) + } + defer azdClient.Close() + + // Validate required parameters + if jobID == "" { + return fmt.Errorf("job-id is required") + } + if deploymentName == "" { + return fmt.Errorf("deployment-name is required") + } + + // Get environment values + envValueMap := make(map[string]string) + if envResponse, err := azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}); err == nil { + env := envResponse.Environment + envValues, err := azdClient.Environment().GetValues(ctx, &azdext.GetEnvironmentRequest{ + Name: env.Name, + }) + if err != nil { + return fmt.Errorf("failed to get environment values: %w", err) + } + + for _, value := range envValues.KeyValues { + envValueMap[value.Key] = value.Value + } + } + + // Create deployment configuration + deployConfig := JobWrapper.DeploymentConfig{ + JobID: jobID, + DeploymentName: deploymentName, + ModelFormat: modelFormat, + SKU: sku, + Version: version, + Capacity: capacity, + SubscriptionID: envValueMap["AZURE_SUBSCRIPTION_ID"], + ResourceGroup: envValueMap["AZURE_RESOURCE_GROUP_NAME"], + AccountName: envValueMap["AZURE_ACCOUNT_NAME"], + TenantID: envValueMap["AZURE_TENANT_ID"], + WaitForCompletion: true, + } + + // Deploy the model using the wrapper + result, err := JobWrapper.DeployModel(ctx, azdClient, deployConfig) + if err != nil { + return err + } + + // Print success message + fmt.Println(strings.Repeat("=", 120)) + color.Green("\nSuccessfully deployed fine-tuned model!\n") + fmt.Printf("Deployment Name: %s\n", result.DeploymentName) + fmt.Printf("Status: %s\n", result.Status) + fmt.Printf("Message: %s\n", result.Message) + fmt.Println(strings.Repeat("=", 120)) + + return nil + }, + } + + cmd.Flags().StringVarP(&jobID, "job-id", "i", "", "Fine-tuning job ID") + cmd.Flags().StringVarP(&deploymentName, "deployment-name", "d", "", "Deployment name") + cmd.Flags().StringVarP(&modelFormat, "model-format", "m", "OpenAI", "Model format") + cmd.Flags().StringVarP(&sku, "sku", "s", "Standard", "SKU for deployment") + cmd.Flags().StringVarP(&version, "version", "v", "1", "Model version") + cmd.Flags().Int32VarP(&capacity, "capacity", "c", 1, "Capacity for deployment") + cmd.MarkFlagRequired("job-id") + cmd.MarkFlagRequired("deployment-name") + + return cmd +} diff --git a/cli/azd/extensions/azure.ai.finetune/internal/cmd/prompt.go b/cli/azd/extensions/azure.ai.finetune/internal/cmd/prompt.go new file mode 100644 index 00000000000..e6db7f2d06c --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/internal/cmd/prompt.go @@ -0,0 +1,276 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "fmt" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/fatih/color" + "github.com/spf13/cobra" +) + +func newPromptCommand() *cobra.Command { + return &cobra.Command{ + Use: "prompt", + Short: "Examples of prompting the user for input.", + RunE: func(cmd *cobra.Command, args []string) error { + // Create a new context that includes the AZD access token + ctx := azdext.WithAccessToken(cmd.Context()) + + // Create a new AZD client + azdClient, err := azdext.NewAzdClient() + if err != nil { + return fmt.Errorf("failed to create azd client: %w", err) + } + + defer azdClient.Close() + + _, err = azdClient.Prompt().MultiSelect(ctx, &azdext.MultiSelectRequest{ + Options: &azdext.MultiSelectOptions{ + Message: "Which Azure services do you use most with AZD?", + Choices: []*azdext.MultiSelectChoice{ + {Label: "Container Apps", Value: "container-apps"}, + {Label: "Functions", Value: "functions"}, + {Label: "Static Web Apps", Value: "static-web-apps"}, + {Label: "App Service", Value: "app-service"}, + {Label: "Cosmos DB", Value: "cosmos-db"}, + {Label: "SQL Database", Value: "sql-db"}, + {Label: "Storage", Value: "storage"}, + {Label: "Key Vault", Value: "key-vault"}, + {Label: "Kubernetes Service", Value: "kubernetes-service"}, + }, + }, + }) + if err != nil { + return nil + } + + confirmResponse, err := azdClient. + Prompt(). + Confirm(ctx, &azdext.ConfirmRequest{ + Options: &azdext.ConfirmOptions{ + Message: "Do you want to search for Azure resources?", + DefaultValue: to.Ptr(true), + }, + }) + if err != nil { + return err + } + + if !*confirmResponse.Value { + return nil + } + + azureContext := azdext.AzureContext{ + Scope: &azdext.AzureScope{}, + } + + selectedSubscription, err := azdClient. + Prompt(). + PromptSubscription(ctx, &azdext.PromptSubscriptionRequest{}) + if err != nil { + return err + } + + azureContext.Scope.SubscriptionId = selectedSubscription.Subscription.Id + azureContext.Scope.TenantId = selectedSubscription.Subscription.TenantId + + filterByResourceTypeResponse, err := azdClient. + Prompt(). + Confirm(ctx, &azdext.ConfirmRequest{ + Options: &azdext.ConfirmOptions{ + Message: "Do you want to filter by resource type?", + DefaultValue: to.Ptr(false), + }, + }) + if err != nil { + return err + } + + fullResourceType := "" + filterByResourceType := *filterByResourceTypeResponse.Value + + if filterByResourceType { + credential, err := azidentity.NewAzureDeveloperCLICredential(&azidentity.AzureDeveloperCLICredentialOptions{ + TenantID: azureContext.Scope.TenantId, + }) + if err != nil { + return err + } + + providerList := []*armresources.Provider{} + providersClient, err := armresources.NewProvidersClient(azureContext.Scope.SubscriptionId, credential, nil) + if err != nil { + return err + } + + providerListPager := providersClient.NewListPager(nil) + for providerListPager.More() { + page, err := providerListPager.NextPage(ctx) + if err != nil { + return err + } + + for _, provider := range page.ProviderListResult.Value { + if *provider.RegistrationState == "Registered" { + providerList = append(providerList, provider) + } + } + } + + providerOptions := []*azdext.SelectChoice{} + for _, provider := range providerList { + providerOptions = append(providerOptions, &azdext.SelectChoice{ + Label: *provider.Namespace, + Value: *provider.ID, + }) + } + + providerSelectResponse, err := azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: "Select a resource provider", + Choices: providerOptions, + }, + }) + if err != nil { + return err + } + + selectedProvider := providerList[*providerSelectResponse.Value] + + resourceTypesClient, err := armresources.NewProviderResourceTypesClient( + azureContext.Scope.SubscriptionId, + credential, + nil, + ) + if err != nil { + return err + } + + resourceTypesResponse, err := resourceTypesClient.List(ctx, *selectedProvider.Namespace, nil) + if err != nil { + return err + } + + resourceTypeOptions := []*azdext.SelectChoice{} + for _, resourceType := range resourceTypesResponse.Value { + resourceTypeOptions = append(resourceTypeOptions, &azdext.SelectChoice{ + Label: *resourceType.ResourceType, + Value: *resourceType.ResourceType, + }) + } + + resourceTypes := []*armresources.ProviderResourceType{} + resourceTypeSelectResponse, err := azdClient. + Prompt(). + Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: fmt.Sprintf("Select a %s resource type", *selectedProvider.Namespace), + Choices: resourceTypeOptions, + }, + }) + if err != nil { + return err + } + + resourceTypes = append(resourceTypes, resourceTypesResponse.Value...) + selectedResourceType := resourceTypes[*resourceTypeSelectResponse.Value] + fullResourceType = fmt.Sprintf("%s/%s", *selectedProvider.Namespace, *selectedResourceType.ResourceType) + } + + filterByResourceGroupResponse, err := azdClient. + Prompt(). + Confirm(ctx, &azdext.ConfirmRequest{ + Options: &azdext.ConfirmOptions{ + Message: "Do you want to filter by resource group?", + DefaultValue: to.Ptr(false), + }, + }) + if err != nil { + return err + } + + filterByResourceGroup := *filterByResourceGroupResponse.Value + var selectedResource *azdext.ResourceExtended + + if filterByResourceGroup { + selectedResourceGroup, err := azdClient. + Prompt(). + PromptResourceGroup(ctx, &azdext.PromptResourceGroupRequest{ + AzureContext: &azureContext, + }) + if err != nil { + return err + } + + azureContext.Scope.ResourceGroup = selectedResourceGroup.ResourceGroup.Name + + selectedResourceResponse, err := azdClient. + Prompt(). + PromptResourceGroupResource(ctx, &azdext.PromptResourceGroupResourceRequest{ + AzureContext: &azureContext, + Options: &azdext.PromptResourceOptions{ + ResourceType: fullResourceType, + SelectOptions: &azdext.PromptResourceSelectOptions{ + AllowNewResource: to.Ptr(false), + }, + }, + }) + if err != nil { + return err + } + + selectedResource = selectedResourceResponse.Resource + } else { + selectedResourceResponse, err := azdClient. + Prompt(). + PromptSubscriptionResource(ctx, &azdext.PromptSubscriptionResourceRequest{ + AzureContext: &azureContext, + Options: &azdext.PromptResourceOptions{ + ResourceType: fullResourceType, + SelectOptions: &azdext.PromptResourceSelectOptions{ + AllowNewResource: to.Ptr(false), + }, + }, + }) + if err != nil { + return err + } + + selectedResource = selectedResourceResponse.Resource + } + + parsedResource, err := arm.ParseResourceID(selectedResource.Id) + if err != nil { + return err + } + + fmt.Println() + color.Cyan("Selected resource:") + values := map[string]string{ + "Subscription ID": parsedResource.SubscriptionID, + "Resource Group": parsedResource.ResourceGroupName, + "Name": parsedResource.Name, + "Type": selectedResource.Type, + "Location": parsedResource.Location, + "Kind": selectedResource.Kind, + } + + for key, value := range values { + if value == "" { + value = "N/A" + } + + fmt.Printf("%s: %s\n", color.HiWhiteString(key), color.HiBlackString(value)) + } + + return nil + }, + } +} diff --git a/cli/azd/extensions/azure.ai.finetune/internal/cmd/root.go b/cli/azd/extensions/azure.ai.finetune/internal/cmd/root.go new file mode 100644 index 00000000000..bc424cfd67b --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/internal/cmd/root.go @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "github.com/spf13/cobra" +) + +type rootFlagsDefinition struct { + Debug bool + NoPrompt bool +} + +// Enable access to the global command flags +var rootFlags rootFlagsDefinition + +func NewRootCommand() *cobra.Command { + rootCmd := &cobra.Command{ + Use: "finetuning [options]", + Short: "Extension for Foundry Fine Tuning. (Preview)", + SilenceUsage: true, + SilenceErrors: true, + CompletionOptions: cobra.CompletionOptions{ + DisableDefaultCmd: true, + }, + } + + rootCmd.SetHelpCommand(&cobra.Command{Hidden: true}) + rootCmd.PersistentFlags().BoolVar( + &rootFlags.Debug, + "debug", + false, + "Enable debug mode", + ) + + // Adds support for `--no-prompt` global flag in azd + // Without this the extension command will error when the flag is provided + rootCmd.PersistentFlags().BoolVar( + &rootFlags.NoPrompt, + "no-prompt", + false, + "Accepts the default value instead of prompting, or it fails if there is no default.", + ) + + rootCmd.AddCommand(newListenCommand()) + rootCmd.AddCommand(newVersionCommand()) + rootCmd.AddCommand(newInitCommand(rootFlags)) + rootCmd.AddCommand(newOperationCommand()) + // rootCmd.AddCommand(newOperationListCommand()) + //rootCmd.AddCommand(newOperationCheckpointsCommand()) + + return rootCmd +} diff --git a/cli/azd/extensions/azure.ai.finetune/internal/cmd/version.go b/cli/azd/extensions/azure.ai.finetune/internal/cmd/version.go new file mode 100644 index 00000000000..715323a6c5c --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/internal/cmd/version.go @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var ( + // Populated at build time + Version = "dev" // Default value for development builds + Commit = "none" + BuildDate = "unknown" +) + +func newVersionCommand() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Prints the version of the application", + Run: func(cmd *cobra.Command, args []string) { + fmt.Printf("Version: %s\nCommit: %s\nBuild Date: %s\n", Version, Commit, BuildDate) + }, + } +} diff --git a/cli/azd/extensions/azure.ai.finetune/internal/fine_tuning_yaml/parser.go b/cli/azd/extensions/azure.ai.finetune/internal/fine_tuning_yaml/parser.go new file mode 100644 index 00000000000..7f9f6dc7275 --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/internal/fine_tuning_yaml/parser.go @@ -0,0 +1,570 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package fine_tuning_yaml + +import ( + "fmt" + "os" + + "github.com/braydonk/yaml" +) + +// ParseFineTuningConfig reads and parses a YAML fine-tuning configuration file +func ParseFineTuningConfig(filePath string) (*FineTuningConfig, error) { + // Read the YAML file + yamlFile, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("failed to read config file %s: %w", filePath, err) + } + + // Parse YAML into config struct + var config FineTuningConfig + if err := yaml.Unmarshal(yamlFile, &config); err != nil { + return nil, fmt.Errorf("failed to parse YAML config: %w", err) + } + + // Validate the configuration + if err := config.Validate(); err != nil { + return nil, fmt.Errorf("invalid configuration: %w", err) + } + + return &config, nil +} + +// Validate checks if the configuration is valid +func (c *FineTuningConfig) Validate() error { + // Validate required fields + if c.Model == "" { + return fmt.Errorf("model is required") + } + + if c.TrainingFile == "" { + return fmt.Errorf("training_file is required") + } + + // Validate method if provided + if c.Method.Type != "" { + if c.Method.Type != string(Supervised) && c.Method.Type != string(DPO) && c.Method.Type != string(Reinforcement) { + return fmt.Errorf("invalid method type: %s (must be 'supervised', 'dpo', or 'reinforcement')", c.Method.Type) + } + + // Validate method-specific configuration + switch c.Method.Type { + case string(Supervised): + if c.Method.Supervised == nil { + return fmt.Errorf("supervised method requires 'supervised' configuration block") + } + case string(DPO): + if c.Method.DPO == nil { + return fmt.Errorf("dpo method requires 'dpo' configuration block") + } + case string(Reinforcement): + if c.Method.Reinforcement == nil { + return fmt.Errorf("reinforcement method requires 'reinforcement' configuration block") + } + // Validate reinforcement-specific configuration + if err := c.Method.Reinforcement.Validate(); err != nil { + return err + } + } + } + + // Validate suffix length if provided + if c.Suffix != nil && len(*c.Suffix) > 64 { + return fmt.Errorf("suffix exceeds maximum length of 64 characters: %d", len(*c.Suffix)) + } + + // Validate metadata constraints + if c.Metadata != nil { + if len(c.Metadata) > 16 { + return fmt.Errorf("metadata exceeds maximum of 16 key-value pairs: %d", len(c.Metadata)) + } + for k, v := range c.Metadata { + if len(k) > 64 { + return fmt.Errorf("metadata key exceeds maximum length of 64 characters: %s", k) + } + if len(v) > 512 { + return fmt.Errorf("metadata value exceeds maximum length of 512 characters for key: %s", k) + } + } + } + + return nil +} + +// Validate checks if reinforcement configuration is valid +func (r *ReinforcementConfig) Validate() error { + if r == nil { + return nil + } + + // Validate grader configuration + if r.Grader.Type != "" { + if err := r.Grader.Validate(); err != nil { + return err + } + } + + return nil +} + +// Validate checks if grader configuration is valid +func (g *GraderConfig) Validate() error { + if g.Type == "" { + return nil // grader is optional + } + + validGraderTypes := map[string]bool{ + "string_check": true, + "text_similarity": true, + "python": true, + "score_model": true, + "multi": true, + } + + if !validGraderTypes[g.Type] { + return fmt.Errorf("invalid grader type: %s (must be 'string_check', 'text_similarity', 'python', 'score_model', or 'multi')", g.Type) + } + + switch g.Type { + case "string_check": + if g.StringCheck == nil { + return fmt.Errorf("string_check grader type requires 'string_check' configuration block") + } + if err := g.StringCheck.Validate(); err != nil { + return err + } + + case "text_similarity": + if g.TextSimilarity == nil { + return fmt.Errorf("text_similarity grader type requires 'text_similarity' configuration block") + } + if err := g.TextSimilarity.Validate(); err != nil { + return err + } + + case "python": + if g.Python == nil { + return fmt.Errorf("python grader type requires 'python' configuration block") + } + if err := g.Python.Validate(); err != nil { + return err + } + + case "score_model": + if g.ScoreModel == nil { + return fmt.Errorf("score_model grader type requires 'score_model' configuration block") + } + if err := g.ScoreModel.Validate(); err != nil { + return err + } + + case "multi": + if g.Multi == nil { + return fmt.Errorf("multi grader type requires 'multi' configuration block") + } + if err := g.Multi.Validate(); err != nil { + return err + } + } + + return nil +} + +// Validate checks if string check grader configuration is valid +func (s *StringCheckGraderConfig) Validate() error { + if s.Type == "" { + s.Type = "string_check" // set default + } + + if s.Type != "string_check" { + return fmt.Errorf("string_check grader type must be 'string_check', got: %s", s.Type) + } + + if s.Input == "" { + return fmt.Errorf("string_check grader requires 'input' field") + } + + if s.Name == "" { + return fmt.Errorf("string_check grader requires 'name' field") + } + + if s.Operation == "" { + return fmt.Errorf("string_check grader requires 'operation' field") + } + + validOperations := map[string]bool{"eq": true, "contains": true, "regex": true} + if !validOperations[s.Operation] { + return fmt.Errorf("invalid string_check operation: %s (must be 'eq', 'contains', or 'regex')", s.Operation) + } + + if s.Reference == "" { + return fmt.Errorf("string_check grader requires 'reference' field") + } + + return nil +} + +// Validate checks if text similarity grader configuration is valid +func (t *TextSimilarityGraderConfig) Validate() error { + if t.Type == "" { + t.Type = "text_similarity" // set default + } + + if t.Type != "text_similarity" { + return fmt.Errorf("text_similarity grader type must be 'text_similarity', got: %s", t.Type) + } + + if t.Name == "" { + return fmt.Errorf("text_similarity grader requires 'name' field") + } + + if t.Input == "" { + return fmt.Errorf("text_similarity grader requires 'input' field") + } + + if t.Reference == "" { + return fmt.Errorf("text_similarity grader requires 'reference' field") + } + + if t.EvaluationMetric == "" { + return fmt.Errorf("text_similarity grader requires 'evaluation_metric' field") + } + + validMetrics := map[string]bool{ + "cosine": true, + "fuzzy_match": true, + "bleu": true, + "gleu": true, + "meteor": true, + "rouge_1": true, + "rouge_2": true, + "rouge_3": true, + "rouge_4": true, + "rouge_5": true, + "rouge_l": true, + } + if !validMetrics[t.EvaluationMetric] { + return fmt.Errorf("invalid evaluation_metric: %s", t.EvaluationMetric) + } + + return nil +} + +// Validate checks if python grader configuration is valid +func (p *PythonGraderConfig) Validate() error { + if p.Type == "" { + p.Type = "python" // set default + } + + if p.Type != "python" { + return fmt.Errorf("python grader type must be 'python', got: %s", p.Type) + } + + if p.Name == "" { + return fmt.Errorf("python grader requires 'name' field") + } + + if p.Source == "" { + return fmt.Errorf("python grader requires 'source' field") + } + + return nil +} + +// Validate checks if score model grader configuration is valid +func (s *ScoreModelGraderConfig) Validate() error { + if s.Type == "" { + s.Type = "score_model" // set default + } + + if s.Type != "score_model" { + return fmt.Errorf("score_model grader type must be 'score_model', got: %s", s.Type) + } + + if s.Name == "" { + return fmt.Errorf("score_model grader requires 'name' field") + } + + if s.Model == "" { + return fmt.Errorf("score_model grader requires 'model' field") + } + + if len(s.Input) == 0 { + return fmt.Errorf("score_model grader requires 'input' field with at least one message") + } + + // Validate each message input + for i, msgInput := range s.Input { + if msgInput.Role == "" { + return fmt.Errorf("score_model grader input[%d] requires 'role' field", i) + } + + validRoles := map[string]bool{"user": true, "assistant": true, "system": true, "developer": true} + if !validRoles[msgInput.Role] { + return fmt.Errorf("score_model grader input[%d] has invalid role: %s (must be 'user', 'assistant', 'system', or 'developer')", i, msgInput.Role) + } + + if len(msgInput.Content) == 0 { + return fmt.Errorf("score_model grader input[%d] requires at least one content item", i) + } + + // Validate each content item + for j, content := range msgInput.Content { + if content.Type == "" { + return fmt.Errorf("score_model grader input[%d].content[%d] requires 'type' field", i, j) + } + + validContentTypes := map[string]bool{"text": true, "image": true, "audio": true} + if !validContentTypes[content.Type] { + return fmt.Errorf("score_model grader input[%d].content[%d] has invalid type: %s (must be 'text', 'image', or 'audio')", i, j, content.Type) + } + } + } + + // Validate sampling parameters if provided + if s.SamplingParams != nil { + if s.SamplingParams.ReasoningEffort != "" { + validEfforts := map[string]bool{ + "none": true, + "minimal": true, + "low": true, + "medium": true, + "high": true, + "xhigh": true, + } + if !validEfforts[s.SamplingParams.ReasoningEffort] { + return fmt.Errorf("invalid reasoning_effort: %s", s.SamplingParams.ReasoningEffort) + } + } + } + + return nil +} + +// Validate checks if multi grader configuration is valid +func (m *MultiGraderConfig) Validate() error { + if m.Type == "" { + m.Type = "multi" // set default + } + + if m.Type != "multi" { + return fmt.Errorf("multi grader type must be 'multi', got: %s", m.Type) + } + + if len(m.Graders) == 0 { + return fmt.Errorf("multi grader requires at least one grader in 'graders' field") + } + + if m.Aggregation == "" { + return fmt.Errorf("multi grader requires 'aggregation' field") + } + + validAggregations := map[string]bool{"average": true, "weighted": true, "min": true, "max": true} + if !validAggregations[m.Aggregation] { + return fmt.Errorf("invalid aggregation method: %s (must be 'average', 'weighted', 'min', or 'max')", m.Aggregation) + } + + // Validate weights if weighted aggregation + if m.Aggregation == "weighted" { + if len(m.Weights) == 0 { + return fmt.Errorf("weighted aggregation requires 'weights' field") + } + if len(m.Weights) != len(m.Graders) { + return fmt.Errorf("number of weights (%d) must match number of graders (%d)", len(m.Weights), len(m.Graders)) + } + } + + return nil +} + +// GetMethodType returns the method type as MethodType constant +func (c *FineTuningConfig) GetMethodType() MethodType { + switch c.Method.Type { + case string(Supervised): + return Supervised + case string(DPO): + return DPO + case string(Reinforcement): + return Reinforcement + default: + return Supervised // default to supervised + } +} + +// Example YAML structure: +/* +# Minimal configuration +model: gpt-4o-mini +training_file: "local:/path/to/training.jsonl" + +--- + +# Supervised fine-tuning +model: gpt-4o-mini +training_file: "local:/path/to/training.jsonl" +validation_file: "local:/path/to/validation.jsonl" + +suffix: "supervised-model" +seed: 42 + +method: + type: supervised + supervised: + hyperparameters: + epochs: 3 + batch_size: 8 + learning_rate_multiplier: 1.0 + +metadata: + project: "my-project" + team: "data-science" + +--- + +# DPO (Direct Preference Optimization) +model: gpt-4o-mini +training_file: "local:/path/to/training.jsonl" + +method: + type: dpo + dpo: + hyperparameters: + epochs: 2 + batch_size: 16 + learning_rate_multiplier: 0.5 + beta: 0.1 + +--- + +# Reinforcement learning with string check grader +model: gpt-4o-mini +training_file: "local:/path/to/training.jsonl" + +method: + type: reinforcement + reinforcement: + grader: + type: string_check + string_check: + type: string_check + input: "{{ item.output }}" + name: "exact_match_grader" + operation: "eq" + reference: "{{ item.expected }}" + hyperparameters: + epochs: 3 + batch_size: 8 + eval_interval: 10 + eval_samples: 5 + +--- + +# Reinforcement learning with text similarity grader +model: gpt-4o-mini +training_file: "local:/path/to/training.jsonl" + +method: + type: reinforcement + reinforcement: + grader: + type: text_similarity + text_similarity: + type: text_similarity + name: "similarity_grader" + input: "{{ item.output }}" + reference: "{{ item.reference }}" + evaluation_metric: "rouge_l" + hyperparameters: + epochs: 2 + compute_multiplier: auto + reasoning_effort: "medium" + +--- + +# Reinforcement learning with python grader +model: gpt-4o-mini +training_file: "local:/path/to/training.jsonl" + +method: + type: reinforcement + reinforcement: + grader: + type: python + python: + type: python + name: "custom_evaluator" + source: | + def evaluate(output, expected): + return 1.0 if output == expected else 0.0 + image_tag: "python:3.11" + hyperparameters: + epochs: 3 + batch_size: 8 + +--- + +# Reinforcement learning with score model grader +model: gpt-4o-mini +training_file: "local:/path/to/training.jsonl" + +method: + type: reinforcement + reinforcement: + grader: + type: score_model + score_model: + type: score_model + name: "gpt_evaluator" + model: "gpt-4o" + input: + - role: "user" + type: "message" + content: + - type: "text" + text: "Rate this response: {{ item.output }}" + - role: "assistant" + type: "message" + content: + - type: "text" + text: "Expected: {{ item.expected }}" + range: [0, 10] + sampling_params: + max_completions_tokens: 50 + reasoning_effort: "medium" + hyperparameters: + epochs: 2 + eval_interval: 5 + +--- + +# Reinforcement learning with multi grader (combining multiple evaluators) +model: gpt-4o-mini +training_file: "local:/path/to/training.jsonl" + +method: + type: reinforcement + reinforcement: + grader: + type: multi + multi: + type: multi + graders: + - type: string_check + input: "{{ item.output }}" + name: "exact_match" + operation: "eq" + reference: "{{ item.expected }}" + - type: text_similarity + name: "semantic_similarity" + input: "{{ item.output }}" + reference: "{{ item.expected }}" + evaluation_metric: "rouge_l" + aggregation: "weighted" + weights: [0.4, 0.6] + hyperparameters: + epochs: 3 + batch_size: 8 + compute_multiplier: auto +*/ diff --git a/cli/azd/extensions/azure.ai.finetune/internal/fine_tuning_yaml/yaml.go b/cli/azd/extensions/azure.ai.finetune/internal/fine_tuning_yaml/yaml.go new file mode 100644 index 00000000000..3f4099273b4 --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/internal/fine_tuning_yaml/yaml.go @@ -0,0 +1,323 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package fine_tuning_yaml + +// MethodType represents the type of method used for fine-tuning +type MethodType string + +const ( + Supervised MethodType = "supervised" + DPO MethodType = "dpo" + Reinforcement MethodType = "reinforcement" +) + +// FineTuningConfig represents the YAML configuration structure for fine-tuning jobs +// This schema aligns with OpenAI Fine-Tuning API requirements +type FineTuningConfig struct { + // Required: The name of the model to fine-tune + // Supported models: gpt-4o-mini, gpt-4o, gpt-4-turbo, etc. + Model string `yaml:"model"` + + // Required: Path to training file + // Format: "file-id" or "local:/path/to/file.jsonl" + TrainingFile string `yaml:"training_file"` + + // Optional: Path to validation file + ValidationFile string `yaml:"validation_file,omitempty"` + + // Optional: Fine-tuning method configuration (supervised, dpo, or reinforcement) + Method MethodConfig `yaml:"method,omitempty"` + + // Optional: Suffix for the fine-tuned model name (up to 64 characters) + // Example: "custom-model-name" produces "ft:gpt-4o-mini:openai:custom-model-name:7p4lURel" + Suffix *string `yaml:"suffix,omitempty"` + + // Optional: Random seed for reproducibility + Seed *int64 `yaml:"seed,omitempty"` + + // Optional: Custom metadata for the fine-tuning job + // Max 16 key-value pairs, keys max 64 chars, values max 512 chars + Metadata map[string]string `yaml:"metadata,omitempty"` + + // Optional: Integrations to enable (e.g., wandb for Weights & Biases) + Integrations []IntegrationConfig `yaml:"integrations,omitempty"` + + // Optional: Additional request body fields not covered by standard config + ExtraBody map[string]interface{} `yaml:"extra_body,omitempty"` +} + +// MethodConfig represents fine-tuning method configuration +type MethodConfig struct { + // Type of fine-tuning method: "supervised", "dpo", or "reinforcement" + Type string `yaml:"type"` + + // Supervised fine-tuning configuration + Supervised *SupervisedConfig `yaml:"supervised,omitempty"` + + // Direct Preference Optimization (DPO) configuration + DPO *DPOConfig `yaml:"dpo,omitempty"` + + // Reinforcement learning fine-tuning configuration + Reinforcement *ReinforcementConfig `yaml:"reinforcement,omitempty"` +} + +// SupervisedConfig represents supervised fine-tuning method configuration +// Suitable for standard supervised learning tasks +type SupervisedConfig struct { + Hyperparameters HyperparametersConfig `yaml:"hyperparameters,omitempty"` +} + +// DPOConfig represents Direct Preference Optimization (DPO) configuration +// DPO is used for preference-based fine-tuning +type DPOConfig struct { + Hyperparameters HyperparametersConfig `yaml:"hyperparameters,omitempty"` +} + +// ReinforcementConfig represents reinforcement learning fine-tuning configuration +// Suitable for reasoning models that benefit from reinforcement learning +type ReinforcementConfig struct { + // Grader configuration for reinforcement learning (evaluates model outputs) + Grader GraderConfig `yaml:"grader,omitempty"` + + // Hyperparameters specific to reinforcement learning + Hyperparameters HyperparametersConfig `yaml:"hyperparameters,omitempty"` +} + +// GraderConfig represents grader configuration for reinforcement learning +// The grader evaluates and scores fine-tuning outputs +// Supports one of: StringCheckGrader, TextSimilarityGrader, PythonGrader, ScoreModelGrader, or MultiGrader +type GraderConfig struct { + // Type of grader: "string_check", "text_similarity", "python", "score_model", or "multi" + Type string `yaml:"type,omitempty"` + + // StringCheckGrader: Performs string comparison between input and reference + StringCheck *StringCheckGraderConfig `yaml:"string_check,omitempty"` + + // TextSimilarityGrader: Grades based on text similarity metrics + TextSimilarity *TextSimilarityGraderConfig `yaml:"text_similarity,omitempty"` + + // PythonGrader: Runs a Python script for evaluation + Python *PythonGraderConfig `yaml:"python,omitempty"` + + // ScoreModelGrader: Uses a model to assign scores + ScoreModel *ScoreModelGraderConfig `yaml:"score_model,omitempty"` + + // MultiGrader: Combines multiple graders for composite scoring + Multi *MultiGraderConfig `yaml:"multi,omitempty"` +} + +// StringCheckGraderConfig performs string comparison evaluation +type StringCheckGraderConfig struct { + // Type: always "string_check" + Type string `yaml:"type"` + + // The input field to check (reference to {{ item.XXX }} in training data) + Input string `yaml:"input"` + + // Name of the grader + Name string `yaml:"name"` + + // Operation to perform: "eq" (equals), "contains", "regex" + Operation string `yaml:"operation"` + + // Reference value to compare against (can use {{ item.XXX }} template) + Reference string `yaml:"reference"` +} + +// TextSimilarityGraderConfig grades based on text similarity +type TextSimilarityGraderConfig struct { + // Type: always "text_similarity" + Type string `yaml:"type"` + + // Name of the grader + Name string `yaml:"name"` + + // The text being graded (input field to evaluate) + Input string `yaml:"input"` + + // Reference text to compare similarity against + Reference string `yaml:"reference"` + + // Evaluation metric to use + // Options: "cosine", "fuzzy_match", "bleu", "gleu", "meteor", + // "rouge_1", "rouge_2", "rouge_3", "rouge_4", "rouge_5", "rouge_l" + EvaluationMetric string `yaml:"evaluation_metric"` +} + +// PythonGraderConfig runs Python code for evaluation +type PythonGraderConfig struct { + // Type: always "python" + Type string `yaml:"type"` + + // Name of the grader + Name string `yaml:"name"` + + // Source code of the Python script + // Must define a function that evaluates and returns a score + Source string `yaml:"source"` + + // Optional: Docker image tag to use for the Python script execution + ImageTag string `yaml:"image_tag,omitempty"` +} + +// ScoreModelGraderConfig uses a model for scoring +type ScoreModelGraderConfig struct { + // Type: always "score_model" + Type string `yaml:"type"` + + // Name of the grader + Name string `yaml:"name"` + + // The input messages evaluated by the grader + // Supports text, output text, input image, and input audio content blocks + // May include template strings (e.g., {{ item.output }}) + Input []MessageInputConfig `yaml:"input"` + + // Model to use for scoring (e.g., "gpt-4", "gpt-4o") + Model string `yaml:"model"` + + // Optional: The range of the score (e.g., [0, 1]) + // Defaults to [0, 1] + Range []float64 `yaml:"range,omitempty"` + + // Optional: Sampling parameters for the model + SamplingParams *SamplingParamsConfig `yaml:"sampling_params,omitempty"` +} + +// MessageInputConfig represents a message input for score model grader +type MessageInputConfig struct { + // Role of the message: "user", "assistant", "system", or "developer" + Role string `yaml:"role"` + + // Optional: Type of the message input. Always "message" + Type string `yaml:"type,omitempty"` + + // Content blocks in the message + // Can contain one or more content items: input text, output text, input image, or input audio + // Can include template strings (e.g., {{ item.output }}) + Content []ContentItem `yaml:"content"` +} + +// ContentItem represents a single content item in a message +// Can be one of: InputTextContent, OutputTextContent, InputImageContent, or InputAudioContent +type ContentItem struct { + // Type of content: "text" or "image" or "audio" + Type string `yaml:"type,omitempty"` + + // For text content (input or output): the text content + // Can include template strings + Text string `yaml:"text,omitempty"` + + // For image content: URL or base64-encoded image data + Image string `yaml:"image,omitempty"` + + // For audio content: URL or base64-encoded audio data + AudioURL string `yaml:"audio_url,omitempty"` + + // For audio content (optional): audio format/codec + Format string `yaml:"format,omitempty"` +} + +// InputTextContent represents input text content +type InputTextContent struct { + Type string `yaml:"type"` // "text" + Text string `yaml:"text"` // Can include template strings like {{ item.input }} +} + +// OutputTextContent represents output text content +type OutputTextContent struct { + Type string `yaml:"type"` // "text" + Text string `yaml:"text"` // Can include template strings like {{ item.output }} +} + +// InputImageContent represents input image content +type InputImageContent struct { + Type string `yaml:"type"` // "image" + Image string `yaml:"image"` // URL or base64-encoded image data +} + +// InputAudioContent represents input audio content +type InputAudioContent struct { + Type string `yaml:"type"` // "audio" + AudioURL string `yaml:"audio_url"` // URL or base64-encoded audio data + Format string `yaml:"format,omitempty"` // Optional: audio format/codec +} + +// SamplingParamsConfig represents sampling parameters for score model grader +type SamplingParamsConfig struct { + // Optional: Maximum number of tokens the grader model may generate + MaxCompletionsTokens *int64 `yaml:"max_completions_tokens,omitempty"` + + // Optional: Reasoning effort level ("none", "minimal", "low", "medium", "high", "xhigh") + // Defaults to "medium" + // Note: gpt-5.1 defaults to "none" and only supports "none", "low", "medium", "high" + // gpt-5-pro defaults to and only supports "high" + ReasoningEffort string `yaml:"reasoning_effort,omitempty"` +} + +// MultiGraderConfig combines multiple graders +type MultiGraderConfig struct { + // Type: always "multi" + Type string `yaml:"type"` + + // List of graders to combine + Graders []map[string]interface{} `yaml:"graders"` + + // How to combine scores: "average", "weighted", "min", "max" + Aggregation string `yaml:"aggregation,omitempty"` + + // Weights for each grader (for weighted aggregation) + Weights []float64 `yaml:"weights,omitempty"` +} + +// HyperparametersConfig represents hyperparameter configuration +// Values can be integers, floats, or "auto" for automatic configuration +type HyperparametersConfig struct { + // Number of training epochs + // Can be: integer (1-10), "auto" (OpenAI determines optimal value) + Epochs interface{} `yaml:"epochs,omitempty"` + + // Batch size for training + // Can be: integer (1, 8, 16, 32, 64, 128), "auto" (OpenAI determines optimal value) + BatchSize interface{} `yaml:"batch_size,omitempty"` + + // Learning rate multiplier + // Can be: float (0.1-2.0), "auto" (OpenAI determines optimal value) + LearningRateMultiplier interface{} `yaml:"learning_rate_multiplier,omitempty"` + + // Weight for prompt loss in supervised learning (0.0-1.0) + PromptLossWeight *float64 `yaml:"prompt_loss_weight,omitempty"` + + // Beta parameter for DPO (temperature-like parameter) + // Can be: float, "auto" + Beta interface{} `yaml:"beta,omitempty"` + + // Compute multiplier for reinforcement learning + // Multiplier on amount of compute used for exploring search space during training + // Can be: float, "auto" + ComputeMultiplier interface{} `yaml:"compute_multiplier,omitempty"` + + // Reasoning effort level for reinforcement learning with reasoning models + // Options: "low", "medium", "high" + ReasoningEffort string `yaml:"reasoning_effort,omitempty"` + + // Evaluation interval for reinforcement learning + // Number of training steps between evaluation runs + // Can be: integer, "auto" + EvalInterval interface{} `yaml:"eval_interval,omitempty"` + + // Evaluation samples for reinforcement learning + // Number of evaluation samples to generate per training step + // Can be: integer, "auto" + EvalSamples interface{} `yaml:"eval_samples,omitempty"` +} + +// IntegrationConfig represents integration configuration (e.g., Weights & Biases) +type IntegrationConfig struct { + // Type of integration: "wandb" (Weights & Biases), etc. + Type string `yaml:"type"` + + // Integration-specific configuration (API keys, project names, etc.) + Config map[string]interface{} `yaml:"config,omitempty"` +} diff --git a/cli/azd/extensions/azure.ai.finetune/internal/project/service_target_finetune.go b/cli/azd/extensions/azure.ai.finetune/internal/project/service_target_finetune.go new file mode 100644 index 00000000000..b57f2d75c95 --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/internal/project/service_target_finetune.go @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "context" + "fmt" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/fatih/color" +) + +// Reference implementation + +// Ensure FineTuneServiceTargetProvider implements ServiceTargetProvider interface +var _ azdext.ServiceTargetProvider = &FineTuneServiceTargetProvider{} + +// AgentServiceTargetProvider is a minimal implementation of ServiceTargetProvider for demonstration +type FineTuneServiceTargetProvider struct { + azdClient *azdext.AzdClient + serviceConfig *azdext.ServiceConfig + agentDefinitionPath string + credential *azidentity.AzureDeveloperCLICredential + tenantId string + env *azdext.Environment + foundryProject *arm.ResourceID +} + +// NewFineTuneServiceTargetProvider creates a new FineTuneServiceTargetProvider instance +func NewFineTuneServiceTargetProvider(azdClient *azdext.AzdClient) azdext.ServiceTargetProvider { + return &FineTuneServiceTargetProvider{ + azdClient: azdClient, + } +} + +// Initialize initializes the service target by looking for the agent definition file +func (p *FineTuneServiceTargetProvider) Initialize(ctx context.Context, serviceConfig *azdext.ServiceConfig) error { + fmt.Println("Initializing the deployment") + return nil +} + +// Endpoints returns endpoints exposed by the agent service +func (p *FineTuneServiceTargetProvider) Endpoints( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, + targetResource *azdext.TargetResource, +) ([]string, error) { + endpoint := "https://foundrysdk-eastus2-foundry-resou.services.ai.azure.com/api/projects/foundrysdk-eastus2-project" + return []string{endpoint}, nil + +} + +func (p *FineTuneServiceTargetProvider) GetTargetResource( + ctx context.Context, + subscriptionId string, + serviceConfig *azdext.ServiceConfig, + defaultResolver func() (*azdext.TargetResource, error), +) (*azdext.TargetResource, error) { + targetResource := &azdext.TargetResource{ + SubscriptionId: p.foundryProject.SubscriptionID, + ResourceGroupName: p.foundryProject.ResourceGroupName, + ResourceName: "projectName", + ResourceType: "Microsoft.CognitiveServices/accounts/projects", + Metadata: map[string]string{ + "accountName": "accountName", + "projectName": "projectName", + }, + } + + return targetResource, nil +} + +// Package performs packaging for the agent service +func (p *FineTuneServiceTargetProvider) Package( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, + serviceContext *azdext.ServiceContext, + progress azdext.ProgressReporter, +) (*azdext.ServicePackageResult, error) { + return nil, fmt.Errorf("failed building container:") + +} + +// Publish performs the publish operation for the agent service +func (p *FineTuneServiceTargetProvider) Publish( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, + serviceContext *azdext.ServiceContext, + targetResource *azdext.TargetResource, + publishOptions *azdext.PublishOptions, + progress azdext.ProgressReporter, +) (*azdext.ServicePublishResult, error) { + + progress("Publishing container") + publishResponse, err := p.azdClient. + Container(). + Publish(ctx, &azdext.ContainerPublishRequest{ + ServiceName: serviceConfig.Name, + ServiceContext: serviceContext, + }) + + if err != nil { + return nil, fmt.Errorf("failed publishing container: %w", err) + } + + return &azdext.ServicePublishResult{ + Artifacts: publishResponse.Result.Artifacts, + }, nil +} + +// Deploy performs the deployment operation for the agent service +func (p *FineTuneServiceTargetProvider) Deploy( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, + serviceContext *azdext.ServiceContext, + targetResource *azdext.TargetResource, + progress azdext.ProgressReporter, +) (*azdext.ServiceDeployResult, error) { + color.Green("Deploying the AI Project...") + time.Sleep(1 * time.Second) + color.Green("Deployed the AI Project successfully. Project URI : https://foundrysdk-eastus2-foundry-resou.services.ai.azure.com/api/projects/foundrysdk-eastus2-project") + color.Green("Deploying validation file...") + time.Sleep(1 * time.Second) + color.Green("Deployed validation file successfully. File ID: file-7219fd8e93954c039203203f953bab3b.jsonl") + + color.Green("Deploying Training file...") + time.Sleep(1 * time.Second) + color.Green("Deployed training file successfully. File ID: file-7219fd8e93954c039203203f953bab4b.jsonl") + + color.Green("Starting Fine-tuning...") + time.Sleep(2 * time.Second) + color.Green("Fine-tuning started successfully. Fine-tune ID: ftjob-4485dc4da8694d3b8c13c516baa18bc0") + + return nil, nil + +} diff --git a/cli/azd/extensions/azure.ai.finetune/internal/providers/azure/provider.go b/cli/azd/extensions/azure.ai.finetune/internal/providers/azure/provider.go new file mode 100644 index 00000000000..c61a74bf6ea --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/internal/providers/azure/provider.go @@ -0,0 +1,133 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azure + +import ( + "context" + + "azure.ai.finetune/internal/providers" + "azure.ai.finetune/pkg/models" +) + +// Ensure AzureProvider implements FineTuningProvider and ModelDeploymentProvider interfaces +var ( + _ providers.FineTuningProvider = (*AzureProvider)(nil) + _ providers.ModelDeploymentProvider = (*AzureProvider)(nil) +) + +// AzureProvider implements the provider interface for Azure APIs +// This includes both Azure OpenAI and Azure Cognitive Services APIs +type AzureProvider struct { + // TODO: Add Azure SDK clients + // cognitiveServicesClient *armcognitiveservices.Client + endpoint string + apiKey string +} + +// NewAzureProvider creates a new Azure provider instance +func NewAzureProvider(endpoint, apiKey string) *AzureProvider { + return &AzureProvider{ + endpoint: endpoint, + apiKey: apiKey, + } +} + +// CreateFineTuningJob creates a new fine-tuning job via Azure OpenAI API +func (p *AzureProvider) CreateFineTuningJob(ctx context.Context, req *models.CreateFineTuningRequest) (*models.FineTuningJob, error) { + // TODO: Implement + // 1. Convert domain model to Azure SDK format + // 2. Call Azure SDK CreateFineTuningJob + // 3. Convert Azure response to domain model + return nil, nil +} + +// GetFineTuningStatus retrieves the status of a fine-tuning job +func (p *AzureProvider) GetFineTuningStatus(ctx context.Context, jobID string) (*models.FineTuningJob, error) { + // TODO: Implement + return nil, nil +} + +// ListFineTuningJobs lists all fine-tuning jobs +func (p *AzureProvider) ListFineTuningJobs(ctx context.Context, limit int, after string) ([]*models.FineTuningJob, error) { + // TODO: Implement + return nil, nil +} + +// GetFineTuningJobDetails retrieves detailed information about a job +func (p *AzureProvider) GetFineTuningJobDetails(ctx context.Context, jobID string) (*models.FineTuningJobDetail, error) { + // TODO: Implement + return nil, nil +} + +// GetJobEvents retrieves events for a fine-tuning job +func (p *AzureProvider) GetJobEvents(ctx context.Context, jobID string, limit int, after string) ([]*models.JobEvent, error) { + // TODO: Implement + return nil, nil +} + +// GetJobCheckpoints retrieves checkpoints for a fine-tuning job +func (p *AzureProvider) GetJobCheckpoints(ctx context.Context, jobID string, limit int, after string) ([]*models.JobCheckpoint, error) { + // TODO: Implement + return nil, nil +} + +// PauseJob pauses a fine-tuning job +func (p *AzureProvider) PauseJob(ctx context.Context, jobID string) (*models.FineTuningJob, error) { + // TODO: Implement + return nil, nil +} + +// ResumeJob resumes a paused fine-tuning job +func (p *AzureProvider) ResumeJob(ctx context.Context, jobID string) (*models.FineTuningJob, error) { + // TODO: Implement + return nil, nil +} + +// CancelJob cancels a fine-tuning job +func (p *AzureProvider) CancelJob(ctx context.Context, jobID string) (*models.FineTuningJob, error) { + // TODO: Implement + return nil, nil +} + +// UploadFile uploads a file for fine-tuning +func (p *AzureProvider) UploadFile(ctx context.Context, filePath string) (string, error) { + // TODO: Implement + return "", nil +} + +// GetUploadedFile retrieves information about an uploaded file +func (p *AzureProvider) GetUploadedFile(ctx context.Context, fileID string) (interface{}, error) { + // TODO: Implement + return nil, nil +} + +// DeployModel deploys a fine-tuned or base model via Azure Cognitive Services +func (p *AzureProvider) DeployModel(ctx context.Context, req *models.DeploymentRequest) (*models.Deployment, error) { + // TODO: Implement + return nil, nil +} + +// GetDeploymentStatus retrieves the status of a deployment +func (p *AzureProvider) GetDeploymentStatus(ctx context.Context, deploymentID string) (*models.Deployment, error) { + // TODO: Implement + return nil, nil +} + +// ListDeployments lists all deployments +func (p *AzureProvider) ListDeployments(ctx context.Context, limit int, after string) ([]*models.Deployment, error) { + // TODO: Implement + return nil, nil +} + +// UpdateDeployment updates deployment configuration +func (p *AzureProvider) UpdateDeployment(ctx context.Context, deploymentID string, capacity int32) (*models.Deployment, error) { + // TODO: Implement + return nil, nil +} + +// DeleteDeployment deletes a deployment +func (p *AzureProvider) DeleteDeployment(ctx context.Context, deploymentID string) error { + // TODO: Implement + return nil +} diff --git a/cli/azd/extensions/azure.ai.finetune/internal/providers/interface.go b/cli/azd/extensions/azure.ai.finetune/internal/providers/interface.go new file mode 100644 index 00000000000..e0f935d88b0 --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/internal/providers/interface.go @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package providers + +import ( + "context" + + "azure.ai.finetune/pkg/models" +) + +// FineTuningProvider defines the interface for fine-tuning operations +// All providers (OpenAI, Azure, Anthropic, etc.) must implement this interface +type FineTuningProvider interface { + // CreateFineTuningJob creates a new fine-tuning job + CreateFineTuningJob(ctx context.Context, req *models.CreateFineTuningRequest) (*models.FineTuningJob, error) + + // GetFineTuningStatus retrieves the status of a fine-tuning job + GetFineTuningStatus(ctx context.Context, jobID string) (*models.FineTuningJob, error) + + // ListFineTuningJobs lists all fine-tuning jobs + ListFineTuningJobs(ctx context.Context, limit int, after string) ([]*models.FineTuningJob, error) + + // GetFineTuningJobDetails retrieves detailed information about a job + GetFineTuningJobDetails(ctx context.Context, jobID string) (*models.FineTuningJobDetail, error) + + // GetJobEvents retrieves events for a fine-tuning job + GetJobEvents(ctx context.Context, jobID string, limit int, after string) ([]*models.JobEvent, error) + + // GetJobCheckpoints retrieves checkpoints for a fine-tuning job + GetJobCheckpoints(ctx context.Context, jobID string, limit int, after string) ([]*models.JobCheckpoint, error) + + // PauseJob pauses a fine-tuning job + PauseJob(ctx context.Context, jobID string) (*models.FineTuningJob, error) + + // ResumeJob resumes a paused fine-tuning job + ResumeJob(ctx context.Context, jobID string) (*models.FineTuningJob, error) + + // CancelJob cancels a fine-tuning job + CancelJob(ctx context.Context, jobID string) (*models.FineTuningJob, error) + + // UploadFile uploads a file for fine-tuning + UploadFile(ctx context.Context, filePath string) (string, error) + + // GetUploadedFile retrieves information about an uploaded file + GetUploadedFile(ctx context.Context, fileID string) (interface{}, error) +} + +// ModelDeploymentProvider defines the interface for model deployment operations +// All providers must implement this interface for deployment functionality +type ModelDeploymentProvider interface { + // DeployModel deploys a fine-tuned or base model + DeployModel(ctx context.Context, req *models.DeploymentRequest) (*models.Deployment, error) + + // GetDeploymentStatus retrieves the status of a deployment + GetDeploymentStatus(ctx context.Context, deploymentID string) (*models.Deployment, error) + + // ListDeployments lists all deployments + ListDeployments(ctx context.Context, limit int, after string) ([]*models.Deployment, error) + + // UpdateDeployment updates deployment configuration + UpdateDeployment(ctx context.Context, deploymentID string, capacity int32) (*models.Deployment, error) + + // DeleteDeployment deletes a deployment + DeleteDeployment(ctx context.Context, deploymentID string) error +} diff --git a/cli/azd/extensions/azure.ai.finetune/internal/providers/openai/provider.go b/cli/azd/extensions/azure.ai.finetune/internal/providers/openai/provider.go new file mode 100644 index 00000000000..3edad0f6b35 --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/internal/providers/openai/provider.go @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package openai + +import ( + "context" + + "azure.ai.finetune/internal/providers" + "azure.ai.finetune/pkg/models" +) + +// Ensure OpenAIProvider implements FineTuningProvider and ModelDeploymentProvider interfaces +var ( + _ providers.FineTuningProvider = (*OpenAIProvider)(nil) + _ providers.ModelDeploymentProvider = (*OpenAIProvider)(nil) +) + +// OpenAIProvider implements the provider interface for OpenAI APIs +type OpenAIProvider struct { + // TODO: Add OpenAI SDK client + // client *openai.Client + apiKey string + endpoint string +} + +// NewOpenAIProvider creates a new OpenAI provider instance +func NewOpenAIProvider(apiKey, endpoint string) *OpenAIProvider { + return &OpenAIProvider{ + apiKey: apiKey, + endpoint: endpoint, + } +} + +// CreateFineTuningJob creates a new fine-tuning job via OpenAI API +func (p *OpenAIProvider) CreateFineTuningJob(ctx context.Context, req *models.CreateFineTuningRequest) (*models.FineTuningJob, error) { + // TODO: Implement + // 1. Convert domain model to OpenAI SDK format + // 2. Call OpenAI SDK CreateFineTuningJob + // 3. Convert OpenAI response to domain model + return nil, nil +} + +// GetFineTuningStatus retrieves the status of a fine-tuning job +func (p *OpenAIProvider) GetFineTuningStatus(ctx context.Context, jobID string) (*models.FineTuningJob, error) { + // TODO: Implement + return nil, nil +} + +// ListFineTuningJobs lists all fine-tuning jobs +func (p *OpenAIProvider) ListFineTuningJobs(ctx context.Context, limit int, after string) ([]*models.FineTuningJob, error) { + // TODO: Implement + return nil, nil +} + +// GetFineTuningJobDetails retrieves detailed information about a job +func (p *OpenAIProvider) GetFineTuningJobDetails(ctx context.Context, jobID string) (*models.FineTuningJobDetail, error) { + // TODO: Implement + return nil, nil +} + +// GetJobEvents retrieves events for a fine-tuning job +func (p *OpenAIProvider) GetJobEvents(ctx context.Context, jobID string, limit int, after string) ([]*models.JobEvent, error) { + // TODO: Implement + return nil, nil +} + +// GetJobCheckpoints retrieves checkpoints for a fine-tuning job +func (p *OpenAIProvider) GetJobCheckpoints(ctx context.Context, jobID string, limit int, after string) ([]*models.JobCheckpoint, error) { + // TODO: Implement + return nil, nil +} + +// PauseJob pauses a fine-tuning job +func (p *OpenAIProvider) PauseJob(ctx context.Context, jobID string) (*models.FineTuningJob, error) { + // TODO: Implement + return nil, nil +} + +// ResumeJob resumes a paused fine-tuning job +func (p *OpenAIProvider) ResumeJob(ctx context.Context, jobID string) (*models.FineTuningJob, error) { + // TODO: Implement + return nil, nil +} + +// CancelJob cancels a fine-tuning job +func (p *OpenAIProvider) CancelJob(ctx context.Context, jobID string) (*models.FineTuningJob, error) { + // TODO: Implement + return nil, nil +} + +// UploadFile uploads a file for fine-tuning +func (p *OpenAIProvider) UploadFile(ctx context.Context, filePath string) (string, error) { + // TODO: Implement + return "", nil +} + +// GetUploadedFile retrieves information about an uploaded file +func (p *OpenAIProvider) GetUploadedFile(ctx context.Context, fileID string) (interface{}, error) { + // TODO: Implement + return nil, nil +} + +// DeployModel deploys a fine-tuned or base model +func (p *OpenAIProvider) DeployModel(ctx context.Context, req *models.DeploymentRequest) (*models.Deployment, error) { + // TODO: Implement + return nil, nil +} + +// GetDeploymentStatus retrieves the status of a deployment +func (p *OpenAIProvider) GetDeploymentStatus(ctx context.Context, deploymentID string) (*models.Deployment, error) { + // TODO: Implement + return nil, nil +} + +// ListDeployments lists all deployments +func (p *OpenAIProvider) ListDeployments(ctx context.Context, limit int, after string) ([]*models.Deployment, error) { + // TODO: Implement + return nil, nil +} + +// UpdateDeployment updates deployment configuration +func (p *OpenAIProvider) UpdateDeployment(ctx context.Context, deploymentID string, capacity int32) (*models.Deployment, error) { + // TODO: Implement + return nil, nil +} + +// DeleteDeployment deletes a deployment +func (p *OpenAIProvider) DeleteDeployment(ctx context.Context, deploymentID string) error { + // TODO: Implement + return nil +} diff --git a/cli/azd/extensions/azure.ai.finetune/internal/services/deployment_service.go b/cli/azd/extensions/azure.ai.finetune/internal/services/deployment_service.go new file mode 100644 index 00000000000..aa5275df6f4 --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/internal/services/deployment_service.go @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package services + +import ( + "context" + + "azure.ai.finetune/internal/providers" + "azure.ai.finetune/pkg/models" +) + +// Ensure deploymentServiceImpl implements DeploymentService interface +var _ DeploymentService = (*deploymentServiceImpl)(nil) + +// deploymentServiceImpl implements the DeploymentService interface +type deploymentServiceImpl struct { + provider providers.ModelDeploymentProvider + stateStore StateStore +} + +// NewDeploymentService creates a new instance of DeploymentService +func NewDeploymentService(provider providers.ModelDeploymentProvider, stateStore StateStore) DeploymentService { + return &deploymentServiceImpl{ + provider: provider, + stateStore: stateStore, + } +} + +// DeployModel deploys a fine-tuned or base model with validation +func (s *deploymentServiceImpl) DeployModel(ctx context.Context, req *models.DeploymentRequest) (*models.Deployment, error) { + // TODO: Implement + // 1. Validate request (deployment name format, SKU valid, capacity valid, etc.) + // 2. Call provider.DeployModel() + // 3. Transform any errors to standardized ErrorDetail + // 4. Persist deployment to state store + // 5. Return deployment + return nil, nil +} + +// GetDeploymentStatus retrieves the current status of a deployment +func (s *deploymentServiceImpl) GetDeploymentStatus(ctx context.Context, deploymentID string) (*models.Deployment, error) { + // TODO: Implement + return nil, nil +} + +// ListDeployments lists all deployments for the user +func (s *deploymentServiceImpl) ListDeployments(ctx context.Context, limit int, after string) ([]*models.Deployment, error) { + // TODO: Implement + return nil, nil +} + +// UpdateDeployment updates deployment configuration (e.g., capacity) +func (s *deploymentServiceImpl) UpdateDeployment(ctx context.Context, deploymentID string, capacity int32) (*models.Deployment, error) { + // TODO: Implement + return nil, nil +} + +// DeleteDeployment deletes a deployment with proper validation +func (s *deploymentServiceImpl) DeleteDeployment(ctx context.Context, deploymentID string) error { + // TODO: Implement + return nil +} + +// WaitForDeployment waits for a deployment to become active +func (s *deploymentServiceImpl) WaitForDeployment(ctx context.Context, deploymentID string, timeoutSeconds int) (*models.Deployment, error) { + // TODO: Implement + return nil, nil +} diff --git a/cli/azd/extensions/azure.ai.finetune/internal/services/finetune_service.go b/cli/azd/extensions/azure.ai.finetune/internal/services/finetune_service.go new file mode 100644 index 00000000000..67f05d95d40 --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/internal/services/finetune_service.go @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package services + +import ( + "context" + + "azure.ai.finetune/internal/providers" + "azure.ai.finetune/pkg/models" +) + +// Ensure fineTuningServiceImpl implements FineTuningService interface +var _ FineTuningService = (*fineTuningServiceImpl)(nil) + +// fineTuningServiceImpl implements the FineTuningService interface +type fineTuningServiceImpl struct { + provider providers.FineTuningProvider + stateStore StateStore +} + +// NewFineTuningService creates a new instance of FineTuningService +func NewFineTuningService(provider providers.FineTuningProvider, stateStore StateStore) FineTuningService { + return &fineTuningServiceImpl{ + provider: provider, + stateStore: stateStore, + } +} + +// CreateFineTuningJob creates a new fine-tuning job with business validation +func (s *fineTuningServiceImpl) CreateFineTuningJob(ctx context.Context, req *models.CreateFineTuningRequest) (*models.FineTuningJob, error) { + // TODO: Implement + // 1. Validate request (model exists, data size valid, etc.) + // 2. Call provider.CreateFineTuningJob() + // 3. Transform any errors to standardized ErrorDetail + // 4. Persist job to state store + // 5. Return job + return nil, nil +} + +// GetFineTuningStatus retrieves the current status of a job +func (s *fineTuningServiceImpl) GetFineTuningStatus(ctx context.Context, jobID string) (*models.FineTuningJob, error) { + // TODO: Implement + return nil, nil +} + +// ListFineTuningJobs lists all fine-tuning jobs for the user +func (s *fineTuningServiceImpl) ListFineTuningJobs(ctx context.Context, limit int, after string) ([]*models.FineTuningJob, error) { + // TODO: Implement + return nil, nil +} + +// GetFineTuningJobDetails retrieves detailed information about a job +func (s *fineTuningServiceImpl) GetFineTuningJobDetails(ctx context.Context, jobID string) (*models.FineTuningJobDetail, error) { + // TODO: Implement + return nil, nil +} + +// GetJobEvents retrieves events for a job with filtering and pagination +func (s *fineTuningServiceImpl) GetJobEvents(ctx context.Context, jobID string, limit int, after string) ([]*models.JobEvent, error) { + // TODO: Implement + return nil, nil +} + +// GetJobCheckpoints retrieves checkpoints for a job with pagination +func (s *fineTuningServiceImpl) GetJobCheckpoints(ctx context.Context, jobID string, limit int, after string) ([]*models.JobCheckpoint, error) { + // TODO: Implement + return nil, nil +} + +// PauseJob pauses a running job (if applicable) +func (s *fineTuningServiceImpl) PauseJob(ctx context.Context, jobID string) (*models.FineTuningJob, error) { + // TODO: Implement + return nil, nil +} + +// ResumeJob resumes a paused job (if applicable) +func (s *fineTuningServiceImpl) ResumeJob(ctx context.Context, jobID string) (*models.FineTuningJob, error) { + // TODO: Implement + return nil, nil +} + +// CancelJob cancels a job with proper state validation +func (s *fineTuningServiceImpl) CancelJob(ctx context.Context, jobID string) (*models.FineTuningJob, error) { + // TODO: Implement + return nil, nil +} + +// UploadTrainingFile uploads and validates a training file +func (s *fineTuningServiceImpl) UploadTrainingFile(ctx context.Context, filePath string) (string, error) { + // TODO: Implement + return "", nil +} + +// UploadValidationFile uploads and validates a validation file +func (s *fineTuningServiceImpl) UploadValidationFile(ctx context.Context, filePath string) (string, error) { + // TODO: Implement + return "", nil +} + +// PollJobUntilCompletion polls a job until it completes or fails +func (s *fineTuningServiceImpl) PollJobUntilCompletion(ctx context.Context, jobID string, intervalSeconds int) (*models.FineTuningJob, error) { + // TODO: Implement + return nil, nil +} diff --git a/cli/azd/extensions/azure.ai.finetune/internal/services/interface.go b/cli/azd/extensions/azure.ai.finetune/internal/services/interface.go new file mode 100644 index 00000000000..e2b0d63c7e5 --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/internal/services/interface.go @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package services + +import ( + "context" + + "azure.ai.finetune/pkg/models" +) + +// FineTuningService defines the business logic interface for fine-tuning operations +type FineTuningService interface { + // CreateFineTuningJob creates a new fine-tuning job with business validation + CreateFineTuningJob(ctx context.Context, req *models.CreateFineTuningRequest) (*models.FineTuningJob, error) + + // GetFineTuningStatus retrieves the current status of a job + GetFineTuningStatus(ctx context.Context, jobID string) (*models.FineTuningJob, error) + + // ListFineTuningJobs lists all fine-tuning jobs for the user + ListFineTuningJobs(ctx context.Context, limit int, after string) ([]*models.FineTuningJob, error) + + // GetFineTuningJobDetails retrieves detailed information about a job + GetFineTuningJobDetails(ctx context.Context, jobID string) (*models.FineTuningJobDetail, error) + + // GetJobEvents retrieves events for a job with filtering and pagination + GetJobEvents(ctx context.Context, jobID string, limit int, after string) ([]*models.JobEvent, error) + + // GetJobCheckpoints retrieves checkpoints for a job with pagination + GetJobCheckpoints(ctx context.Context, jobID string, limit int, after string) ([]*models.JobCheckpoint, error) + + // PauseJob pauses a running job (if applicable) + PauseJob(ctx context.Context, jobID string) (*models.FineTuningJob, error) + + // ResumeJob resumes a paused job (if applicable) + ResumeJob(ctx context.Context, jobID string) (*models.FineTuningJob, error) + + // CancelJob cancels a job with proper state validation + CancelJob(ctx context.Context, jobID string) (*models.FineTuningJob, error) + + // UploadTrainingFile uploads and validates a training file + UploadTrainingFile(ctx context.Context, filePath string) (string, error) + + // UploadValidationFile uploads and validates a validation file + UploadValidationFile(ctx context.Context, filePath string) (string, error) + + // PollJobUntilCompletion polls a job until it completes or fails + PollJobUntilCompletion(ctx context.Context, jobID string, intervalSeconds int) (*models.FineTuningJob, error) +} + +// DeploymentService defines the business logic interface for model deployment operations +type DeploymentService interface { + // DeployModel deploys a fine-tuned or base model with validation + DeployModel(ctx context.Context, req *models.DeploymentRequest) (*models.Deployment, error) + + // GetDeploymentStatus retrieves the current status of a deployment + GetDeploymentStatus(ctx context.Context, deploymentID string) (*models.Deployment, error) + + // ListDeployments lists all deployments for the user + ListDeployments(ctx context.Context, limit int, after string) ([]*models.Deployment, error) + + // UpdateDeployment updates deployment configuration (e.g., capacity) + UpdateDeployment(ctx context.Context, deploymentID string, capacity int32) (*models.Deployment, error) + + // DeleteDeployment deletes a deployment with proper validation + DeleteDeployment(ctx context.Context, deploymentID string) error + + // WaitForDeployment waits for a deployment to become active + WaitForDeployment(ctx context.Context, deploymentID string, timeoutSeconds int) (*models.Deployment, error) +} diff --git a/cli/azd/extensions/azure.ai.finetune/internal/services/state_store.go b/cli/azd/extensions/azure.ai.finetune/internal/services/state_store.go new file mode 100644 index 00000000000..02b93103160 --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/internal/services/state_store.go @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package services + +import ( + "context" + + "azure.ai.finetune/pkg/models" +) + +// StateStore defines the interface for persisting job state +// This allows tracking jobs across CLI sessions +type StateStore interface { + // SaveJob persists a job to local storage + SaveJob(ctx context.Context, job *models.FineTuningJob) error + + // GetJob retrieves a job from local storage + GetJob(ctx context.Context, jobID string) (*models.FineTuningJob, error) + + // ListJobs lists all locally tracked jobs + ListJobs(ctx context.Context) ([]*models.FineTuningJob, error) + + // UpdateJobStatus updates the status of a tracked job + UpdateJobStatus(ctx context.Context, jobID string, status models.JobStatus) error + + // DeleteJob removes a job from local storage + DeleteJob(ctx context.Context, jobID string) error + + // SaveDeployment persists a deployment to local storage + SaveDeployment(ctx context.Context, deployment *models.Deployment) error + + // GetDeployment retrieves a deployment from local storage + GetDeployment(ctx context.Context, deploymentID string) (*models.Deployment, error) + + // ListDeployments lists all locally tracked deployments + ListDeployments(ctx context.Context) ([]*models.Deployment, error) + + // UpdateDeploymentStatus updates the status of a tracked deployment + UpdateDeploymentStatus(ctx context.Context, deploymentID string, status models.DeploymentStatus) error + + // DeleteDeployment removes a deployment from local storage + DeleteDeployment(ctx context.Context, deploymentID string) error +} + +// ErrorTransformer defines the interface for transforming vendor-specific errors +// to standardized error details +type ErrorTransformer interface { + // TransformError converts a vendor-specific error to a standardized ErrorDetail + TransformError(vendorError error, vendorCode string) *models.ErrorDetail +} diff --git a/cli/azd/extensions/azure.ai.finetune/internal/tools/deployment_wrapper.go b/cli/azd/extensions/azure.ai.finetune/internal/tools/deployment_wrapper.go new file mode 100644 index 00000000000..69f8f81ea3a --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/internal/tools/deployment_wrapper.go @@ -0,0 +1,148 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package JobWrapper + +import ( + "context" + "fmt" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/ux" +) + +// DeploymentConfig contains the configuration for deploying a fine-tuned model +type DeploymentConfig struct { + JobID string + DeploymentName string + ModelFormat string + SKU string + Version string + Capacity int32 + SubscriptionID string + ResourceGroup string + AccountName string + TenantID string + WaitForCompletion bool +} + +// DeployModelResult represents the result of a model deployment operation +type DeployModelResult struct { + DeploymentName string + Status string + Message string +} + +// DeployModel deploys a fine-tuned model to an Azure Cognitive Services account +func DeployModel(ctx context.Context, azdClient *azdext.AzdClient, config DeploymentConfig) (*DeployModelResult, error) { + // Validate required fields + if config.JobID == "" { + return nil, fmt.Errorf("job ID is required") + } + if config.DeploymentName == "" { + return nil, fmt.Errorf("deployment name is required") + } + if config.SubscriptionID == "" { + return nil, fmt.Errorf("subscription ID is required") + } + if config.ResourceGroup == "" { + return nil, fmt.Errorf("resource group is required") + } + if config.AccountName == "" { + return nil, fmt.Errorf("account name is required") + } + if config.TenantID == "" { + return nil, fmt.Errorf("tenant ID is required") + } + + // Get fine-tuned model details + jobDetails, err := GetJobDetails(ctx, azdClient, config.JobID) + if err != nil { + return nil, fmt.Errorf("failed to get job details: %w", err) + } + + if jobDetails.FineTunedModel == "" { + return nil, fmt.Errorf("job does not have a fine-tuned model yet") + } + + // Create Azure credential + credential, err := azidentity.NewAzureDeveloperCLICredential(&azidentity.AzureDeveloperCLICredentialOptions{ + TenantID: config.TenantID, + AdditionallyAllowedTenants: []string{"*"}, + }) + if err != nil { + return nil, fmt.Errorf("failed to create azure credential: %w", err) + } + + // Create Cognitive Services client factory + clientFactory, err := armcognitiveservices.NewClientFactory( + config.SubscriptionID, + credential, + nil, + ) + if err != nil { + return nil, fmt.Errorf("failed to create client factory: %w", err) + } + + // Show spinner while creating deployment + spinner := ux.NewSpinner(&ux.SpinnerOptions{ + Text: fmt.Sprintf("Deploying model to %s...", config.DeploymentName), + }) + if err := spinner.Start(ctx); err != nil { + fmt.Printf("Failed to start spinner: %v\n", err) + } + + // Create or update the deployment + poller, err := clientFactory.NewDeploymentsClient().BeginCreateOrUpdate( + ctx, + config.ResourceGroup, + config.AccountName, + config.DeploymentName, + armcognitiveservices.Deployment{ + Properties: &armcognitiveservices.DeploymentProperties{ + Model: &armcognitiveservices.DeploymentModel{ + Name: to.Ptr(jobDetails.FineTunedModel), + Format: to.Ptr(config.ModelFormat), + Version: to.Ptr(config.Version), + }, + }, + SKU: &armcognitiveservices.SKU{ + Name: to.Ptr(config.SKU), + Capacity: to.Ptr(config.Capacity), + }, + }, + nil, + ) + if err != nil { + _ = spinner.Stop(ctx) + return nil, fmt.Errorf("failed to start deployment: %w", err) + } + + // Wait for deployment to complete if requested + var status string + var message string + + if config.WaitForCompletion { + _, err := poller.PollUntilDone(ctx, nil) + _ = spinner.Stop(ctx) + if err != nil { + return nil, fmt.Errorf("deployment failed: %w", err) + } + status = "succeeded" + message = fmt.Sprintf("Model deployed successfully to %s", config.DeploymentName) + } else { + _ = spinner.Stop(ctx) + status = "in_progress" + message = fmt.Sprintf("Deployment %s initiated. Check deployment status in Azure Portal", config.DeploymentName) + } + + // Return result + return &DeployModelResult{ + DeploymentName: config.DeploymentName, + Status: status, + Message: message, + }, nil +} diff --git a/cli/azd/extensions/azure.ai.finetune/internal/tools/job_wrapper.go b/cli/azd/extensions/azure.ai.finetune/internal/tools/job_wrapper.go new file mode 100644 index 00000000000..5dc9c034857 --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/internal/tools/job_wrapper.go @@ -0,0 +1,543 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package JobWrapper + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/ux" + "github.com/openai/openai-go/v3" + "github.com/openai/openai-go/v3/azure" + "github.com/openai/openai-go/v3/option" +) + +const ( + // OpenAI API version for Azure cognitive services + apiVersion = "2025-04-01-preview" + // Azure cognitive services endpoint URL pattern + azureCognitiveServicesEndpoint = "https://%s.cognitiveservices.azure.com" +) + +// JobContract represents a fine-tuning job response contract +type JobContract struct { + Id string `json:"id"` + Status string `json:"status"` + Model string `json:"model"` + FineTunedModel string `json:"fine_tuned_model,omitempty"` + CreatedAt string `json:"created_at"` + FinishedAt *int64 `json:"finished_at,omitempty"` + FineTuning map[string]interface{} `json:"fine_tuning,omitempty"` + ResultFiles []string `json:"result_files,omitempty"` + Error *ErrorContract `json:"error,omitempty"` +} + +// ErrorContract represents an error response +type ErrorContract struct { + Code string `json:"code"` + Message string `json:"message"` +} + +// HyperparametersDetail represents hyperparameters details +type HyperparametersDetail struct { + BatchSize int64 `json:"batch_size,omitempty"` + LearningRateMultiplier float64 `json:"learning_rate_multiplier,omitempty"` + NEpochs int64 `json:"n_epochs,omitempty"` +} + +// MethodDetail represents method details +type MethodDetail struct { + Type string `json:"type"` +} + +// JobDetailContract represents a detailed fine-tuning job response contract +type JobDetailContract struct { + Id string `json:"id"` + Status string `json:"status"` + Model string `json:"model"` + FineTunedModel string `json:"fine_tuned_model,omitempty"` + CreatedAt string `json:"created_at"` + FinishedAt string `json:"finished_at,omitempty"` + Method string `json:"method,omitempty"` + TrainingFile string `json:"training_file,omitempty"` + ValidationFile string `json:"validation_file,omitempty"` + Hyperparameters *HyperparametersDetail `json:"hyperparameters,omitempty"` +} + +// EventContract represents a fine-tuning job event +type EventContract struct { + ID string `json:"id"` + CreatedAt string `json:"created_at"` + Level string `json:"level"` + Message string `json:"message"` + Data interface{} `json:"data,omitempty"` + Type string `json:"type"` +} + +// EventsListContract represents a list of fine-tuning job events +type EventsListContract struct { + Data []EventContract `json:"data"` + HasMore bool `json:"has_more"` +} + +// CheckpointMetrics represents the metrics for a checkpoint +type CheckpointMetrics struct { + FullValidLoss float64 `json:"full_valid_loss,omitempty"` + FullValidMeanTokenAccuracy float64 `json:"full_valid_mean_token_accuracy,omitempty"` +} + +// CheckpointContract represents a provider-agnostic fine-tuning job checkpoint +// This allows supporting multiple AI providers (OpenAI, Azure, etc.) +type CheckpointContract struct { + ID string `json:"id"` + CreatedAt string `json:"created_at"` + FineTunedModelCheckpoint string `json:"fine_tuned_model_checkpoint,omitempty"` + Metrics *CheckpointMetrics `json:"metrics,omitempty"` + FineTuningJobID string `json:"fine_tuning_job_id,omitempty"` + StepNumber int64 `json:"step_number,omitempty"` +} + +// CheckpointsListContract represents a list of fine-tuning job checkpoints +type CheckpointsListContract struct { + Data []CheckpointContract `json:"data"` + HasMore bool `json:"has_more"` +} + +// ListJobs retrieves a list of fine-tuning jobs and returns them as JobContract objects +func ListJobs(ctx context.Context, azdClient *azdext.AzdClient, top int, after string) ([]JobContract, error) { + client, err := GetOpenAIClientFromAzdClient(ctx, azdClient) + if err != nil { + return nil, fmt.Errorf("failed to create OpenAI client: %w", err) + } + + jobList, err := client.FineTuning.Jobs.List(ctx, openai.FineTuningJobListParams{ + Limit: openai.Int(int64(top)), // optional pagination control + After: openai.String(after), + }) + if err != nil { + return nil, fmt.Errorf("failed to list fine-tuning jobs: %w", err) + } + + var jobs []JobContract + + if err != nil { + fmt.Printf("failed to list fine-tuning jobs: %v", err) + } + lineNum := 0 + for _, job := range jobList.Data { + lineNum++ + jobContract := JobContract{ + Id: job.ID, + Status: string(job.Status), + Model: job.Model, + CreatedAt: formatUnixTimestampToUTC(job.CreatedAt), + FineTunedModel: job.FineTunedModel, + } + jobs = append(jobs, jobContract) + } + + return jobs, nil +} + +// CreateJob creates a new fine-tuning job with the provided parameters +func CreateJob(ctx context.Context, azdClient *azdext.AzdClient, params openai.FineTuningJobNewParams) (*JobContract, error) { + client, err := GetOpenAIClientFromAzdClient(ctx, azdClient) + if err != nil { + return nil, fmt.Errorf("failed to create OpenAI client: %w", err) + } + + // Validate required parameters + if params.Model == "" { + return nil, fmt.Errorf("model is required for fine-tuning job") + } + + if params.TrainingFile == "" { + return nil, fmt.Errorf("training_file is required for fine-tuning job") + } + + // Show spinner while creating job + spinner := ux.NewSpinner(&ux.SpinnerOptions{ + Text: "Submitting fine-tuning job...", + }) + if err := spinner.Start(ctx); err != nil { + fmt.Printf("Failed to start spinner: %v\n", err) + } + + // Create the fine-tuning job + job, err := client.FineTuning.Jobs.New(ctx, params) + _ = spinner.Stop(ctx) + + if err != nil { + return nil, fmt.Errorf("failed to create fine-tuning job: %w", err) + } + + // Convert to JobContract + jobContract := &JobContract{ + Id: job.ID, + Status: string(job.Status), + Model: job.Model, + CreatedAt: formatUnixTimestampToUTC(job.CreatedAt), + FineTunedModel: job.FineTunedModel, + } + + return jobContract, nil +} + +// formatUnixTimestampToUTC converts Unix timestamp (seconds) to UTC time string +func formatUnixTimestampToUTC(timestamp int64) string { + if timestamp == 0 { + return "" + } + return time.Unix(timestamp, 0).UTC().Format("2006-01-02 15:04:05 UTC") +} + +func GetJobDetails(ctx context.Context, azdClient *azdext.AzdClient, jobId string) (*JobDetailContract, error) { + client, err := GetOpenAIClientFromAzdClient(ctx, azdClient) + if err != nil { + return nil, fmt.Errorf("failed to create OpenAI client: %w", err) + } + + job, err := client.FineTuning.Jobs.Get(ctx, jobId) + if err != nil { + return nil, fmt.Errorf("failed to get job details: %w", err) + } + + // Extract hyperparameters based on method type + hyperparameters := &HyperparametersDetail{} + hyperparameters.BatchSize = job.Hyperparameters.BatchSize.OfInt + hyperparameters.LearningRateMultiplier = job.Hyperparameters.LearningRateMultiplier.OfFloat + hyperparameters.NEpochs = job.Hyperparameters.NEpochs.OfInt + + // Create job detail contract + jobDetail := &JobDetailContract{ + Id: job.ID, + Status: string(job.Status), + Model: job.Model, + FineTunedModel: job.FineTunedModel, + CreatedAt: formatUnixTimestampToUTC(job.CreatedAt), + FinishedAt: formatUnixTimestampToUTC(job.FinishedAt), + Method: job.Method.Type, + TrainingFile: job.TrainingFile, + ValidationFile: job.ValidationFile, + Hyperparameters: hyperparameters, + } + + return jobDetail, nil +} + +func GetJobEvents( + ctx context.Context, + azdClient *azdext.AzdClient, + jobId string, +) (*EventsListContract, error) { + client, err := GetOpenAIClientFromAzdClient(ctx, azdClient) + if err != nil { + return nil, fmt.Errorf("failed to create OpenAI client: %w", err) + } + + eventsList, err := client.FineTuning.Jobs.ListEvents( + ctx, + jobId, + openai.FineTuningJobListEventsParams{}, + ) + if err != nil { + return nil, fmt.Errorf("failed to get job events: %w", err) + } + + // Convert events to EventContract slice + var events []EventContract + for _, event := range eventsList.Data { + eventContract := EventContract{ + ID: event.ID, + CreatedAt: formatUnixTimestampToUTC(event.CreatedAt), + Level: string(event.Level), + Message: event.Message, + Data: event.Data, + Type: string(event.Type), + } + events = append(events, eventContract) + } + + // Return EventsListContract + return &EventsListContract{ + Data: events, + HasMore: eventsList.HasMore, + }, nil +} + +func GetJobCheckPoints( + ctx context.Context, + azdClient *azdext.AzdClient, + jobId string, +) (*CheckpointsListContract, error) { + client, err := GetOpenAIClientFromAzdClient(ctx, azdClient) + if err != nil { + return nil, fmt.Errorf("failed to create OpenAI client: %w", err) + } + + checkpointList, err := client.FineTuning.Jobs.Checkpoints.List( + ctx, + jobId, + openai.FineTuningJobCheckpointListParams{}, + ) + if err != nil { + return nil, fmt.Errorf("failed to get job checkpoints: %w", err) + } + + // Convert checkpoints to CheckpointContract slice + var checkpoints []CheckpointContract + for _, checkpoint := range checkpointList.Data { + metrics := &CheckpointMetrics{ + FullValidLoss: checkpoint.Metrics.FullValidLoss, + FullValidMeanTokenAccuracy: checkpoint.Metrics.FullValidMeanTokenAccuracy, + } + + checkpointContract := CheckpointContract{ + ID: checkpoint.ID, + CreatedAt: formatUnixTimestampToUTC(checkpoint.CreatedAt), + FineTunedModelCheckpoint: checkpoint.FineTunedModelCheckpoint, + Metrics: metrics, + FineTuningJobID: checkpoint.FineTuningJobID, + StepNumber: checkpoint.StepNumber, + } + checkpoints = append(checkpoints, checkpointContract) + } + + // Return CheckpointsListContract + return &CheckpointsListContract{ + Data: checkpoints, + HasMore: checkpointList.HasMore, + }, nil +} + +// GetOpenAIClientFromAzdClient creates an OpenAI client from AzdClient context +func GetOpenAIClientFromAzdClient(ctx context.Context, azdClient *azdext.AzdClient) (*openai.Client, error) { + envValueMap := make(map[string]string) + + if envResponse, err := azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}); err == nil { + env := envResponse.Environment + envValues, err := azdClient.Environment().GetValues(ctx, &azdext.GetEnvironmentRequest{ + Name: env.Name, + }) + if err != nil { + return nil, fmt.Errorf("failed to get environment values: %w", err) + } + + for _, value := range envValues.KeyValues { + envValueMap[value.Key] = value.Value + } + } + + azureContext := &azdext.AzureContext{ + Scope: &azdext.AzureScope{ + TenantId: envValueMap["AZURE_TENANT_ID"], + SubscriptionId: envValueMap["AZURE_SUBSCRIPTION_ID"], + Location: envValueMap["AZURE_LOCATION"], + }, + Resources: []string{}, + } + + credential, err := azidentity.NewAzureDeveloperCLICredential(&azidentity.AzureDeveloperCLICredentialOptions{ + TenantID: azureContext.Scope.TenantId, + AdditionallyAllowedTenants: []string{"*"}, + }) + if err != nil { + return nil, fmt.Errorf("failed to create azure credential: %w", err) + } + + // Get Azure credentials and endpoint - TODO + // You'll need to get these from your environment or config + accountName := envValueMap["AZURE_ACCOUNT_NAME"] + endpoint := fmt.Sprintf(azureCognitiveServicesEndpoint, accountName) + + if endpoint == "" { + return nil, fmt.Errorf("AZURE_OPENAI_ENDPOINT environment variable not set") + } + + // Create OpenAI client + client := openai.NewClient( + //azure.WithEndpoint(endpoint, apiVersion), + option.WithBaseURL(fmt.Sprintf("%s/openai", endpoint)), + option.WithQuery("api-version", apiVersion), + azure.WithTokenCredential(credential), + ) + return &client, nil +} + +// UploadFileIfLocal handles local file upload or returns the file ID if it's already uploaded +func UploadFileIfLocal(ctx context.Context, azdClient *azdext.AzdClient, filePath string) (string, error) { + client, err := GetOpenAIClientFromAzdClient(ctx, azdClient) + if err != nil { + return "", fmt.Errorf("failed to create OpenAI client: %w", err) + } + // Check if it's a local file + if strings.HasPrefix(filePath, "local:") { + // Remove "local:" prefix and get the actual path + localPath := strings.TrimPrefix(filePath, "local:") + localPath = strings.TrimSpace(localPath) + + // Resolve absolute path + absPath, err := filepath.Abs(localPath) + if err != nil { + return "", fmt.Errorf("failed to resolve absolute path for %s: %w", localPath, err) + } + + // Open the file + data, err := os.Open(absPath) + if err != nil { + return "", fmt.Errorf("failed to open file %s: %w", localPath, err) + } + defer data.Close() + + // Upload the file + uploadedFile, err := client.Files.New(ctx, openai.FileNewParams{ + File: data, + Purpose: openai.FilePurposeFineTune, + }) + if err != nil { + return "", fmt.Errorf("failed to upload file: %w", err) + } + + // Wait for file processing + spinner := ux.NewSpinner(&ux.SpinnerOptions{ + Text: "Waiting for file processing...", + }) + if err := spinner.Start(ctx); err != nil { + fmt.Printf("Failed to start spinner: %v\n", err) + } + for { + f, err := client.Files.Get(ctx, uploadedFile.ID) + if err != nil { + _ = spinner.Stop(ctx) + return "", fmt.Errorf("\nfailed to check file status: %w", err) + } + + if f.Status == openai.FileObjectStatusProcessed { + _ = spinner.Stop(ctx) + break + } + + if f.Status == openai.FileObjectStatusError { + _ = spinner.Stop(ctx) + return "", fmt.Errorf("\nfile processing failed with status: %s", f.Status) + } + + fmt.Print(".") + time.Sleep(2 * time.Second) + } + fmt.Printf(" Uploaded: %s -> %s, status:%s\n", localPath, uploadedFile.ID, uploadedFile.Status) + return uploadedFile.ID, nil + } + + // If it's not a local file, assume it's already a file ID + return filePath, nil +} + +// PauseJob pauses a fine-tuning job +func PauseJob(ctx context.Context, azdClient *azdext.AzdClient, jobId string) (*JobContract, error) { + client, err := GetOpenAIClientFromAzdClient(ctx, azdClient) + if err != nil { + return nil, fmt.Errorf("failed to create OpenAI client: %w", err) + } + + // Show spinner while pausing job + spinner := ux.NewSpinner(&ux.SpinnerOptions{ + Text: fmt.Sprintf("Pausing fine-tuning job %s...", jobId), + }) + if err := spinner.Start(ctx); err != nil { + fmt.Printf("Failed to start spinner: %v\n", err) + } + + job, err := client.FineTuning.Jobs.Pause(ctx, jobId) + _ = spinner.Stop(ctx) + + if err != nil { + return nil, fmt.Errorf("failed to pause fine-tuning job: %w", err) + } + + // Convert to JobContract + jobContract := &JobContract{ + Id: job.ID, + Status: string(job.Status), + Model: job.Model, + CreatedAt: formatUnixTimestampToUTC(job.CreatedAt), + FineTunedModel: job.FineTunedModel, + } + + return jobContract, nil +} + +// ResumeJob resumes a fine-tuning job +func ResumeJob(ctx context.Context, azdClient *azdext.AzdClient, jobId string) (*JobContract, error) { + client, err := GetOpenAIClientFromAzdClient(ctx, azdClient) + if err != nil { + return nil, fmt.Errorf("failed to create OpenAI client: %w", err) + } + + // Show spinner while resuming job + spinner := ux.NewSpinner(&ux.SpinnerOptions{ + Text: fmt.Sprintf("Resuming fine-tuning job %s...", jobId), + }) + if err := spinner.Start(ctx); err != nil { + fmt.Printf("Failed to start spinner: %v\n", err) + } + + job, err := client.FineTuning.Jobs.Resume(ctx, jobId) + _ = spinner.Stop(ctx) + + if err != nil { + return nil, fmt.Errorf("failed to resume fine-tuning job: %w", err) + } + + // Convert to JobContract + jobContract := &JobContract{ + Id: job.ID, + Status: string(job.Status), + Model: job.Model, + CreatedAt: formatUnixTimestampToUTC(job.CreatedAt), + FineTunedModel: job.FineTunedModel, + } + + return jobContract, nil +} + +// CancelJob cancels a fine-tuning job +func CancelJob(ctx context.Context, azdClient *azdext.AzdClient, jobId string) (*JobContract, error) { + client, err := GetOpenAIClientFromAzdClient(ctx, azdClient) + if err != nil { + return nil, fmt.Errorf("failed to create OpenAI client: %w", err) + } + + // Show spinner while cancelling job + spinner := ux.NewSpinner(&ux.SpinnerOptions{ + Text: fmt.Sprintf("Cancelling fine-tuning job %s...", jobId), + }) + if err := spinner.Start(ctx); err != nil { + fmt.Printf("Failed to start spinner: %v\n", err) + } + + job, err := client.FineTuning.Jobs.Cancel(ctx, jobId) + _ = spinner.Stop(ctx) + + if err != nil { + return nil, fmt.Errorf("failed to cancel fine-tuning job: %w", err) + } + + // Convert to JobContract + jobContract := &JobContract{ + Id: job.ID, + Status: string(job.Status), + Model: job.Model, + CreatedAt: formatUnixTimestampToUTC(job.CreatedAt), + FineTunedModel: job.FineTunedModel, + } + + return jobContract, nil +} diff --git a/cli/azd/extensions/azure.ai.finetune/main.go b/cli/azd/extensions/azure.ai.finetune/main.go new file mode 100644 index 00000000000..6ea052455e1 --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/main.go @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package main + +import ( + "context" + "os" + + "azure.ai.finetune/internal/cmd" + "github.com/fatih/color" +) + +func init() { + forceColorVal, has := os.LookupEnv("FORCE_COLOR") + if has && forceColorVal == "1" { + color.NoColor = false + } +} + +func main() { + // Execute the root command + ctx := context.Background() + rootCmd := cmd.NewRootCommand() + + if err := rootCmd.ExecuteContext(ctx); err != nil { + color.Red("Error: %v", err) + os.Exit(1) + } +} diff --git a/cli/azd/extensions/azure.ai.finetune/pkg/models/deployment.go b/cli/azd/extensions/azure.ai.finetune/pkg/models/deployment.go new file mode 100644 index 00000000000..0a6257868da --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/pkg/models/deployment.go @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package models + +import "time" + +// DeploymentStatus represents the status of a deployment +type DeploymentStatus string + +const ( + DeploymentPending DeploymentStatus = "pending" + DeploymentActive DeploymentStatus = "active" + DeploymentUpdating DeploymentStatus = "updating" + DeploymentFailed DeploymentStatus = "failed" + DeploymentDeleting DeploymentStatus = "deleting" +) + +// Deployment represents a model deployment +type Deployment struct { + // Core identification + ID string + VendorID string // Vendor-specific ID + + // Deployment details + Name string + Status DeploymentStatus + FineTunedModel string + BaseModel string + + // Endpoint + Endpoint string + + // Timestamps + CreatedAt time.Time + UpdatedAt *time.Time + DeletedAt *time.Time + + // Metadata + VendorMetadata map[string]interface{} + ErrorDetails *ErrorDetail +} + +// DeploymentRequest represents a request to create a deployment +type DeploymentRequest struct { + DeploymentName string + ModelID string + ModelFormat string + SKU string + Version string + Capacity int32 + SubscriptionID string + ResourceGroup string + AccountName string + TenantID string + WaitForCompletion bool +} + +// DeploymentConfig contains configuration for deploying a model +type DeploymentConfig struct { + JobID string + DeploymentName string + ModelFormat string + SKU string + Version string + Capacity int32 + SubscriptionID string + ResourceGroup string + AccountName string + TenantID string + WaitForCompletion bool +} + +// BaseModel represents information about a base model +type BaseModel struct { + ID string + Name string + Description string + Deprecated bool +} diff --git a/cli/azd/extensions/azure.ai.finetune/pkg/models/errors.go b/cli/azd/extensions/azure.ai.finetune/pkg/models/errors.go new file mode 100644 index 00000000000..98fd25db404 --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/pkg/models/errors.go @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package models + +// ErrorDetail represents a standardized error response across vendors +type ErrorDetail struct { + Code string // Standard error code (e.g., "INVALID_REQUEST", "RATE_LIMITED") + Message string // User-friendly error message + Retryable bool // Whether the operation can be retried + VendorError error // Original vendor-specific error (for debugging) + VendorCode string // Vendor-specific error code +} + +// Common error codes +const ( + ErrorCodeInvalidRequest = "INVALID_REQUEST" + ErrorCodeNotFound = "NOT_FOUND" + ErrorCodeUnauthorized = "UNAUTHORIZED" + ErrorCodeForbidden = "FORBIDDEN" + ErrorCodeRateLimited = "RATE_LIMITED" + ErrorCodeServiceUnavailable = "SERVICE_UNAVAILABLE" + ErrorCodeInternalError = "INTERNAL_ERROR" + ErrorCodeInvalidModel = "INVALID_MODEL" + ErrorCodeInvalidFileSize = "INVALID_FILE_SIZE" + ErrorCodeOperationFailed = "OPERATION_FAILED" +) + +// Error implements the error interface +func (e *ErrorDetail) Error() string { + return e.Message +} diff --git a/cli/azd/extensions/azure.ai.finetune/pkg/models/finetune.go b/cli/azd/extensions/azure.ai.finetune/pkg/models/finetune.go new file mode 100644 index 00000000000..a8b6b11b237 --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/pkg/models/finetune.go @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package models + +import "time" + +// JobStatus represents the status of a fine-tuning job +type JobStatus string + +const ( + StatusPending JobStatus = "pending" + StatusQueued JobStatus = "queued" + StatusRunning JobStatus = "running" + StatusSucceeded JobStatus = "succeeded" + StatusFailed JobStatus = "failed" + StatusCancelled JobStatus = "cancelled" + StatusPaused JobStatus = "paused" +) + +// FineTuningJob represents a vendor-agnostic fine-tuning job +type FineTuningJob struct { + // Core identification + ID string + VendorJobID string // Vendor-specific ID (e.g., OpenAI's ftjob-xxx) + + // Job details + Status JobStatus + BaseModel string + FineTunedModel string + + // Timestamps + CreatedAt time.Time + CompletedAt *time.Time + + // Files + TrainingFileID string + ValidationFileID string + + // Metadata + VendorMetadata map[string]interface{} // Store vendor-specific details + ErrorDetails *ErrorDetail +} + +// CreateFineTuningRequest represents a request to create a fine-tuning job +type CreateFineTuningRequest struct { + BaseModel string + TrainingDataID string + ValidationDataID string + Hyperparameters *Hyperparameters +} + +// Hyperparameters represents fine-tuning hyperparameters +type Hyperparameters struct { + BatchSize int64 + LearningRateMultiplier float64 + NEpochs int64 +} + +// ListFineTuningJobsRequest represents a request to list fine-tuning jobs +type ListFineTuningJobsRequest struct { + Limit int + After string +} + +// FineTuningJobDetail represents detailed information about a fine-tuning job +type FineTuningJobDetail struct { + ID string + Status JobStatus + Model string + FineTunedModel string + CreatedAt time.Time + FinishedAt *time.Time + Method string + TrainingFile string + ValidationFile string + Hyperparameters *Hyperparameters + VendorMetadata map[string]interface{} +} + +// JobEvent represents an event associated with a fine-tuning job +type JobEvent struct { + ID string + CreatedAt time.Time + Level string + Message string + Data interface{} + Type string +} + +// JobCheckpoint represents a checkpoint of a fine-tuning job +type JobCheckpoint struct { + ID string + CreatedAt time.Time + FineTunedModelCheckpoint string + Metrics *CheckpointMetrics + FineTuningJobID string + StepNumber int64 +} + +// CheckpointMetrics represents metrics for a checkpoint +type CheckpointMetrics struct { + FullValidLoss float64 + FullValidMeanTokenAccuracy float64 +} diff --git a/cli/azd/extensions/azure.ai.finetune/pkg/models/requests.go b/cli/azd/extensions/azure.ai.finetune/pkg/models/requests.go new file mode 100644 index 00000000000..3c42e3c146d --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/pkg/models/requests.go @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package models + +// PauseJobRequest represents a request to pause a fine-tuning job +type PauseJobRequest struct { + JobID string +} + +// ResumeJobRequest represents a request to resume a fine-tuning job +type ResumeJobRequest struct { + JobID string +} + +// CancelJobRequest represents a request to cancel a fine-tuning job +type CancelJobRequest struct { + JobID string +} + +// GetJobDetailsRequest represents a request to get job details +type GetJobDetailsRequest struct { + JobID string +} + +// GetJobEventsRequest represents a request to list job events +type GetJobEventsRequest struct { + JobID string + Limit int + After string +} + +// GetJobCheckpointsRequest represents a request to list job checkpoints +type GetJobCheckpointsRequest struct { + JobID string + Limit int + After string +} + +// ListDeploymentsRequest represents a request to list deployments +type ListDeploymentsRequest struct { + Limit int + After string +} + +// GetDeploymentRequest represents a request to get deployment details +type GetDeploymentRequest struct { + DeploymentID string +} + +// DeleteDeploymentRequest represents a request to delete a deployment +type DeleteDeploymentRequest struct { + DeploymentID string +} + +// UpdateDeploymentRequest represents a request to update a deployment +type UpdateDeploymentRequest struct { + DeploymentID string + Capacity int32 +}