From 41030f1d25e0f64b4114a1733cf9abe20a4232f1 Mon Sep 17 00:00:00 2001 From: Amit Chauhan <70937115+achauhan-scc@users.noreply.github.com> Date: Wed, 24 Dec 2025 10:27:39 +0530 Subject: [PATCH 01/32] Moving code Azure fork (#6429) * adding draft for fine tuning * adding service target * fixing root command format * cleaning unused commands and adding operation/sub-operation commands * adding more details to command * fixes for relative path * adding registry entry * adding git download * adding 0.0.3 * fixing bug in deploy * adding printing format * re-structuring code * adding 0.0.5 * 0.0.6 * adding low level design details * fixing ext for build * adding restructured code * reverting any registry changes --- .../extensions/azure.ai.finetune/README.md | 3 + .../extensions/azure.ai.finetune/build.ps1 | 78 ++ cli/azd/extensions/azure.ai.finetune/build.sh | 66 ++ .../extensions/azure.ai.finetune/changelog.md | 3 + .../design/IMPLEMENTATION_SUMMARY.md | 284 ++++++ .../azure.ai.finetune/design/architecture.md | 677 ++++++++++++++ .../examples/fine-tuning-dpo.yaml | 24 + .../examples/fine-tuning-reinforcement.yaml | 35 + .../examples/fine-tuning-supervised.yaml | 34 + .../azure.ai.finetune/extension.yaml | 28 + cli/azd/extensions/azure.ai.finetune/go.mod | 91 ++ cli/azd/extensions/azure.ai.finetune/go.sum | 289 ++++++ .../internal/cmd/converter.go | 206 +++++ .../azure.ai.finetune/internal/cmd/init.go | 827 ++++++++++++++++++ .../azure.ai.finetune/internal/cmd/listen.go | 74 ++ .../internal/cmd/operations.go | 459 ++++++++++ .../azure.ai.finetune/internal/cmd/prompt.go | 276 ++++++ .../azure.ai.finetune/internal/cmd/root.go | 54 ++ .../azure.ai.finetune/internal/cmd/version.go | 27 + .../internal/fine_tuning_yaml/parser.go | 570 ++++++++++++ .../internal/fine_tuning_yaml/yaml.go | 323 +++++++ .../project/service_target_finetune.go | 140 +++ .../internal/providers/azure/provider.go | 133 +++ .../internal/providers/interface.go | 66 ++ .../internal/providers/openai/provider.go | 132 +++ .../internal/services/deployment_service.go | 69 ++ .../internal/services/finetune_service.go | 105 +++ .../internal/services/interface.go | 70 ++ .../internal/services/state_store.go | 51 ++ .../internal/tools/deployment_wrapper.go | 148 ++++ .../internal/tools/job_wrapper.go | 543 ++++++++++++ cli/azd/extensions/azure.ai.finetune/main.go | 30 + .../pkg/models/deployment.go | 80 ++ .../azure.ai.finetune/pkg/models/errors.go | 32 + .../azure.ai.finetune/pkg/models/finetune.go | 105 +++ .../azure.ai.finetune/pkg/models/requests.go | 60 ++ 36 files changed, 6192 insertions(+) create mode 100644 cli/azd/extensions/azure.ai.finetune/README.md create mode 100644 cli/azd/extensions/azure.ai.finetune/build.ps1 create mode 100644 cli/azd/extensions/azure.ai.finetune/build.sh create mode 100644 cli/azd/extensions/azure.ai.finetune/changelog.md create mode 100644 cli/azd/extensions/azure.ai.finetune/design/IMPLEMENTATION_SUMMARY.md create mode 100644 cli/azd/extensions/azure.ai.finetune/design/architecture.md create mode 100644 cli/azd/extensions/azure.ai.finetune/examples/fine-tuning-dpo.yaml create mode 100644 cli/azd/extensions/azure.ai.finetune/examples/fine-tuning-reinforcement.yaml create mode 100644 cli/azd/extensions/azure.ai.finetune/examples/fine-tuning-supervised.yaml create mode 100644 cli/azd/extensions/azure.ai.finetune/extension.yaml create mode 100644 cli/azd/extensions/azure.ai.finetune/go.mod create mode 100644 cli/azd/extensions/azure.ai.finetune/go.sum create mode 100644 cli/azd/extensions/azure.ai.finetune/internal/cmd/converter.go create mode 100644 cli/azd/extensions/azure.ai.finetune/internal/cmd/init.go create mode 100644 cli/azd/extensions/azure.ai.finetune/internal/cmd/listen.go create mode 100644 cli/azd/extensions/azure.ai.finetune/internal/cmd/operations.go create mode 100644 cli/azd/extensions/azure.ai.finetune/internal/cmd/prompt.go create mode 100644 cli/azd/extensions/azure.ai.finetune/internal/cmd/root.go create mode 100644 cli/azd/extensions/azure.ai.finetune/internal/cmd/version.go create mode 100644 cli/azd/extensions/azure.ai.finetune/internal/fine_tuning_yaml/parser.go create mode 100644 cli/azd/extensions/azure.ai.finetune/internal/fine_tuning_yaml/yaml.go create mode 100644 cli/azd/extensions/azure.ai.finetune/internal/project/service_target_finetune.go create mode 100644 cli/azd/extensions/azure.ai.finetune/internal/providers/azure/provider.go create mode 100644 cli/azd/extensions/azure.ai.finetune/internal/providers/interface.go create mode 100644 cli/azd/extensions/azure.ai.finetune/internal/providers/openai/provider.go create mode 100644 cli/azd/extensions/azure.ai.finetune/internal/services/deployment_service.go create mode 100644 cli/azd/extensions/azure.ai.finetune/internal/services/finetune_service.go create mode 100644 cli/azd/extensions/azure.ai.finetune/internal/services/interface.go create mode 100644 cli/azd/extensions/azure.ai.finetune/internal/services/state_store.go create mode 100644 cli/azd/extensions/azure.ai.finetune/internal/tools/deployment_wrapper.go create mode 100644 cli/azd/extensions/azure.ai.finetune/internal/tools/job_wrapper.go create mode 100644 cli/azd/extensions/azure.ai.finetune/main.go create mode 100644 cli/azd/extensions/azure.ai.finetune/pkg/models/deployment.go create mode 100644 cli/azd/extensions/azure.ai.finetune/pkg/models/errors.go create mode 100644 cli/azd/extensions/azure.ai.finetune/pkg/models/finetune.go create mode 100644 cli/azd/extensions/azure.ai.finetune/pkg/models/requests.go 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 +} From bb7f9a19798dbbb82ff105f9644471f054753fa0 Mon Sep 17 00:00:00 2001 From: saanikaguptamicrosoft Date: Mon, 29 Dec 2025 08:50:55 +0530 Subject: [PATCH 02/32] Refactoring jobs list command to align with the LLD | Add retry, environment and validation utils (#6430) * initial changes * Add retry util for exponential backoff with jitter strategy * Clean-up * Standardize CLI text to lowercase conventions, and remove redundant error messages * Address comments * Add common util for fetch environment variables * Cosmetic changes * Cosmetic changes 2 * Fix runtime error * Fix error message format --- .../internal/cmd/operations.go | 38 +++++---- .../azure.ai.finetune/internal/cmd/root.go | 8 +- .../internal/cmd/validation.go | 32 +++++++ .../internal/providers/azure/provider.go | 19 +---- .../providers/factory/provider_factory.go | 84 +++++++++++++++++++ .../internal/providers/openai/conversions.go | 49 +++++++++++ .../internal/providers/openai/provider.go | 36 ++++---- .../internal/services/finetune_service.go | 31 ++++++- .../internal/tools/job_wrapper.go | 36 -------- .../internal/utils/environment.go | 43 ++++++++++ .../azure.ai.finetune/internal/utils/retry.go | 74 ++++++++++++++++ .../azure.ai.finetune/internal/utils/time.go | 17 ++++ 12 files changed, 377 insertions(+), 90 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.finetune/internal/cmd/validation.go create mode 100644 cli/azd/extensions/azure.ai.finetune/internal/providers/factory/provider_factory.go create mode 100644 cli/azd/extensions/azure.ai.finetune/internal/providers/openai/conversions.go create mode 100644 cli/azd/extensions/azure.ai.finetune/internal/utils/environment.go create mode 100644 cli/azd/extensions/azure.ai.finetune/internal/utils/retry.go create mode 100644 cli/azd/extensions/azure.ai.finetune/internal/utils/time.go diff --git a/cli/azd/extensions/azure.ai.finetune/internal/cmd/operations.go b/cli/azd/extensions/azure.ai.finetune/internal/cmd/operations.go index 2c18f964002..9c2ce3d6894 100644 --- a/cli/azd/extensions/azure.ai.finetune/internal/cmd/operations.go +++ b/cli/azd/extensions/azure.ai.finetune/internal/cmd/operations.go @@ -10,16 +10,19 @@ import ( "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" + "azure.ai.finetune/internal/services" JobWrapper "azure.ai.finetune/internal/tools" ) func newOperationCommand() *cobra.Command { cmd := &cobra.Command{ - Use: "jobs", + Use: "jobs", + PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { + return validateEnvironment(cmd.Context()) + }, Short: "Manage fine-tuning jobs", } @@ -253,12 +256,13 @@ func newOperationShowCommand() *cobra.Command { return cmd } +// newOperationListCommand creates a command to list fine-tuning jobs func newOperationListCommand() *cobra.Command { - var top int + var limit int var after string cmd := &cobra.Command{ Use: "list", - Short: "List the fine tuning jobs", + Short: "list the fine tuning jobs", RunE: func(cmd *cobra.Command, args []string) error { ctx := azdext.WithAccessToken(cmd.Context()) azdClient, err := azdext.NewAzdClient() @@ -269,32 +273,38 @@ func newOperationListCommand() *cobra.Command { // Show spinner while fetching jobs spinner := ux.NewSpinner(&ux.SpinnerOptions{ - Text: "Fetching fine-tuning jobs...", + Text: "fetching fine-tuning jobs...", }) if err := spinner.Start(ctx); err != nil { - fmt.Printf("Failed to start spinner: %v\n", err) + 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) + fineTuneSvc, err := services.NewFineTuningService(ctx, azdClient, nil) + if err != nil { + _ = spinner.Stop(ctx) + fmt.Println() + return err + } + jobs, err := fineTuneSvc.ListFineTuningJobs(ctx, limit, after) + _ = spinner.Stop(ctx) if err != nil { - return fmt.Errorf("failed to list fine-tuning jobs: %w", err) + fmt.Println() + return 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) + i+1, job.ID, getStatusSymbol(string(job.Status)), job.Status, job.BaseModel, formatFineTunedModel(job.FineTunedModel), job.CreatedAt) } - fmt.Printf("\nTotal jobs: %d\n", len(jobs)) + 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") + cmd.Flags().IntVarP(&limit, "top", "t", 50, "number of fine-tuning jobs to list") + cmd.Flags().StringVarP(&after, "after", "a", "", "cursor for pagination") return cmd } diff --git a/cli/azd/extensions/azure.ai.finetune/internal/cmd/root.go b/cli/azd/extensions/azure.ai.finetune/internal/cmd/root.go index bc424cfd67b..c82febf8804 100644 --- a/cli/azd/extensions/azure.ai.finetune/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.finetune/internal/cmd/root.go @@ -34,21 +34,19 @@ func NewRootCommand() *cobra.Command { "Enable debug mode", ) - // Adds support for `--no-prompt` global flag in azd - // Without this the extension command will error when the flag is provided + // 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.", + "accepts the default value instead of prompting, or 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/validation.go b/cli/azd/extensions/azure.ai.finetune/internal/cmd/validation.go new file mode 100644 index 00000000000..ab9fdca036a --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/internal/cmd/validation.go @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + + "azure.ai.finetune/internal/utils" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +func validateEnvironment(ctx context.Context) error { + ctx = azdext.WithAccessToken(ctx) + + azdClient, err := azdext.NewAzdClient() + if err != nil { + return err + } + defer azdClient.Close() + + envValues, _ := utils.GetEnvironmentValues(ctx, azdClient) + required := []string{utils.EnvAzureTenantID, utils.EnvAzureSubscriptionID, utils.EnvAzureLocation, utils.EnvAzureAccountName} + + for _, varName := range required { + if envValues[varName] == "" { + return fmt.Errorf("required environment variables not set. Please run 'azd ai finetune init' command to configure your environment") + } + } + return 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 index c61a74bf6ea..2d67a6f2e7e 100644 --- a/cli/azd/extensions/azure.ai.finetune/internal/providers/azure/provider.go +++ b/cli/azd/extensions/azure.ai.finetune/internal/providers/azure/provider.go @@ -6,30 +6,19 @@ 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) + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices" ) // 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 + clientFactory *armcognitiveservices.ClientFactory } // NewAzureProvider creates a new Azure provider instance -func NewAzureProvider(endpoint, apiKey string) *AzureProvider { +func NewAzureProvider(clientFactory *armcognitiveservices.ClientFactory) *AzureProvider { return &AzureProvider{ - endpoint: endpoint, - apiKey: apiKey, + clientFactory: clientFactory, } } diff --git a/cli/azd/extensions/azure.ai.finetune/internal/providers/factory/provider_factory.go b/cli/azd/extensions/azure.ai.finetune/internal/providers/factory/provider_factory.go new file mode 100644 index 00000000000..0294287189e --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/internal/providers/factory/provider_factory.go @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package factory + +import ( + "context" + "fmt" + + "azure.ai.finetune/internal/providers" + azureprovider "azure.ai.finetune/internal/providers/azure" + openaiprovider "azure.ai.finetune/internal/providers/openai" + "azure.ai.finetune/internal/utils" + "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/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/openai" +) + +func GetOpenAIClientFromAzdClient(ctx context.Context, azdClient *azdext.AzdClient) (*openai.Client, error) { + envValueMap, err := utils.GetEnvironmentValues(ctx, azdClient) + if err != nil { + return nil, fmt.Errorf("failed to get environment values: %w", err) + } + + azureContext := &azdext.AzureContext{ + Scope: &azdext.AzureScope{ + TenantId: envValueMap[utils.EnvAzureTenantID], + SubscriptionId: envValueMap[utils.EnvAzureSubscriptionID], + Location: envValueMap[utils.EnvAzureLocation], + }, + 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[utils.EnvAzureAccountName] + endpoint := fmt.Sprintf(azureCognitiveServicesEndpoint, accountName) + // Create OpenAI client + client := openai.NewClient( + //azure.WithEndpoint(endpoint, apiVersion), + option.WithBaseURL(endpoint), + option.WithQuery("api-version", apiVersion), + azure.WithTokenCredential(credential), + ) + return &client, nil +} + +// NewFineTuningProvider creates a FineTuningProvider based on provider type +func NewFineTuningProvider(ctx context.Context, azdClient *azdext.AzdClient) (providers.FineTuningProvider, error) { + client, err := GetOpenAIClientFromAzdClient(ctx, azdClient) + return openaiprovider.NewOpenAIProvider(client), err +} + +// NewModelDeploymentProvider creates a ModelDeploymentProvider based on provider type +func NewModelDeploymentProvider(subscriptionId string, credential azcore.TokenCredential) (providers.ModelDeploymentProvider, error) { + clientFactory, err := armcognitiveservices.NewClientFactory( + subscriptionId, + credential, + nil, + ) + if err != nil { + return nil, fmt.Errorf("failed to create armcognitiveservices client factory: %w", err) + } + return azureprovider.NewAzureProvider(clientFactory), err +} diff --git a/cli/azd/extensions/azure.ai.finetune/internal/providers/openai/conversions.go b/cli/azd/extensions/azure.ai.finetune/internal/providers/openai/conversions.go new file mode 100644 index 00000000000..bcf0ccdd9e7 --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/internal/providers/openai/conversions.go @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package openai + +import ( + "azure.ai.finetune/internal/utils" + "azure.ai.finetune/pkg/models" + "github.com/openai/openai-go/v3" +) + +// OpenAI Status Constants - matches OpenAI SDK values +const ( + OpenAIStatusValidatingFiles = "validating_files" + OpenAIStatusQueued = "queued" + OpenAIStatusRunning = "running" + OpenAIStatusSucceeded = "succeeded" + OpenAIStatusFailed = "failed" + OpenAIStatusCancelled = "cancelled" +) + +// mapOpenAIStatusToJobStatus converts OpenAI SDK status to domain model JobStatus +func mapOpenAIStatusToJobStatus(openaiStatus openai.FineTuningJobStatus) models.JobStatus { + switch openaiStatus { + case OpenAIStatusValidatingFiles, OpenAIStatusRunning: + return models.StatusRunning + case OpenAIStatusQueued: + return models.StatusQueued + case OpenAIStatusSucceeded: + return models.StatusSucceeded + case OpenAIStatusFailed: + return models.StatusFailed + case OpenAIStatusCancelled: + return models.StatusCancelled + default: + return models.StatusPending // Default fallback + } +} + +// convertOpenAIJobToModel converts OpenAI SDK job to domain model +func convertOpenAIJobToModel(openaiJob openai.FineTuningJob) *models.FineTuningJob { + return &models.FineTuningJob{ + ID: openaiJob.ID, + Status: mapOpenAIStatusToJobStatus(openaiJob.Status), + BaseModel: openaiJob.Model, + FineTunedModel: openaiJob.FineTunedModel, + CreatedAt: utils.UnixTimestampToUTC(openaiJob.CreatedAt), + } +} 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 index 3edad0f6b35..5de45c00feb 100644 --- a/cli/azd/extensions/azure.ai.finetune/internal/providers/openai/provider.go +++ b/cli/azd/extensions/azure.ai.finetune/internal/providers/openai/provider.go @@ -6,29 +6,19 @@ 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) + "github.com/openai/openai-go/v3" ) // OpenAIProvider implements the provider interface for OpenAI APIs type OpenAIProvider struct { - // TODO: Add OpenAI SDK client - // client *openai.Client - apiKey string - endpoint string + client *openai.Client } // NewOpenAIProvider creates a new OpenAI provider instance -func NewOpenAIProvider(apiKey, endpoint string) *OpenAIProvider { +func NewOpenAIProvider(client *openai.Client) *OpenAIProvider { return &OpenAIProvider{ - apiKey: apiKey, - endpoint: endpoint, + client: client, } } @@ -49,8 +39,22 @@ func (p *OpenAIProvider) GetFineTuningStatus(ctx context.Context, jobID string) // 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 + jobList, err := p.client.FineTuning.Jobs.List(ctx, openai.FineTuningJobListParams{ + Limit: openai.Int(int64(limit)), // optional pagination control + After: openai.String(after), + }) + + if err != nil { + return nil, err + } + + var jobs []*models.FineTuningJob + + for _, job := range jobList.Data { + finetuningJob := convertOpenAIJobToModel(job) + jobs = append(jobs, finetuningJob) + } + return jobs, nil } // GetFineTuningJobDetails retrieves detailed information about a job 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 index 67f05d95d40..03ae59d508f 100644 --- a/cli/azd/extensions/azure.ai.finetune/internal/services/finetune_service.go +++ b/cli/azd/extensions/azure.ai.finetune/internal/services/finetune_service.go @@ -5,9 +5,13 @@ package services import ( "context" + "fmt" "azure.ai.finetune/internal/providers" + "azure.ai.finetune/internal/providers/factory" + "azure.ai.finetune/internal/utils" "azure.ai.finetune/pkg/models" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" ) // Ensure fineTuningServiceImpl implements FineTuningService interface @@ -15,16 +19,23 @@ var _ FineTuningService = (*fineTuningServiceImpl)(nil) // fineTuningServiceImpl implements the FineTuningService interface type fineTuningServiceImpl struct { + azdClient *azdext.AzdClient provider providers.FineTuningProvider stateStore StateStore } // NewFineTuningService creates a new instance of FineTuningService -func NewFineTuningService(provider providers.FineTuningProvider, stateStore StateStore) FineTuningService { +func NewFineTuningService(ctx context.Context, azdClient *azdext.AzdClient, stateStore StateStore) (FineTuningService, error) { + provider, err := factory.NewFineTuningProvider(ctx, azdClient) + if err != nil { + return nil, fmt.Errorf("failed to initialize fine-tuning service: %w", err) + } + return &fineTuningServiceImpl{ + azdClient: azdClient, provider: provider, stateStore: stateStore, - } + }, nil } // CreateFineTuningJob creates a new fine-tuning job with business validation @@ -46,8 +57,20 @@ func (s *fineTuningServiceImpl) GetFineTuningStatus(ctx context.Context, jobID s // 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 + var jobs []*models.FineTuningJob + + // Use retry utility for list operation + err := utils.RetryOperation(ctx, utils.DefaultRetryConfig(), func() error { + var err error + jobs, err = s.provider.ListFineTuningJobs(ctx, limit, after) + return err + }) + + if err != nil { + return nil, fmt.Errorf("failed to list fine-tuning jobs: %w", err) + } + + return jobs, nil } // GetFineTuningJobDetails retrieves detailed information about a job 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 index 5dc9c034857..7aa35e223c8 100644 --- a/cli/azd/extensions/azure.ai.finetune/internal/tools/job_wrapper.go +++ b/cli/azd/extensions/azure.ai.finetune/internal/tools/job_wrapper.go @@ -110,42 +110,6 @@ type CheckpointsListContract struct { 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) diff --git a/cli/azd/extensions/azure.ai.finetune/internal/utils/environment.go b/cli/azd/extensions/azure.ai.finetune/internal/utils/environment.go new file mode 100644 index 00000000000..6e5b3c81204 --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/internal/utils/environment.go @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package utils + +import ( + "context" + "fmt" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +const ( + EnvAzureTenantID = "AZURE_TENANT_ID" + EnvAzureSubscriptionID = "AZURE_SUBSCRIPTION_ID" + EnvAzureLocation = "AZURE_LOCATION" + EnvAzureAccountName = "AZURE_ACCOUNT_NAME" +) + +// GetEnvironmentValues retrieves Azure environment configuration from azd client. +// Returns empty map if environment cannot be accessed. +func GetEnvironmentValues(ctx context.Context, azdClient *azdext.AzdClient) (map[string]string, error) { + envValueMap := make(map[string]string) + + envResponse, err := azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}) + if err != nil { + return envValueMap, fmt.Errorf("failed to get current environment: %w", err) + } + env := envResponse.Environment + + envValues, err := azdClient.Environment().GetValues(ctx, &azdext.GetEnvironmentRequest{ + Name: env.Name, + }) + if err != nil { + return envValueMap, fmt.Errorf("failed to get environment values: %w", err) + } + + for _, value := range envValues.KeyValues { + envValueMap[value.Key] = value.Value + } + + return envValueMap, nil +} diff --git a/cli/azd/extensions/azure.ai.finetune/internal/utils/retry.go b/cli/azd/extensions/azure.ai.finetune/internal/utils/retry.go new file mode 100644 index 00000000000..7ed7465d158 --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/internal/utils/retry.go @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package utils + +import ( + "context" + "fmt" + "time" +) + +const ( + // DefaultMaxAttempts is the default number of retry attempts + DefaultMaxAttempts = 3 + // DefaultDelaySeconds is the default initial delay in seconds + DefaultDelaySeconds = 2 +) + +// RetryConfig holds configuration for retry operations +type RetryConfig struct { + MaxAttempts int + Delay time.Duration + BackoffFunc func(attempt int, delay time.Duration) time.Duration +} + +// DefaultRetryConfig returns a sensible default retry configuration +func DefaultRetryConfig() *RetryConfig { + return &RetryConfig{ + MaxAttempts: DefaultMaxAttempts, + Delay: DefaultDelaySeconds * time.Second, + BackoffFunc: func(attempt int, delay time.Duration) time.Duration { + // Exponential backoff: 2s, 4s, 8s + return delay * time.Duration(1<<(attempt-1)) + }, + } +} + +// RetryOperation executes the given operation with retry logic +// The operation should return an error if it should be retried +func RetryOperation(ctx context.Context, config *RetryConfig, operation func() error) error { + if config == nil { + config = DefaultRetryConfig() + } + + var lastErr error + + for attempt := 1; attempt <= config.MaxAttempts; attempt++ { + // Execute the operation + err := operation() + if err == nil { + return nil // Success! + } + + lastErr = err + + // If this was the last attempt, don't wait + if attempt == config.MaxAttempts { + break + } + + // Calculate delay for this attempt + delay := config.BackoffFunc(attempt, config.Delay) + + // Wait before retrying, respecting context cancellation + select { + case <-time.After(delay): + // Continue to next attempt + case <-ctx.Done(): + return fmt.Errorf("operation cancelled: %w", ctx.Err()) + } + } + + return lastErr +} diff --git a/cli/azd/extensions/azure.ai.finetune/internal/utils/time.go b/cli/azd/extensions/azure.ai.finetune/internal/utils/time.go new file mode 100644 index 00000000000..05af770fafb --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/internal/utils/time.go @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package utils + +import ( + "time" +) + +// UnixTimestampToUTC converts a Unix timestamp (seconds since epoch) to a UTC time.Time. +// Returns zero time.Time if timestamp is 0. +func UnixTimestampToUTC(timestamp int64) time.Time { + if timestamp == 0 { + return time.Time{} + } + return time.Unix(timestamp, 0).UTC() +} From 4e01756b37f4d26d841a47f336716be2cdeb4051 Mon Sep 17 00:00:00 2001 From: saanikaguptamicrosoft Date: Fri, 2 Jan 2026 14:49:53 +0530 Subject: [PATCH 03/32] Refactoring jobs show command to align with the LLD (#6434) --- .../internal/cmd/operations.go | 89 +++++++++++-------- .../internal/providers/azure/provider.go | 4 +- .../internal/providers/interface.go | 4 +- .../internal/providers/openai/conversions.go | 76 +++++++++++++++- .../internal/providers/openai/provider.go | 44 +++++++-- .../internal/services/finetune_service.go | 55 ++++++++++-- .../internal/services/interface.go | 4 +- .../internal/tools/job_wrapper.go | 85 ------------------ .../internal/utils/status.go | 26 ++++++ .../azure.ai.finetune/internal/utils/time.go | 12 +++ .../azure.ai.finetune/pkg/models/finetune.go | 14 ++- 11 files changed, 267 insertions(+), 146 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.finetune/internal/utils/status.go diff --git a/cli/azd/extensions/azure.ai.finetune/internal/cmd/operations.go b/cli/azd/extensions/azure.ai.finetune/internal/cmd/operations.go index 9c2ce3d6894..bfb574eb7db 100644 --- a/cli/azd/extensions/azure.ai.finetune/internal/cmd/operations.go +++ b/cli/azd/extensions/azure.ai.finetune/internal/cmd/operations.go @@ -7,14 +7,17 @@ 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" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/ux" + FTYaml "azure.ai.finetune/internal/fine_tuning_yaml" "azure.ai.finetune/internal/services" JobWrapper "azure.ai.finetune/internal/tools" + "azure.ai.finetune/internal/utils" + "azure.ai.finetune/pkg/models" ) func newOperationCommand() *cobra.Command { @@ -35,8 +38,8 @@ func newOperationCommand() *cobra.Command { return cmd } -// getStatusSymbol returns a symbol representation for job status -func getStatusSymbol(status string) string { +// getStatusSymbolFromString returns a symbol representation for job status +func getStatusSymbolFromString(status string) string { switch status { case "pending": return "⌛" @@ -139,12 +142,13 @@ func newOperationSubmitCommand() *cobra.Command { return cmd } +// newOperationShowCommand creates a command to show the fine-tuning job details func newOperationShowCommand() *cobra.Command { var jobID string cmd := &cobra.Command{ Use: "show", - Short: "Show the fine tuning job details", + Short: "Show fine-tuning job details.", RunE: func(cmd *cobra.Command, args []string) error { ctx := azdext.WithAccessToken(cmd.Context()) azdClient, err := azdext.NewAzdClient() @@ -152,31 +156,38 @@ func newOperationShowCommand() *cobra.Command { return fmt.Errorf("failed to create azd client: %w", err) } defer azdClient.Close() - // Show spinner while fetching jobs + + // Show spinner while fetching job 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) + 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) + fineTuneSvc, err := services.NewFineTuningService(ctx, azdClient, nil) + if err != nil { + _ = spinner.Stop(ctx) + fmt.Println() + return err + } + job, err := fineTuneSvc.GetFineTuningJobDetails(ctx, jobID) + _ = spinner.Stop(ctx) if err != nil { - return fmt.Errorf("failed to get fine-tuning job details: %w", err) + fmt.Println() + return 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) + // Display job details + color.Green("\nFine-tuning Job Details\n") + fmt.Printf("Job ID: %s\n", job.ID) + fmt.Printf("Status: %s %s\n", utils.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("Created At: %s\n", utils.FormatTime(job.CreatedAt)) + if !job.FinishedAt.IsZero() { + fmt.Printf("Finished At: %s\n", utils.FormatTime(job.FinishedAt)) } fmt.Printf("Method: %s\n", job.Method) fmt.Printf("Training File: %s\n", job.TrainingFile) @@ -197,18 +208,20 @@ func newOperationShowCommand() *cobra.Command { Text: "Fetching job events...", }) if err := eventsSpinner.Start(ctx); err != nil { - fmt.Printf("Failed to start spinner: %v\n", err) + fmt.Printf("failed to start spinner: %v\n", err) } - events, err := JobWrapper.GetJobEvents(ctx, azdClient, jobID) + events, err := fineTuneSvc.GetJobEvents(ctx, jobID) _ = eventsSpinner.Stop(ctx) if err != nil { - fmt.Printf("Warning: failed to fetch job events: %v\n", err) + fmt.Println() + return 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) + fmt.Printf(" %d. Event ID: %s\n", i+1, event.ID) + fmt.Printf(" [%s] %s - %s\n", event.Level, utils.FormatTime(event.CreatedAt), event.Message) } if events.HasMore { fmt.Println(" ... (more events available)") @@ -216,25 +229,26 @@ func newOperationShowCommand() *cobra.Command { } // Fetch and print checkpoints if job is completed - if job.Status == "succeeded" { + if job.Status == models.StatusSucceeded { 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) + fmt.Printf("failed to start spinner: %v\n", err) } - checkpoints, err := JobWrapper.GetJobCheckPoints(ctx, azdClient, jobID) + checkpoints, err := fineTuneSvc.GetJobCheckpoints(ctx, jobID) _ = checkpointsSpinner.Stop(ctx) if err != nil { - fmt.Printf("Warning: failed to fetch job checkpoints: %v\n", err) + fmt.Println() + return 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(" Created On: %s\n", utils.FormatTime(checkpoint.CreatedAt)) fmt.Printf(" Step Number: %d\n", checkpoint.StepNumber) if checkpoint.Metrics != nil { fmt.Printf(" Full Validation Loss: %.6f\n", checkpoint.Metrics.FullValidLoss) @@ -251,8 +265,10 @@ func newOperationShowCommand() *cobra.Command { return nil }, } + cmd.Flags().StringVarP(&jobID, "job-id", "i", "", "Fine-tuning job ID") cmd.MarkFlagRequired("job-id") + return cmd } @@ -262,7 +278,7 @@ func newOperationListCommand() *cobra.Command { var after string cmd := &cobra.Command{ Use: "list", - Short: "list the fine tuning jobs", + Short: "List fine-tuning jobs.", RunE: func(cmd *cobra.Command, args []string) error { ctx := azdext.WithAccessToken(cmd.Context()) azdClient, err := azdext.NewAzdClient() @@ -273,7 +289,7 @@ func newOperationListCommand() *cobra.Command { // Show spinner while fetching jobs spinner := ux.NewSpinner(&ux.SpinnerOptions{ - Text: "fetching fine-tuning jobs...", + Text: "Fetching fine-tuning jobs...", }) if err := spinner.Start(ctx); err != nil { fmt.Printf("failed to start spinner: %v\n", err) @@ -289,22 +305,25 @@ func newOperationListCommand() *cobra.Command { jobs, err := fineTuneSvc.ListFineTuningJobs(ctx, limit, after) _ = spinner.Stop(ctx) if err != nil { - fmt.Println() + fmt.Println() return err } + // Display job list 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(string(job.Status)), job.Status, job.BaseModel, formatFineTunedModel(job.FineTunedModel), job.CreatedAt) + i+1, job.ID, utils.GetStatusSymbol(job.Status), job.Status, job.BaseModel, + formatFineTunedModel(job.FineTunedModel), utils.FormatTime(job.CreatedAt)) } - fmt.Printf("\ntotal jobs: %d\n", len(jobs)) + fmt.Printf("\nTotal jobs: %d\n", len(jobs)) return nil }, } - cmd.Flags().IntVarP(&limit, "top", "t", 50, "number of fine-tuning jobs to list") - cmd.Flags().StringVarP(&after, "after", "a", "", "cursor for pagination") + + cmd.Flags().IntVarP(&limit, "top", "t", 50, "Number of fine-tuning jobs to list") + cmd.Flags().StringVarP(&after, "after", "a", "", "Cursor for pagination") return cmd } @@ -361,7 +380,7 @@ func newOperationActionCommand() *cobra.Command { 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("Status: %s %s\n", getStatusSymbolFromString(job.Status), job.Status) fmt.Printf("Created: %s\n", job.CreatedAt) if job.FineTunedModel != "" { fmt.Printf("Fine-tuned: %s\n", job.FineTunedModel) 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 index 2d67a6f2e7e..46fdd92e358 100644 --- a/cli/azd/extensions/azure.ai.finetune/internal/providers/azure/provider.go +++ b/cli/azd/extensions/azure.ai.finetune/internal/providers/azure/provider.go @@ -50,13 +50,13 @@ func (p *AzureProvider) GetFineTuningJobDetails(ctx context.Context, jobID strin } // GetJobEvents retrieves events for a fine-tuning job -func (p *AzureProvider) GetJobEvents(ctx context.Context, jobID string, limit int, after string) ([]*models.JobEvent, error) { +func (p *AzureProvider) GetJobEvents(ctx context.Context, jobID string, limit int, after string) (*models.JobEventsList, 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) { +func (p *AzureProvider) GetJobCheckpoints(ctx context.Context, jobID string, limit int, after string) (*models.JobCheckpointsList, error) { // TODO: Implement return nil, 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 index e0f935d88b0..d2aeb2df163 100644 --- a/cli/azd/extensions/azure.ai.finetune/internal/providers/interface.go +++ b/cli/azd/extensions/azure.ai.finetune/internal/providers/interface.go @@ -25,10 +25,10 @@ type FineTuningProvider interface { 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) + GetJobEvents(ctx context.Context, jobID string) (*models.JobEventsList, error) // GetJobCheckpoints retrieves checkpoints for a fine-tuning job - GetJobCheckpoints(ctx context.Context, jobID string, limit int, after string) ([]*models.JobCheckpoint, error) + GetJobCheckpoints(ctx context.Context, jobID string) (*models.JobCheckpointsList, error) // PauseJob pauses a fine-tuning job PauseJob(ctx context.Context, jobID string) (*models.FineTuningJob, error) diff --git a/cli/azd/extensions/azure.ai.finetune/internal/providers/openai/conversions.go b/cli/azd/extensions/azure.ai.finetune/internal/providers/openai/conversions.go index bcf0ccdd9e7..914524f0069 100644 --- a/cli/azd/extensions/azure.ai.finetune/internal/providers/openai/conversions.go +++ b/cli/azd/extensions/azure.ai.finetune/internal/providers/openai/conversions.go @@ -4,9 +4,11 @@ package openai import ( + "github.com/openai/openai-go/v3" + "github.com/openai/openai-go/v3/packages/pagination" + "azure.ai.finetune/internal/utils" "azure.ai.finetune/pkg/models" - "github.com/openai/openai-go/v3" ) // OpenAI Status Constants - matches OpenAI SDK values @@ -47,3 +49,75 @@ func convertOpenAIJobToModel(openaiJob openai.FineTuningJob) *models.FineTuningJ CreatedAt: utils.UnixTimestampToUTC(openaiJob.CreatedAt), } } + +// convertOpenAIJobToDetailModel converts OpenAI SDK job to detailed domain model +func convertOpenAIJobToDetailModel(openaiJob *openai.FineTuningJob) *models.FineTuningJobDetail { + // Extract hyperparameters from OpenAI job + hyperparameters := &models.Hyperparameters{} + hyperparameters.BatchSize = openaiJob.Hyperparameters.BatchSize.OfInt + hyperparameters.LearningRateMultiplier = openaiJob.Hyperparameters.LearningRateMultiplier.OfFloat + hyperparameters.NEpochs = openaiJob.Hyperparameters.NEpochs.OfInt + + jobDetail := &models.FineTuningJobDetail{ + ID: openaiJob.ID, + Status: mapOpenAIStatusToJobStatus(openaiJob.Status), + Model: openaiJob.Model, + FineTunedModel: openaiJob.FineTunedModel, + CreatedAt: utils.UnixTimestampToUTC(openaiJob.CreatedAt), + FinishedAt: utils.UnixTimestampToUTC(openaiJob.FinishedAt), + Method: openaiJob.Method.Type, + TrainingFile: openaiJob.TrainingFile, + ValidationFile: openaiJob.ValidationFile, + Hyperparameters: hyperparameters, + } + + return jobDetail +} + +// convertOpenAIJobEventsToModel converts OpenAI SDK job events to domain model +func convertOpenAIJobEventsToModel(eventsPage *pagination.CursorPage[openai.FineTuningJobEvent]) *models.JobEventsList { + var events []models.JobEvent + for _, event := range eventsPage.Data { + jobEvent := models.JobEvent{ + ID: event.ID, + CreatedAt: utils.UnixTimestampToUTC(event.CreatedAt), + Level: string(event.Level), + Message: event.Message, + Data: event.Data, + Type: string(event.Type), + } + events = append(events, jobEvent) + } + + return &models.JobEventsList{ + Data: events, + HasMore: eventsPage.HasMore, + } +} + +// convertOpenAIJobCheckpointsToModel converts OpenAI SDK job checkpoints to domain model +func convertOpenAIJobCheckpointsToModel(checkpointsPage *pagination.CursorPage[openai.FineTuningJobCheckpoint]) *models.JobCheckpointsList { + var checkpoints []models.JobCheckpoint + + for _, checkpoint := range checkpointsPage.Data { + metrics := &models.CheckpointMetrics{ + FullValidLoss: checkpoint.Metrics.FullValidLoss, + FullValidMeanTokenAccuracy: checkpoint.Metrics.FullValidMeanTokenAccuracy, + } + + jobCheckpoint := models.JobCheckpoint{ + ID: checkpoint.ID, + CreatedAt: utils.UnixTimestampToUTC(checkpoint.CreatedAt), + FineTunedModelCheckpoint: checkpoint.FineTunedModelCheckpoint, + Metrics: metrics, + FineTuningJobID: checkpoint.FineTuningJobID, + StepNumber: checkpoint.StepNumber, + } + checkpoints = append(checkpoints, jobCheckpoint) + } + + return &models.JobCheckpointsList{ + Data: checkpoints, + HasMore: checkpointsPage.HasMore, + } +} 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 index 5de45c00feb..f0e99e0a19a 100644 --- a/cli/azd/extensions/azure.ai.finetune/internal/providers/openai/provider.go +++ b/cli/azd/extensions/azure.ai.finetune/internal/providers/openai/provider.go @@ -6,8 +6,9 @@ package openai import ( "context" - "azure.ai.finetune/pkg/models" "github.com/openai/openai-go/v3" + + "azure.ai.finetune/pkg/models" ) // OpenAIProvider implements the provider interface for OpenAI APIs @@ -54,25 +55,50 @@ func (p *OpenAIProvider) ListFineTuningJobs(ctx context.Context, limit int, afte finetuningJob := convertOpenAIJobToModel(job) jobs = append(jobs, finetuningJob) } + return jobs, 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 + job, err := p.client.FineTuning.Jobs.Get(ctx, jobID) + if err != nil { + return nil, err + } + finetuningJobDetail := convertOpenAIJobToDetailModel(job) + + return finetuningJobDetail, 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 +func (p *OpenAIProvider) GetJobEvents(ctx context.Context, jobID string) (*models.JobEventsList, error) { + eventsPage, err := p.client.FineTuning.Jobs.ListEvents( + ctx, + jobID, + openai.FineTuningJobListEventsParams{}, + ) + if err != nil { + return nil, err + } + + events := convertOpenAIJobEventsToModel(eventsPage) + + return events, 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 +func (p *OpenAIProvider) GetJobCheckpoints(ctx context.Context, jobID string) (*models.JobCheckpointsList, error) { + checkpointsPage, err := p.client.FineTuning.Jobs.Checkpoints.List( + ctx, + jobID, + openai.FineTuningJobCheckpointListParams{}, + ) + if err != nil { + return nil, err + } + checkpoints := convertOpenAIJobCheckpointsToModel(checkpointsPage) + + return checkpoints, nil } // PauseJob pauses a fine-tuning job 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 index 03ae59d508f..054583eee5c 100644 --- a/cli/azd/extensions/azure.ai.finetune/internal/services/finetune_service.go +++ b/cli/azd/extensions/azure.ai.finetune/internal/services/finetune_service.go @@ -7,11 +7,12 @@ import ( "context" "fmt" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "azure.ai.finetune/internal/providers" "azure.ai.finetune/internal/providers/factory" "azure.ai.finetune/internal/utils" "azure.ai.finetune/pkg/models" - "github.com/azure/azure-dev/cli/azd/pkg/azdext" ) // Ensure fineTuningServiceImpl implements FineTuningService interface @@ -75,20 +76,56 @@ func (s *fineTuningServiceImpl) ListFineTuningJobs(ctx context.Context, limit in // GetFineTuningJobDetails retrieves detailed information about a job func (s *fineTuningServiceImpl) GetFineTuningJobDetails(ctx context.Context, jobID string) (*models.FineTuningJobDetail, error) { - // TODO: Implement - return nil, nil + var jobDetail *models.FineTuningJobDetail + + // Use retry utility for job detail operation + err := utils.RetryOperation(ctx, utils.DefaultRetryConfig(), func() error { + var err error + jobDetail, err = s.provider.GetFineTuningJobDetails(ctx, jobID) + return err + }) + + if err != nil { + return nil, fmt.Errorf("failed to get job details: %w", err) + } + + return jobDetail, 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 +func (s *fineTuningServiceImpl) GetJobEvents(ctx context.Context, jobID string) (*models.JobEventsList, error) { + var eventsList *models.JobEventsList + + // Use retry utility for job events operation + err := utils.RetryOperation(ctx, utils.DefaultRetryConfig(), func() error { + var err error + eventsList, err = s.provider.GetJobEvents(ctx, jobID) + return err + }) + + if err != nil { + return nil, fmt.Errorf("failed to get job events: %w", err) + } + + return eventsList, 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 +func (s *fineTuningServiceImpl) GetJobCheckpoints(ctx context.Context, jobID string) (*models.JobCheckpointsList, error) { + var checkpointList *models.JobCheckpointsList + + // Use retry utility for job checkpoints operation + err := utils.RetryOperation(ctx, utils.DefaultRetryConfig(), func() error { + var err error + checkpointList, err = s.provider.GetJobCheckpoints(ctx, jobID) + return err + }) + + if err != nil { + return nil, fmt.Errorf("failed to get job checkpoints: %w", err) + } + + return checkpointList, nil } // PauseJob pauses a running job (if applicable) diff --git a/cli/azd/extensions/azure.ai.finetune/internal/services/interface.go b/cli/azd/extensions/azure.ai.finetune/internal/services/interface.go index e2b0d63c7e5..c4d20d13c9d 100644 --- a/cli/azd/extensions/azure.ai.finetune/internal/services/interface.go +++ b/cli/azd/extensions/azure.ai.finetune/internal/services/interface.go @@ -24,10 +24,10 @@ type FineTuningService interface { 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) + GetJobEvents(ctx context.Context, jobID string) (*models.JobEventsList, error) // GetJobCheckpoints retrieves checkpoints for a job with pagination - GetJobCheckpoints(ctx context.Context, jobID string, limit int, after string) ([]*models.JobCheckpoint, error) + GetJobCheckpoints(ctx context.Context, jobID string) (*models.JobCheckpointsList, error) // PauseJob pauses a running job (if applicable) PauseJob(ctx context.Context, jobID string) (*models.FineTuningJob, error) 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 index 7aa35e223c8..9b760df9cb8 100644 --- a/cli/azd/extensions/azure.ai.finetune/internal/tools/job_wrapper.go +++ b/cli/azd/extensions/azure.ai.finetune/internal/tools/job_wrapper.go @@ -196,91 +196,6 @@ func GetJobDetails(ctx context.Context, azdClient *azdext.AzdClient, jobId strin 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) diff --git a/cli/azd/extensions/azure.ai.finetune/internal/utils/status.go b/cli/azd/extensions/azure.ai.finetune/internal/utils/status.go new file mode 100644 index 00000000000..25e961dbcde --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/internal/utils/status.go @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package utils + +import "azure.ai.finetune/pkg/models" + +// getStatusSymbol returns a symbol representation for job status +func GetStatusSymbol(status models.JobStatus) string { + switch status { + case models.StatusPending: + return "⌛" + case models.StatusQueued: + return "📚" + case models.StatusRunning: + return "🔄" + case models.StatusSucceeded: + return "✅" + case models.StatusFailed: + return "💥" + case models.StatusCancelled: + return "❌" + default: + return "❓" + } +} diff --git a/cli/azd/extensions/azure.ai.finetune/internal/utils/time.go b/cli/azd/extensions/azure.ai.finetune/internal/utils/time.go index 05af770fafb..52b9d378333 100644 --- a/cli/azd/extensions/azure.ai.finetune/internal/utils/time.go +++ b/cli/azd/extensions/azure.ai.finetune/internal/utils/time.go @@ -7,6 +7,9 @@ import ( "time" ) +// TimeFormat defines the standard time format used for display output +const TimeFormat = "2006-01-02 15:04:05 UTC" + // UnixTimestampToUTC converts a Unix timestamp (seconds since epoch) to a UTC time.Time. // Returns zero time.Time if timestamp is 0. func UnixTimestampToUTC(timestamp int64) time.Time { @@ -15,3 +18,12 @@ func UnixTimestampToUTC(timestamp int64) time.Time { } return time.Unix(timestamp, 0).UTC() } + +// FormatTime formats a time.Time to the standard display format. +// Returns empty string if time is zero. +func FormatTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Format(TimeFormat) +} diff --git a/cli/azd/extensions/azure.ai.finetune/pkg/models/finetune.go b/cli/azd/extensions/azure.ai.finetune/pkg/models/finetune.go index a8b6b11b237..9a943580c52 100644 --- a/cli/azd/extensions/azure.ai.finetune/pkg/models/finetune.go +++ b/cli/azd/extensions/azure.ai.finetune/pkg/models/finetune.go @@ -70,7 +70,7 @@ type FineTuningJobDetail struct { Model string FineTunedModel string CreatedAt time.Time - FinishedAt *time.Time + FinishedAt time.Time Method string TrainingFile string ValidationFile string @@ -88,6 +88,12 @@ type JobEvent struct { Type string } +// JobEventsList represents a paginated list of job events +type JobEventsList struct { + Data []JobEvent + HasMore bool +} + // JobCheckpoint represents a checkpoint of a fine-tuning job type JobCheckpoint struct { ID string @@ -98,6 +104,12 @@ type JobCheckpoint struct { StepNumber int64 } +// JobCheckpointsList represents a list of job checkpoints +type JobCheckpointsList struct { + Data []JobCheckpoint + HasMore bool +} + // CheckpointMetrics represents metrics for a checkpoint type CheckpointMetrics struct { FullValidLoss float64 From 5db7d484f116a65439eb7fed5eb62e257d286294 Mon Sep 17 00:00:00 2001 From: Zubairuddin Mohammed Date: Mon, 5 Jan 2026 13:10:53 +0530 Subject: [PATCH 04/32] Create command implementation for FineTuning CLI (#6437) * Create command for ft cli * handling null pointer for state * removing changes from old converter and yaml logic * pr review changes + scheme implementation for job params * a few formatting changes * removing ofAuto value for hyperparameters * more formatting changes * adding command line parameters + formatting * minor merge fixes --- .../internal/cmd/operations.go | 100 ++++--- .../internal/providers/openai/conversions.go | 280 ++++++++++++++++++ .../internal/providers/openai/provider.go | 81 ++++- .../internal/services/finetune_service.go | 108 +++++-- .../internal/services/interface.go | 7 +- .../internal/utils/common.go | 18 ++ .../internal/utils/parser.go | 30 ++ .../azure.ai.finetune/pkg/models/finetune.go | 212 ++++++++++++- 8 files changed, 761 insertions(+), 75 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.finetune/internal/utils/common.go create mode 100644 cli/azd/extensions/azure.ai.finetune/internal/utils/parser.go diff --git a/cli/azd/extensions/azure.ai.finetune/internal/cmd/operations.go b/cli/azd/extensions/azure.ai.finetune/internal/cmd/operations.go index bfb574eb7db..e90329f35a8 100644 --- a/cli/azd/extensions/azure.ai.finetune/internal/cmd/operations.go +++ b/cli/azd/extensions/azure.ai.finetune/internal/cmd/operations.go @@ -13,7 +13,6 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/azure/azure-dev/cli/azd/pkg/ux" - FTYaml "azure.ai.finetune/internal/fine_tuning_yaml" "azure.ai.finetune/internal/services" JobWrapper "azure.ai.finetune/internal/tools" "azure.ai.finetune/internal/utils" @@ -68,15 +67,18 @@ func formatFineTunedModel(model string) string { func newOperationSubmitCommand() *cobra.Command { var filename string + var model string + var trainingFile string + var validationFile string + var suffix string + var seed int64 cmd := &cobra.Command{ Use: "submit", - Short: "Submit fine tuning job", + 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") + if filename == "" && (model == "" || trainingFile == "") { + return fmt.Errorf("either config file or model and training-file parameters are required") } azdClient, err := azdext.NewAzdClient() @@ -85,60 +87,88 @@ func newOperationSubmitCommand() *cobra.Command { } 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 + // Show spinner while creating job + spinner := ux.NewSpinner(&ux.SpinnerOptions{ + Text: "creating fine-tuning job...", + }) + if err := spinner.Start(ctx); err != nil { + fmt.Printf("failed to start spinner: %v\n", err) } - // Upload training file + // Parse and validate the YAML configuration file if provided + var config *models.CreateFineTuningRequest + if filename != "" { + color.Green("\nparsing configuration file...") + config, err = utils.ParseCreateFineTuningRequestConfig(filename) + if err != nil { + _ = spinner.Stop(ctx) + fmt.Println() + return err + } + } else { + config = &models.CreateFineTuningRequest{} + } - trainingFileID, err := JobWrapper.UploadFileIfLocal(ctx, azdClient, config.TrainingFile) - if err != nil { - return fmt.Errorf("failed to upload training file: %w", err) + // Override config values with command-line parameters if provided + if model != "" { + config.BaseModel = model } + if trainingFile != "" { - // 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) - } + config.TrainingFile = trainingFile + } + if validationFile != "" { + config.ValidationFile = &validationFile + } + if suffix != "" { + config.Suffix = &suffix + } + if seed != 0 { + config.Seed = &seed } - // Create fine-tuning job - // Convert YAML configuration to OpenAI job parameters - jobParams, err := ConvertYAMLToJobParams(config, trainingFileID, validationFileID) + fineTuneSvc, err := services.NewFineTuningService(ctx, azdClient, nil) if err != nil { - return fmt.Errorf("failed to convert configuration to job parameters: %w", err) + _ = spinner.Stop(ctx) + fmt.Println() + return err } // Submit the fine-tuning job using CreateJob from JobWrapper - job, err := JobWrapper.CreateJob(ctx, azdClient, jobParams) + job, err := fineTuneSvc.CreateFineTuningJob(ctx, config) + _ = spinner.Stop(ctx) + fmt.Println() + 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.Println("\n", strings.Repeat("=", 60)) + color.Green("\nsuccessfully submitted fine-tuning Job!\n") + fmt.Printf("Job ID: %s\n", job.ID) + fmt.Printf("Model: %s\n", job.BaseModel) 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)) - + fmt.Println(strings.Repeat("=", 60)) return nil }, } - cmd.Flags().StringVarP(&filename, "file", "f", "", "Path to the config file") - + cmd.Flags().StringVarP(&filename, "file", "f", "", "Path to the config file.") + cmd.Flags().StringVarP(&model, "model", "m", "", "Base model to fine-tune. Overrides config file. Required if --file is not provided") + cmd.Flags().StringVarP(&trainingFile, "training-file", "t", "", "Training file ID or local path. Use 'local:' prefix for local paths. Required if --file is not provided") + cmd.Flags().StringVarP(&validationFile, "validation-file", "v", "", "Validation file ID or local path. Use 'local:' prefix for local paths.") + cmd.Flags().StringVarP(&suffix, "suffix", "s", "", "An optional string of up to 64 characters that will be added to your fine-tuned model name. Overrides config file.") + cmd.Flags().Int64VarP(&seed, "seed", "r", 0, "Random seed for reproducibility of the job. If a seed is not specified, one will be generated for you. Overrides config file.") + + //Either config file should be provided or at least `model` & `training-file` parameters + cmd.MarkFlagFilename("file", "yaml", "yml") + cmd.MarkFlagsOneRequired("file", "model") + cmd.MarkFlagsRequiredTogether("model", "training-file") return cmd } diff --git a/cli/azd/extensions/azure.ai.finetune/internal/providers/openai/conversions.go b/cli/azd/extensions/azure.ai.finetune/internal/providers/openai/conversions.go index 914524f0069..f27372483f1 100644 --- a/cli/azd/extensions/azure.ai.finetune/internal/providers/openai/conversions.go +++ b/cli/azd/extensions/azure.ai.finetune/internal/providers/openai/conversions.go @@ -4,6 +4,9 @@ package openai import ( + "encoding/json" + "strings" + "github.com/openai/openai-go/v3" "github.com/openai/openai-go/v3/packages/pagination" @@ -121,3 +124,280 @@ func convertOpenAIJobCheckpointsToModel(checkpointsPage *pagination.CursorPage[o HasMore: checkpointsPage.HasMore, } } + +// Converts the internal create finetuning request model to OpenAI job parameters +func convertInternalJobParamToOpenAiJobParams(config *models.CreateFineTuningRequest) (*openai.FineTuningJobNewParams, error) { + jobParams := openai.FineTuningJobNewParams{ + Model: openai.FineTuningJobNewParamsModel(config.BaseModel), + TrainingFile: config.TrainingFile, + } + + if config.ValidationFile != nil && *config.ValidationFile != "" { + jobParams.ValidationFile = openai.String(*config.ValidationFile) + } + + // Set optional fields + if config.Suffix != nil && *config.Suffix != "" { + jobParams.Suffix = openai.String(*config.Suffix) + } + + if config.Seed != nil { + jobParams.Seed = openai.Int(*config.Seed) + } + + // Set metadata if provided + if 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), + } + } + } + + if hp.EvalInterval != nil { + if evalSteps := convertHyperparameterToInt(hp.EvalInterval); evalSteps != nil { + reinforcementMethod.Hyperparameters.EvalInterval = openai.ReinforcementHyperparametersEvalIntervalUnion{ + OfInt: openai.Int(*evalSteps), + } + } + } + + if hp.EvalSamples != nil { + if evalSamples := convertHyperparameterToInt(hp.EvalSamples); evalSamples != nil { + reinforcementMethod.Hyperparameters.EvalSamples = openai.ReinforcementHyperparametersEvalSamplesUnion{ + OfInt: openai.Int(*evalSamples), + } + } + } + + if hp.ReasoningEffort != "" { + reinforcementMethod.Hyperparameters.ReasoningEffort = getReasoningEffortValue(hp.ReasoningEffort) + } + + grader := config.Method.Reinforcement.Grader + if grader != nil { + // Convert grader to JSON and unmarshal to ReinforcementMethodGraderUnionParam + graderJSON, err := json.Marshal(grader) + if err != nil { + return nil, err + } + + var graderUnion openai.ReinforcementMethodGraderUnionParam + err = json.Unmarshal(graderJSON, &graderUnion) + if err != nil { + return nil, err + } + reinforcementMethod.Grader = graderUnion + } + + jobParams.Method = openai.FineTuningJobNewParamsMethod{ + Type: "reinforcement", + Reinforcement: reinforcementMethod, + } + } + + // Set integrations if provided + if len(config.Integrations) > 0 { + var integrations []openai.FineTuningJobNewParamsIntegration + + for _, integration := range config.Integrations { + if integration.Type == "" || integration.Type == "wandb" { + + wandbConfigJSON, err := json.Marshal(integration.Config) + if err != nil { + return nil, err + } + + var wandbConfig openai.FineTuningJobNewParamsIntegrationWandb + err = json.Unmarshal(wandbConfigJSON, &wandbConfig) + if err != nil { + return nil, err + } + integrations = append(integrations, openai.FineTuningJobNewParamsIntegration{ + Type: "wandb", + Wandb: wandbConfig, + }) + } + } + + if len(integrations) > 0 { + jobParams.Integrations = integrations + } + } + + 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 + } +} + +func getReasoningEffortValue(effort string) openai.ReinforcementHyperparametersReasoningEffort { + + switch strings.ToLower(effort) { + case "low": + return openai.ReinforcementHyperparametersReasoningEffortLow + case "medium": + return openai.ReinforcementHyperparametersReasoningEffortMedium + case "high": + return openai.ReinforcementHyperparametersReasoningEffortHigh + default: + return openai.ReinforcementHyperparametersReasoningEffortDefault + } +} 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 index f0e99e0a19a..5ed9c1404a9 100644 --- a/cli/azd/extensions/azure.ai.finetune/internal/providers/openai/provider.go +++ b/cli/azd/extensions/azure.ai.finetune/internal/providers/openai/provider.go @@ -5,10 +5,14 @@ package openai import ( "context" - - "github.com/openai/openai-go/v3" + "fmt" + "os" + "time" "azure.ai.finetune/pkg/models" + "github.com/azure/azure-dev/cli/azd/pkg/ux" + "github.com/fatih/color" + "github.com/openai/openai-go/v3" ) // OpenAIProvider implements the provider interface for OpenAI APIs @@ -25,11 +29,18 @@ func NewOpenAIProvider(client *openai.Client) *OpenAIProvider { // 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 + + params, err := convertInternalJobParamToOpenAiJobParams(req) + if err != nil { + return nil, fmt.Errorf("failed to convert internal model to openai: %w", err) + } + + job, err := p.client.FineTuning.Jobs.New(ctx, *params) + if err != nil { + return nil, fmt.Errorf("failed to create fine-tuning job: %w", err) + } + + return convertOpenAIJobToModel(*job), nil } // GetFineTuningStatus retrieves the status of a fine-tuning job @@ -121,8 +132,60 @@ func (p *OpenAIProvider) CancelJob(ctx context.Context, jobID string) (*models.F // UploadFile uploads a file for fine-tuning func (p *OpenAIProvider) UploadFile(ctx context.Context, filePath string) (string, error) { - // TODO: Implement - return "", nil + if filePath == "" { + return "", fmt.Errorf("file path cannot be empty") + } + + // Show spinner while creating job + spinner := ux.NewSpinner(&ux.SpinnerOptions{ + Text: "uploading the file for fine-tuning", + }) + if err := spinner.Start(ctx); err != nil { + fmt.Printf("failed to start spinner: %v\n", err) + } + + file, err := os.Open(filePath) + if err != nil { + _ = spinner.Stop(ctx) + return "", fmt.Errorf("\nfailed to open file %s: %w", filePath, err) + } + defer file.Close() + + uploadedFile, err := p.client.Files.New(ctx, openai.FileNewParams{ + File: file, + Purpose: openai.FilePurposeFineTune, + }) + + if err != nil { + _ = spinner.Stop(ctx) + return "", fmt.Errorf("\nfailed to upload file: %w", err) + } + + if uploadedFile == nil || uploadedFile.ID == "" { + _ = spinner.Stop(ctx) + return "", fmt.Errorf("\nuploaded file is empty") + } + + // Poll for file processing status + for { + f, err := p.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) + } + color.Yellow(".") + time.Sleep(2 * time.Second) + } + + return uploadedFile.ID, nil } // GetUploadedFile retrieves information about an uploaded file 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 index 054583eee5c..b257e9f9ad8 100644 --- a/cli/azd/extensions/azure.ai.finetune/internal/services/finetune_service.go +++ b/cli/azd/extensions/azure.ai.finetune/internal/services/finetune_service.go @@ -6,13 +6,15 @@ package services import ( "context" "fmt" - - "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "os" "azure.ai.finetune/internal/providers" "azure.ai.finetune/internal/providers/factory" "azure.ai.finetune/internal/utils" + Utils "azure.ai.finetune/internal/utils" "azure.ai.finetune/pkg/models" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/fatih/color" ) // Ensure fineTuningServiceImpl implements FineTuningService interface @@ -41,13 +43,62 @@ func NewFineTuningService(ctx context.Context, azdClient *azdext.AzdClient, stat // 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 + // Validate request + if req == nil { + return nil, fmt.Errorf("request cannot be nil") + } + if req.BaseModel == "" { + return nil, fmt.Errorf("base model is required") + } + if req.TrainingFile == "" { + return nil, fmt.Errorf("training file is required") + } + + if Utils.IsLocalFilePath(req.TrainingFile) { + color.Green("\nuploading training file...") + + trainingDataID, err := s.UploadFile(ctx, Utils.GetLocalFilePath(req.TrainingFile)) + if err != nil { + return nil, fmt.Errorf("failed to upload training file: %w", err) + } + req.TrainingFile = trainingDataID + } else { + color.Yellow("\nProvided training file is non-local, skipping upload...") + } + + // Upload validation file if provided + if req.ValidationFile != nil && *req.ValidationFile != "" { + if Utils.IsLocalFilePath(*req.ValidationFile) { + color.Green("\nuploading validation file...") + validationDataID, err := s.UploadFile(ctx, Utils.GetLocalFilePath(*req.ValidationFile)) + if err != nil { + return nil, fmt.Errorf("failed to upload validation file: %w", err) + } + req.ValidationFile = &validationDataID + } else { + color.Yellow("\nProvided validation file is non-local, skipping upload...") + } + } + + // Call provider with retry logic + var job *models.FineTuningJob + err := utils.RetryOperation(ctx, utils.DefaultRetryConfig(), func() error { + var err error + job, err = s.provider.CreateFineTuningJob(ctx, req) + return err + }) + if err != nil { + return nil, fmt.Errorf("failed to create fine-tuning job: %w", err) + } + + // Persist job to state store if available + if s.stateStore != nil { + if err := s.stateStore.SaveJob(ctx, job); err != nil { + return nil, fmt.Errorf("failed to persist job: %w", err) + } + } + + return job, nil } // GetFineTuningStatus retrieves the current status of a job @@ -146,16 +197,39 @@ func (s *fineTuningServiceImpl) CancelJob(ctx context.Context, jobID string) (*m 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 +// UploadFile uploads and validates a file +func (s *fineTuningServiceImpl) UploadFile(ctx context.Context, filePath string) (string, error) { + if filePath == "" { + return "", fmt.Errorf("file path cannot be empty") + } + uploadedFileId, err := s._uploadFile(ctx, filePath) + if err != nil || uploadedFileId == "" { + return "", fmt.Errorf("failed to upload file: %w", err) + } + return uploadedFileId, nil } -// UploadValidationFile uploads and validates a validation file -func (s *fineTuningServiceImpl) UploadValidationFile(ctx context.Context, filePath string) (string, error) { - // TODO: Implement - return "", nil +func (s *fineTuningServiceImpl) _uploadFile(ctx context.Context, filePath string) (string, error) { + // validate file existence + fileInfo, err := os.Stat(filePath) + if err != nil { + if os.IsNotExist(err) { + return "", fmt.Errorf("file does not exist: %s", filePath) + } + return "", fmt.Errorf("failed to stat file %s: %w", filePath, err) + } + if fileInfo.IsDir() { + return "", fmt.Errorf("path is a directory, not a file: %s", filePath) + } + + // upload file with retry + uploadedFileId := "" + err = utils.RetryOperation(ctx, utils.DefaultRetryConfig(), func() error { + var err error + uploadedFileId, err = s.provider.UploadFile(ctx, filePath) + return err + }) + return uploadedFileId, err } // PollJobUntilCompletion polls a job until it completes or fails diff --git a/cli/azd/extensions/azure.ai.finetune/internal/services/interface.go b/cli/azd/extensions/azure.ai.finetune/internal/services/interface.go index c4d20d13c9d..4bceba7daa2 100644 --- a/cli/azd/extensions/azure.ai.finetune/internal/services/interface.go +++ b/cli/azd/extensions/azure.ai.finetune/internal/services/interface.go @@ -38,11 +38,8 @@ type FineTuningService interface { // 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) + // UploadFile uploads and validates a file + UploadFile(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) diff --git a/cli/azd/extensions/azure.ai.finetune/internal/utils/common.go b/cli/azd/extensions/azure.ai.finetune/internal/utils/common.go new file mode 100644 index 00000000000..491e04a4a22 --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/internal/utils/common.go @@ -0,0 +1,18 @@ +package utils + +func IsLocalFilePath(fileID string) bool { + if fileID == "" { + return false + } + if len(fileID) > 6 && fileID[:6] == "local:" { + return true + } + return false +} + +func GetLocalFilePath(fileID string) string { + if IsLocalFilePath(fileID) { + return fileID[6:] + } + return fileID +} diff --git a/cli/azd/extensions/azure.ai.finetune/internal/utils/parser.go b/cli/azd/extensions/azure.ai.finetune/internal/utils/parser.go new file mode 100644 index 00000000000..8c487a1b7a2 --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/internal/utils/parser.go @@ -0,0 +1,30 @@ +package utils + +import ( + "fmt" + "os" + + "azure.ai.finetune/pkg/models" + "github.com/braydonk/yaml" +) + +func ParseCreateFineTuningRequestConfig(filePath string) (*models.CreateFineTuningRequest, 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 models.CreateFineTuningRequest + 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 +} diff --git a/cli/azd/extensions/azure.ai.finetune/pkg/models/finetune.go b/cli/azd/extensions/azure.ai.finetune/pkg/models/finetune.go index 9a943580c52..35f511667fd 100644 --- a/cli/azd/extensions/azure.ai.finetune/pkg/models/finetune.go +++ b/cli/azd/extensions/azure.ai.finetune/pkg/models/finetune.go @@ -3,7 +3,10 @@ package models -import "time" +import ( + "fmt" + "time" +) // JobStatus represents the status of a fine-tuning job type JobStatus string @@ -18,6 +21,15 @@ const ( StatusPaused JobStatus = "paused" ) +// Represents the type of method used for fine-tuning +type MethodType string + +const ( + Supervised MethodType = "supervised" + DPO MethodType = "dpo" + Reinforcement MethodType = "reinforcement" +) + // FineTuningJob represents a vendor-agnostic fine-tuning job type FineTuningJob struct { // Core identification @@ -42,14 +54,6 @@ type FineTuningJob struct { 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 @@ -115,3 +119,193 @@ type CheckpointMetrics struct { FullValidLoss float64 FullValidMeanTokenAccuracy float64 } + +// CreateFineTuningRequest represents a request to create a fine-tuning job +type CreateFineTuningRequest struct { + // Required: The name of the model to fine-tune + BaseModel 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: 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: Fine-tuning method configuration (supervised, dpo, or reinforcement) + Method MethodConfig `yaml:"method,omitempty"` + + // Optional: Integrations to enable (e.g., wandb for Weights & Biases) + Integrations []Integration `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 map[string]interface{} `yaml:"grader,omitempty"` + + // Hyperparameters specific to reinforcement learning + Hyperparameters HyperparametersConfig `yaml:"hyperparameters,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" + Epochs interface{} `yaml:"epochs,omitempty"` + + // Batch size for training + // Can be: integer (1, 8, 16, 32, 64, 128), "auto" + BatchSize interface{} `yaml:"batch_size,omitempty"` + + // Learning rate multiplier + // Can be: float (0.1-2.0), "auto" + 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"` +} + +// Integration represents integration configuration (e.g., Weights & Biases) +type Integration 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"` +} + +// Validate checks if the configuration is valid +func (c CreateFineTuningRequest) Validate() error { + // Validate required fields + if c.BaseModel == "" { + 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 integrations if provided + if len(c.Integrations) > 0 { + for _, integration := range c.Integrations { + if integration.Type == "" { + return fmt.Errorf("integration type is required if integrations are specified") + } + if integration.Config == nil { + return fmt.Errorf("integration of type '%s' requires 'config' block", integration.Type) + } + } + } + + // 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 +} From 62a90c52ee3d0446e535a9a63605fa3e046ad162 Mon Sep 17 00:00:00 2001 From: Zubairuddin Mohammed Date: Tue, 6 Jan 2026 22:47:50 +0530 Subject: [PATCH 05/32] Build and Release Pipelines for Finetuning CLI (#6449) * Adding pipeline scripts * Adding pipeline scripts * fixing version path * fixing build command --- .../extensions/azure.ai.finetune/ci-build.ps1 | 142 ++++++++++++++++++ .../extensions/azure.ai.finetune/version.txt | 1 + .../release-ext-azure-ai-finetune.yml | 32 ++++ 3 files changed, 175 insertions(+) create mode 100644 cli/azd/extensions/azure.ai.finetune/ci-build.ps1 create mode 100644 cli/azd/extensions/azure.ai.finetune/version.txt create mode 100644 eng/pipelines/release-ext-azure-ai-finetune.yml diff --git a/cli/azd/extensions/azure.ai.finetune/ci-build.ps1 b/cli/azd/extensions/azure.ai.finetune/ci-build.ps1 new file mode 100644 index 00000000000..fafe8a5b969 --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/ci-build.ps1 @@ -0,0 +1,142 @@ +param( + [string] $Version = (Get-Content "$PSScriptRoot/version.txt"), + [string] $SourceVersion = (git rev-parse HEAD), + [switch] $CodeCoverageEnabled, + [switch] $BuildRecordMode, + [string] $MSYS2Shell, # path to msys2_shell.cmd + [string] $OutputFileName +) + +# Remove any previously built binaries +go clean + +if ($LASTEXITCODE) { + Write-Host "Error running go clean" + exit $LASTEXITCODE +} + +# Run `go help build` to obtain detailed information about `go build` flags. +$buildFlags = @( + # remove all file system paths from the resulting executable. + # Instead of absolute file system paths, the recorded file names + # will begin either a module path@version (when using modules), + # or a plain import path (when using the standard library, or GOPATH). + "-trimpath", + + # Use buildmode=pie (Position Independent Executable) for enhanced security across platforms + # against memory corruption exploits across all major platforms. + # + # On Windows, the -buildmode=pie flag enables Address Space Layout + # Randomization (ASLR) and automatically sets DYNAMICBASE and HIGH-ENTROPY-VA flags in the PE header. + "-buildmode=pie" +) + +if ($CodeCoverageEnabled) { + $buildFlags += "-cover" +} + +# Build constraint tags +# cfi: Enable Control Flow Integrity (CFI), +# cfg: Enable Control Flow Guard (CFG), +# osusergo: Optimize for OS user accounts +$tagsFlag = "-tags=cfi,cfg,osusergo" + +# ld linker flags +# -s: Omit symbol table and debug information +# -w: Omit DWARF symbol table +# -X: Set variable at link time. Used to set the version in source. + +$ldFlag = "-ldflags=-s -w -X 'azure.ai.finetune/internal/cmd.Version=$Version' -X 'azure.ai.finetune/internal/cmd.Commit=$SourceVersion' -X 'azure.ai.finetune/internal/cmd.BuildDate=$(Get-Date -Format o)' " + +if ($IsWindows) { + $msg = "Building for Windows" + Write-Host $msg +} +elseif ($IsLinux) { + Write-Host "Building for linux" +} +elseif ($IsMacOS) { + Write-Host "Building for macOS" +} + +# Add output file flag based on specified output file name +$outputFlag = "-o=$OutputFileName" + +# collect flags +$buildFlags += @( + $tagsFlag, + $ldFlag, + $outputFlag +) + +function PrintFlags() { + param( + [string] $flags + ) + + # Attempt to format flags so that they are easily copy-pastable to be ran inside pwsh + $i = 0 + foreach ($buildFlag in $buildFlags) { + # If the flag has a value, wrap it in quotes. This is not required when invoking directly below, + # but when repasted into a shell for execution, the quotes can help escape special characters such as ','. + $argWithValue = $buildFlag.Split('=', 2) + if ($argWithValue.Length -eq 2 -and !$argWithValue[1].StartsWith("`"")) { + $buildFlag = "$($argWithValue[0])=`"$($argWithValue[1])`"" + } + + # Write each flag on a newline with '`' acting as the multiline separator + if ($i -eq $buildFlags.Length - 1) { + Write-Host " $buildFlag" + } + else { + Write-Host " $buildFlag ``" + } + $i++ + } +} + +$oldGOEXPERIMENT = $env:GOEXPERIMENT +# Enable the loopvar experiment, which makes the loop variaible for go loops like `range` behave as most folks would expect. +# the go team is exploring making this default in the future, and we'd like to opt into the behavior now. +$env:GOEXPERIMENT = "loopvar" + +try { + Write-Host "Running: go build ``" + PrintFlags -flags $buildFlags + go build @buildFlags + if ($LASTEXITCODE) { + Write-Host "Error running go build" + exit $LASTEXITCODE + } + + if ($BuildRecordMode) { + # Modify build tags to include record + $recordTagPatched = $false + for ($i = 0; $i -lt $buildFlags.Length; $i++) { + if ($buildFlags[$i].StartsWith("-tags=")) { + $buildFlags[$i] += ",record" + $recordTagPatched = $true + } + } + if (-not $recordTagPatched) { + $buildFlags += "-tags=record" + } + # Add output file flag for record mode + $recordOutput = "-o=$OutputFileName-record" + if ($IsWindows) { $recordOutput += ".exe" } + $buildFlags += $recordOutput + + Write-Host "Running: go build (record) ``" + PrintFlags -flags $buildFlags + go build @buildFlags + if ($LASTEXITCODE) { + Write-Host "Error running go build (record)" + exit $LASTEXITCODE + } + } + + Write-Host "go build succeeded" +} +finally { + $env:GOEXPERIMENT = $oldGOEXPERIMENT +} \ No newline at end of file diff --git a/cli/azd/extensions/azure.ai.finetune/version.txt b/cli/azd/extensions/azure.ai.finetune/version.txt new file mode 100644 index 00000000000..65c144bf57b --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/version.txt @@ -0,0 +1 @@ +0.0.6-preview \ No newline at end of file diff --git a/eng/pipelines/release-ext-azure-ai-finetune.yml b/eng/pipelines/release-ext-azure-ai-finetune.yml new file mode 100644 index 00000000000..fd04e3b4270 --- /dev/null +++ b/eng/pipelines/release-ext-azure-ai-finetune.yml @@ -0,0 +1,32 @@ +# Continuous deployment trigger +trigger: + branches: + include: + - main + paths: + include: + - go.mod + - cli/azd/extensions/azure.ai.finetune + - eng/pipelines/release-azd-extension.yml + - /eng/pipelines/templates/jobs/build-azd-extension.yml + - /eng/pipelines/templates/jobs/cross-build-azd-extension.yml + - /eng/pipelines/templates/variables/image.yml + +pr: + paths: + include: + - cli/azd/extensions/azure.ai.finetune + - eng/pipelines/release-azd-extension.yml + - eng/pipelines/templates/steps/publish-cli.yml + exclude: + - cli/azd/docs/** + +extends: + template: /eng/pipelines/templates/stages/1es-redirect.yml + parameters: + stages: + - template: /eng/pipelines/templates/stages/release-azd-extension.yml + parameters: + AzdExtensionId: azure.ai.finetune + SanitizedExtensionId: azure-ai-finetune + AzdExtensionDirectory: cli/azd/extensions/azure.ai.finetune \ No newline at end of file From 53d2524342119488869bc618b320c7f5898c7c37 Mon Sep 17 00:00:00 2001 From: Amit Chauhan <70937115+achauhan-scc@users.noreply.github.com> Date: Wed, 7 Jan 2026 10:05:25 +0530 Subject: [PATCH 06/32] adding init command (#6442) * adding init command * update version --- .../azure.ai.finetune/extension.yaml | 2 +- .../azure.ai.finetune/internal/cmd/init.go | 377 ++++++++++++------ .../internal/providers/openai/conversions.go | 30 +- .../azure.ai.finetune/pkg/models/finetune.go | 7 + 4 files changed, 285 insertions(+), 131 deletions(-) diff --git a/cli/azd/extensions/azure.ai.finetune/extension.yaml b/cli/azd/extensions/azure.ai.finetune/extension.yaml index 700b34a44b3..b91e0a5f806 100644 --- a/cli/azd/extensions/azure.ai.finetune/extension.yaml +++ b/cli/azd/extensions/azure.ai.finetune/extension.yaml @@ -3,7 +3,7 @@ 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 +version: 0.0.7-preview language: go capabilities: - custom-commands diff --git a/cli/azd/extensions/azure.ai.finetune/internal/cmd/init.go b/cli/azd/extensions/azure.ai.finetune/internal/cmd/init.go index 1db89449d9e..0ef4edabe3a 100644 --- a/cli/azd/extensions/azure.ai.finetune/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.finetune/internal/cmd/init.go @@ -12,7 +12,6 @@ import ( "path/filepath" "regexp" "strings" - "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" @@ -23,12 +22,15 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/tools/github" "github.com/fatih/color" "github.com/spf13/cobra" + + "azure.ai.finetune/internal/services" ) type initFlags struct { rootFlagsDefinition + template string projectResourceId string - manifestPointer string + jobId string src string env string } @@ -66,7 +68,7 @@ func newInitCommand(rootFlags rootFlagsDefinition) *cobra.Command { } cmd := &cobra.Command{ - Use: "init [-m ] [-p ]", + Use: "init [-t ] [-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 { @@ -127,11 +129,17 @@ func newInitCommand(rootFlags rootFlagsDefinition) *cobra.Command { }, } - cmd.Flags().StringVarP(&flags.projectResourceId, "project-id", "p", "", + cmd.Flags().StringVarP(&flags.template, "template", "t", "", + "URL or path to a fine-tune job template") + + cmd.Flags().StringVarP(&flags.projectResourceId, "project", "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.src, "source", "s", "", + "Local path for project output") + + cmd.Flags().StringVarP(&flags.jobId, "from-job", "j", "", + "Clone configuration from an existing job ID") cmd.Flags().StringVarP(&flags.env, "environment", "e", "", "The name of the azd environment to use.") @@ -139,7 +147,9 @@ func newInitCommand(rootFlags rootFlagsDefinition) *cobra.Command { } type FoundryProject struct { + TenantId string `json:"tenantId"` SubscriptionId string `json:"subscriptionId"` + Location string `json:"location"` ResourceGroupName string `json:"resourceGroupName"` AiAccountName string `json:"aiAccountName"` AiProjectName string `json:"aiProjectName"` @@ -187,9 +197,8 @@ func getExistingEnvironment(ctx context.Context, flags *initFlags, azdClient *az func ensureEnvironment(ctx context.Context, flags *initFlags, azdClient *azdext.AzdClient) (*azdext.Environment, error) { var foundryProject *FoundryProject - var foundryProjectLocation string - var tenantId string + // Parse the Microsoft Foundry project resource ID if provided & Fetch Tenant Id and Location using parsed information if flags.projectResourceId != "" { var err error foundryProject, err = extractProjectDetails(flags.projectResourceId) @@ -204,9 +213,9 @@ func ensureEnvironment(ctx context.Context, flags *initFlags, azdClient *azdext. if err != nil { return nil, fmt.Errorf("failed to get tenant ID: %w", err) } - tenantId = tenantResponse.TenantId + foundryProject.TenantId = tenantResponse.TenantId credential, err := azidentity.NewAzureDeveloperCLICredential(&azidentity.AzureDeveloperCLICredentialOptions{ - TenantID: tenantResponse.TenantId, + TenantID: foundryProject.TenantId, AdditionallyAllowedTenants: []string{"*"}, }) if err != nil { @@ -225,7 +234,7 @@ func ensureEnvironment(ctx context.Context, flags *initFlags, azdClient *azdext. return nil, fmt.Errorf("failed to get Microsoft Foundry project: %w", err) } - foundryProjectLocation = *projectResp.Location + foundryProject.Location = *projectResp.Location } // Get specified or current environment if it exists @@ -241,7 +250,7 @@ func ensureEnvironment(ctx context.Context, flags *initFlags, azdClient *azdext. if flags.projectResourceId != "" { envArgs = append(envArgs, "--subscription", foundryProject.SubscriptionId) - envArgs = append(envArgs, "--location", foundryProjectLocation) + envArgs = append(envArgs, "--location", foundryProject.Location) } // Dispatch a workflow to create a new environment @@ -266,106 +275,55 @@ func ensureEnvironment(ctx context.Context, flags *initFlags, azdClient *azdext. return nil, fmt.Errorf("azd environment not found, please create an environment (azd env new) and try again") } } + + // Set TenantId, SubscriptionId, ResourceGroupName, AiAccountName, and Location in the environment if flags.projectResourceId != "" { - currentResouceGroupName, err := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ + + _, err := azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ EnvName: existingEnv.Name, - Key: "AZURE_RESOURCE_GROUP_NAME", + Key: "AZURE_TENANT_ID", + Value: foundryProject.TenantId, }) 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) - } + return nil, fmt.Errorf("failed to set AZURE_TENANT_ID in azd environment: %w", err) } - currentTenantId, err := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ + _, err = azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ EnvName: existingEnv.Name, - Key: "AZURE_TENANT_ID", + Key: "AZURE_SUBSCRIPTION_ID", + Value: foundryProject.SubscriptionId, }) 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, - }) + return nil, fmt.Errorf("failed to set AZURE_SUBSCRIPTION_ID in azd environment: %w", err) } - currentAccount, err := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ + _, err = azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ EnvName: existingEnv.Name, - Key: "AZURE_ACCOUNT_NAME", + Key: "AZURE_RESOURCE_GROUP_NAME", + Value: foundryProject.ResourceGroupName, }) 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) - } + return nil, fmt.Errorf("failed to set AZURE_RESOURCE_GROUP_NAME in azd environment: %w", err) } - currentSubscription, err := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ + _, err = azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ EnvName: existingEnv.Name, - Key: "AZURE_SUBSCRIPTION_ID", + Key: "AZURE_ACCOUNT_NAME", + Value: foundryProject.AiAccountName, }) 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) + return nil, fmt.Errorf("failed to set AZURE_ACCOUNT_NAME in azd environment: %w", err) } - // Get current location from environment - currentLocation, err := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ + _, err = azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ EnvName: existingEnv.Name, Key: "AZURE_LOCATION", + Value: foundryProject.Location, }) if err != nil { - return nil, fmt.Errorf("failed to get AZURE_LOCATION from azd environment: %w", err) + return nil, fmt.Errorf("failed to set AZURE_LOCATION in 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 @@ -444,6 +402,7 @@ func ensureAzureContext( TenantId: envValueMap["AZURE_TENANT_ID"], SubscriptionId: envValueMap["AZURE_SUBSCRIPTION_ID"], Location: envValueMap["AZURE_LOCATION"], + ResourceGroup: envValueMap["AZURE_RESOURCE_GROUP_NAME"], }, Resources: []string{}, } @@ -480,69 +439,158 @@ func ensureAzureContext( return nil, nil, nil, fmt.Errorf("failed to set AZURE_SUBSCRIPTION_ID in environment: %w", err) } } + if azureContext.Scope.ResourceGroup == "" { + fmt.Print() - 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.", - ) + resourceGroupResponse, err := azdClient.Prompt(). + PromptResourceGroup(ctx, &azdext.PromptResourceGroupRequest{ + AzureContext: azureContext, + }) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to prompt for resource group: %w", err) + } + + azureContext.Scope.ResourceGroup = resourceGroupResponse.ResourceGroup.Name + + // Set the subscription ID in the environment + _, err = azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ + EnvName: env.Name, + Key: "AZURE_RESOURCE_GROUP_NAME", + Value: azureContext.Scope.ResourceGroup, + }) + + } + + if envValueMap["AZURE_ACCOUNT_NAME"] == "" { + + aiAccountResponse, err := azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ + Options: &azdext.PromptOptions{ + Message: "Please enter your Azure AI Account name", + }, + }) - locationResponse, err := azdClient.Prompt().PromptLocation(ctx, &azdext.PromptLocationRequest{ - AzureContext: azureContext, + aiProjectName, err := azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ + Options: &azdext.PromptOptions{ + Message: "Please enter your Azure AI Project name", + }, + }) + credential, err := azidentity.NewAzureDeveloperCLICredential(&azidentity.AzureDeveloperCLICredentialOptions{ + TenantID: azureContext.Scope.TenantId, + AdditionallyAllowedTenants: []string{"*"}, }) if err != nil { - return nil, nil, nil, fmt.Errorf("failed to prompt for location: %w", err) + return nil, nil, nil, fmt.Errorf("failed to create Azure credential: %w", err) } - azureContext.Scope.Location = locationResponse.Location.Name + // Create Cognitive Services Projects client + projectsClient, err := armcognitiveservices.NewProjectsClient(azureContext.Scope.SubscriptionId, credential, nil) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to create Cognitive Services Projects client: %w", err) + } + + // Get the Microsoft Foundry project + projectResp, err := projectsClient.Get(ctx, azureContext.Scope.ResourceGroup, aiAccountResponse.Value, aiProjectName.Value, nil) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to get Microsoft Foundry project: %w", err) + } + + // Set the subscription ID in the environment + _, err = azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ + EnvName: env.Name, + Key: "AZURE_ACCOUNT_NAME", + Value: aiAccountResponse.Value, + }) + + location := *projectResp.Location // Set the location in the environment _, err = azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ EnvName: env.Name, Key: "AZURE_LOCATION", - Value: azureContext.Scope.Location, + Value: 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) + // Validate that either template or from-job is provided, but not both + if a.flags.template != "" && a.flags.jobId != "" { + return fmt.Errorf("cannot specify both --template and --from-job flags") + } 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() + + var cwd string + var err error + + // Use src flag if provided, otherwise use current working directory + if a.flags.src != "" { + cwd = a.flags.src + } else { + cwd, err = os.Getwd() if err != nil { return fmt.Errorf("failed to get current working directory: %w", err) } - if a.isGitHubUrl(a.flags.manifestPointer) { + } + + if a.flags.template == "" && a.flags.jobId == "" { + defaultBaseModel := "gpt-4o-mini" + defaultMethod := "supervised" + baseModelForFineTuningInput, 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: defaultBaseModel, + }, + }) + 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", baseModelForFineTuningInput.Value, ftMethodInput.Value) + + // Create YAML file with the fine-tuning job template + yamlContent := fmt.Sprintf(`name: ft-cli-job +description: Template to demonstrate fine-tuning via CLI +model: %s +method: + type: %s +`, baseModelForFineTuningInput.Value, ftMethodInput.Value) + + // Determine the output directory (use src flag or current directory) + outputDir := a.flags.src + if outputDir == "" { + var err error + outputDir, err = os.Getwd() + if err != nil { + return fmt.Errorf("failed to get current working directory: %w", err) + } + } + + yamlFilePath := filepath.Join(outputDir, "config", "job.yaml") + if err := os.MkdirAll(filepath.Dir(yamlFilePath), 0755); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + + if err := os.WriteFile(yamlFilePath, []byte(yamlContent), 0644); err != nil { + return fmt.Errorf("failed to write job.yaml file: %w", err) + } + + fmt.Printf("Created fine-tuning job template at: %s\n", yamlFilePath) + + // Set the template flag to the newly created YAML file + a.flags.template = yamlFilePath + } else if a.flags.template != "" { + + if a.isGitHubUrl(a.flags.template) { // For container agents, download the entire parent directory fmt.Println("Downloading full directory for fine-tuning configuration from GitHub...") var ghCli *github.Cli @@ -571,7 +619,7 @@ func (a *InitAction) Run(ctx context.Context) error { return fmt.Errorf("creating GitHub CLI: %w", err) } - urlInfo, err = parseGitHubUrl(a.flags.manifestPointer) + urlInfo, err = parseGitHubUrl(a.flags.template) if err != nil { return err } @@ -586,10 +634,85 @@ func (a *InitAction) Run(ctx context.Context) error { return fmt.Errorf("downloading parent directory: %w", err) } } else { - if err := copyDirectory(a.flags.manifestPointer, cwd); err != nil { + if err := copyDirectory(a.flags.template, cwd); err != nil { return fmt.Errorf("failed to copy directory: %w", err) } } + } else if a.flags.jobId != "" { + fmt.Printf("Cloning fine-tuning job configuration from job ID: %s\n", a.flags.jobId) + fineTuneSvc, err := services.NewFineTuningService(ctx, a.azdClient, nil) + if err != nil { + return fmt.Errorf("failed to create fine-tuning service: %w", err) + } + + // Fetch job details + fmt.Printf("Fetching fine-tuning job %s...\n", a.flags.jobId) + job, err := fineTuneSvc.GetFineTuningJobDetails(ctx, a.flags.jobId) + if err != nil { + return fmt.Errorf("failed to fetch fine-tuning job details: %w", err) + } + + // Create YAML file with job configuration + yamlContent := fmt.Sprintf(`name: %s +description: Cloned configuration from job %s +model: %s +seed: %d +method: + type: %s +`, a.flags.jobId, a.flags.jobId, job.Model, job.Seed, job.Method) + + // Add hyperparameters nested under method type if present + if job.Hyperparameters != nil { + yamlContent += fmt.Sprintf(` %s: + hyperparameters: + epochs: %d + batch_size: %d + learning_rate_multiplier: %f +`, job.Method, job.Hyperparameters.NEpochs, job.Hyperparameters.BatchSize, job.Hyperparameters.LearningRateMultiplier) + + // Add beta parameter only for DPO method + if strings.ToLower(job.Method) == "dpo" { + yamlContent += fmt.Sprintf(" beta: %v\n", job.Hyperparameters.Beta) + } + + // Add reinforcement-specific hyperparameters + if strings.ToLower(job.Method) == "reinforcement" { + yamlContent += fmt.Sprintf(" compute_multiplier: %f\n", job.Hyperparameters.ComputeMultiplier) + yamlContent += fmt.Sprintf(" eval_interval: %d\n", job.Hyperparameters.EvalInterval) + yamlContent += fmt.Sprintf(" eval_samples: %d\n", job.Hyperparameters.EvalSamples) + yamlContent += fmt.Sprintf(" reasoning_effort: %s\n", job.Hyperparameters.ReasoningEffort) + } + } + + // Add training and validation files + yamlContent += fmt.Sprintf("training_file: %s\n", job.TrainingFile) + if job.ValidationFile != "" { + yamlContent += fmt.Sprintf("validation_file: %s\n", job.ValidationFile) + } + + // Determine the output directory (use src flag or current directory) + outputDir := a.flags.src + if outputDir == "" { + var err error + outputDir, err = os.Getwd() + if err != nil { + return fmt.Errorf("failed to get current working directory: %w", err) + } + } + + yamlFilePath := filepath.Join(outputDir, "config", "job.yaml") + if err := os.MkdirAll(filepath.Dir(yamlFilePath), 0755); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + + if err := os.WriteFile(yamlFilePath, []byte(yamlContent), 0644); err != nil { + return fmt.Errorf("failed to write job.yaml file: %w", err) + } + + fmt.Printf("Created fine-tuning job configuration at: %s\n", yamlFilePath) + + // Set the template flag to the newly created YAML file + a.flags.template = yamlFilePath } fmt.Println() color.Green("Initialized fine-tuning Project.") diff --git a/cli/azd/extensions/azure.ai.finetune/internal/providers/openai/conversions.go b/cli/azd/extensions/azure.ai.finetune/internal/providers/openai/conversions.go index f27372483f1..3fae0d4204a 100644 --- a/cli/azd/extensions/azure.ai.finetune/internal/providers/openai/conversions.go +++ b/cli/azd/extensions/azure.ai.finetune/internal/providers/openai/conversions.go @@ -57,9 +57,32 @@ func convertOpenAIJobToModel(openaiJob openai.FineTuningJob) *models.FineTuningJ func convertOpenAIJobToDetailModel(openaiJob *openai.FineTuningJob) *models.FineTuningJobDetail { // Extract hyperparameters from OpenAI job hyperparameters := &models.Hyperparameters{} - hyperparameters.BatchSize = openaiJob.Hyperparameters.BatchSize.OfInt - hyperparameters.LearningRateMultiplier = openaiJob.Hyperparameters.LearningRateMultiplier.OfFloat - hyperparameters.NEpochs = openaiJob.Hyperparameters.NEpochs.OfInt + if openaiJob.Method.Type == "supervised" { + hyperparameters.BatchSize = openaiJob.Method.Supervised.Hyperparameters.BatchSize.OfInt + hyperparameters.LearningRateMultiplier = openaiJob.Method.Supervised.Hyperparameters.LearningRateMultiplier.OfFloat + hyperparameters.NEpochs = openaiJob.Method.Supervised.Hyperparameters.NEpochs.OfInt + } else if openaiJob.Method.Type == "dpo" { + hyperparameters.BatchSize = openaiJob.Method.Dpo.Hyperparameters.BatchSize.OfInt + hyperparameters.LearningRateMultiplier = openaiJob.Method.Dpo.Hyperparameters.LearningRateMultiplier.OfFloat + hyperparameters.NEpochs = openaiJob.Method.Dpo.Hyperparameters.NEpochs.OfInt + hyperparameters.Beta = openaiJob.Method.Dpo.Hyperparameters.Beta.OfFloat + } else if openaiJob.Method.Type == "reinforcement" { + hyperparameters.BatchSize = openaiJob.Method.Reinforcement.Hyperparameters.BatchSize.OfInt + hyperparameters.LearningRateMultiplier = openaiJob.Method.Reinforcement.Hyperparameters.LearningRateMultiplier.OfFloat + hyperparameters.NEpochs = openaiJob.Method.Reinforcement.Hyperparameters.NEpochs.OfInt + hyperparameters.ComputeMultiplier = openaiJob.Method.Reinforcement.Hyperparameters.ComputeMultiplier.OfFloat + hyperparameters.EvalInterval = openaiJob.Method.Reinforcement.Hyperparameters.EvalInterval.OfInt + hyperparameters.EvalSamples = openaiJob.Method.Reinforcement.Hyperparameters.EvalSamples.OfInt + if openaiJob.Method.Reinforcement.Hyperparameters.ReasoningEffort != "" { + hyperparameters.ReasoningEffort = string(openaiJob.Method.Reinforcement.Hyperparameters.ReasoningEffort) + } + + } else { + // Fallback to top-level hyperparameters (for backward compatibility) + hyperparameters.BatchSize = openaiJob.Hyperparameters.BatchSize.OfInt + hyperparameters.LearningRateMultiplier = openaiJob.Hyperparameters.LearningRateMultiplier.OfFloat + hyperparameters.NEpochs = openaiJob.Hyperparameters.NEpochs.OfInt + } jobDetail := &models.FineTuningJobDetail{ ID: openaiJob.ID, @@ -72,6 +95,7 @@ func convertOpenAIJobToDetailModel(openaiJob *openai.FineTuningJob) *models.Fine TrainingFile: openaiJob.TrainingFile, ValidationFile: openaiJob.ValidationFile, Hyperparameters: hyperparameters, + Seed: openaiJob.Seed, } return jobDetail diff --git a/cli/azd/extensions/azure.ai.finetune/pkg/models/finetune.go b/cli/azd/extensions/azure.ai.finetune/pkg/models/finetune.go index 35f511667fd..1740ba8f946 100644 --- a/cli/azd/extensions/azure.ai.finetune/pkg/models/finetune.go +++ b/cli/azd/extensions/azure.ai.finetune/pkg/models/finetune.go @@ -59,6 +59,11 @@ type Hyperparameters struct { BatchSize int64 LearningRateMultiplier float64 NEpochs int64 + Beta float64 // For DPO + ComputeMultiplier float64 // For Reinforcement + EvalInterval int64 // For Reinforcement + EvalSamples int64 // For Reinforcement + ReasoningEffort string // For Reinforcement } // ListFineTuningJobsRequest represents a request to list fine-tuning jobs @@ -80,6 +85,8 @@ type FineTuningJobDetail struct { ValidationFile string Hyperparameters *Hyperparameters VendorMetadata map[string]interface{} + TrainingType string + Seed int64 } // JobEvent represents an event associated with a fine-tuning job From 57a9e97edc2ab2f24daccee08bd6109268d74226 Mon Sep 17 00:00:00 2001 From: Zubairuddin Mohammed Date: Wed, 7 Jan 2026 13:27:20 +0530 Subject: [PATCH 07/32] network isloation changes. (#6458) * Adding pipeline scripts * Adding pipeline scripts * fixing version path * fixing build command * Djurek/test-network-isolation (#6455) * Update 1es-redirect.yml * Trivial change to test pipeline * Show GOPROXY * Use golang internalModuleProxy --------- Co-authored-by: Daniel Jurek --- cli/azd/extensions/azure.ai.finetune/main.go | 3 +- .../templates/stages/1es-redirect.yml | 30 ++++++++++++++----- eng/pipelines/templates/steps/setup-go.yml | 4 +++ 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/cli/azd/extensions/azure.ai.finetune/main.go b/cli/azd/extensions/azure.ai.finetune/main.go index 6ea052455e1..c28d629a439 100644 --- a/cli/azd/extensions/azure.ai.finetune/main.go +++ b/cli/azd/extensions/azure.ai.finetune/main.go @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. - +// TODO: Remove +// Trivial change to test pipeline package main import ( diff --git a/eng/pipelines/templates/stages/1es-redirect.yml b/eng/pipelines/templates/stages/1es-redirect.yml index 525fa57e579..afdebbf7890 100644 --- a/eng/pipelines/templates/stages/1es-redirect.yml +++ b/eng/pipelines/templates/stages/1es-redirect.yml @@ -16,6 +16,9 @@ parameters: - name: Use1ESOfficial type: boolean default: true +- name: GenerateBaselines + type: boolean + default: false extends: ${{ if and(parameters.Use1ESOfficial, eq(variables['System.TeamProject'], 'internal')) }}: @@ -23,12 +26,20 @@ extends: ${{ else }}: template: v1/1ES.Unofficial.PipelineTemplate.yml@1ESPipelineTemplates parameters: + featureFlags: + golang: + internalModuleProxy: + enabled: true settings: skipBuildTagsForGitHubPullRequests: true + networkIsolationPolicy: Permissive sdl: - ${{ if and(eq(variables['Build.DefinitionName'], 'azure-dev - cli'), eq(variables['Build.SourceBranchName'], 'main'), eq(variables['System.TeamProject'], 'internal')) }}: + # Turn off the build warnings caused by disabling some sdl checks + createAdoIssuesForJustificationsForDisablement: false + ${{ if and(parameters.GenerateBaselines, eq(variables['Build.DefinitionName'], 'azure-dev - cli'), eq(variables['Build.SourceBranchName'], 'main'), eq(variables['System.TeamProject'], 'internal')) }}: autobaseline: isMainPipeline: true + disableAutoBaselineOnNonDefaultBranches: true enableForGitHub: true credscan: suppressionsFile: $(Build.SourcesDirectory)/eng/CredScanSuppression.json @@ -39,14 +50,19 @@ extends: name: azsdk-pool image: windows-2022 os: windows + eslint: + enabled: false + justificationForDisabling: 'ESLint injected task has failures because it uses an old version of mkdirp. We should not fail for tools not controlled by the repo. See: https://dev.azure.com/azure-sdk/internal/_build/results?buildId=3499746' + codeql: + compiled: + enabled: false + justificationForDisabling: CodeQL times our pipelines out by running for 2+ hours before being force canceled. + componentgovernance: + enabled: false + justificationForDisabling: Manually enabling only on the main build job instead of running it on every job. psscriptanalyzer: compiled: true break: true policy: M365 - eslint: - enabled: false - justificationForDisabling: "ESLint injected task has failures because it uses an old version of mkdirp. We should not fail for tools not controlled by the repo. See: https://dev.azure.com/azure-sdk/internal/_build/results?buildId=3556850" - ${{ if or(eq(variables['System.TeamProject'], 'public'), eq(variables['Build.Reason'], 'PullRequest')) }}: - sbom: - enabled: false + stages: ${{ parameters.stages }} diff --git a/eng/pipelines/templates/steps/setup-go.yml b/eng/pipelines/templates/steps/setup-go.yml index cd4375a9b15..c464afea92f 100644 --- a/eng/pipelines/templates/steps/setup-go.yml +++ b/eng/pipelines/templates/steps/setup-go.yml @@ -40,6 +40,10 @@ steps: Write-Host "##vso[task.prependpath]$goBin" displayName: Add Go bin to PATH + - pwsh: | + Write-Host "GOPROXY: $($env:GOPROXY)" + displayName: GOPROXY + - pwsh: go install gotest.tools/gotestsum@latest condition: ${{ parameters.Condition }} displayName: Install gotest From b562fd044d4fa39bc9df44b3611cdf37baf8dcac Mon Sep 17 00:00:00 2001 From: Amit Chauhan <70937115+achauhan-scc@users.noreply.github.com> Date: Wed, 7 Jan 2026 15:59:06 +0530 Subject: [PATCH 08/32] Cleaning code to raise a PR to main (#6457) * adding init command * update version * cleaning some code --- .../azure.ai.finetune/extension.yaml | 4 +- .../internal/cmd/converter.go | 206 ------- .../azure.ai.finetune/internal/cmd/listen.go | 74 --- .../internal/cmd/operations.go | 185 +----- .../azure.ai.finetune/internal/cmd/prompt.go | 276 --------- .../azure.ai.finetune/internal/cmd/root.go | 2 +- .../internal/fine_tuning_yaml/parser.go | 570 ------------------ .../internal/fine_tuning_yaml/yaml.go | 323 ---------- .../project/service_target_finetune.go | 140 ----- .../internal/tools/deployment_wrapper.go | 148 ----- .../internal/tools/job_wrapper.go | 422 ------------- 11 files changed, 4 insertions(+), 2346 deletions(-) delete mode 100644 cli/azd/extensions/azure.ai.finetune/internal/cmd/converter.go delete mode 100644 cli/azd/extensions/azure.ai.finetune/internal/cmd/listen.go delete mode 100644 cli/azd/extensions/azure.ai.finetune/internal/cmd/prompt.go delete mode 100644 cli/azd/extensions/azure.ai.finetune/internal/fine_tuning_yaml/parser.go delete mode 100644 cli/azd/extensions/azure.ai.finetune/internal/fine_tuning_yaml/yaml.go delete mode 100644 cli/azd/extensions/azure.ai.finetune/internal/project/service_target_finetune.go delete mode 100644 cli/azd/extensions/azure.ai.finetune/internal/tools/deployment_wrapper.go delete mode 100644 cli/azd/extensions/azure.ai.finetune/internal/tools/job_wrapper.go diff --git a/cli/azd/extensions/azure.ai.finetune/extension.yaml b/cli/azd/extensions/azure.ai.finetune/extension.yaml index b91e0a5f806..7fb622dc7c2 100644 --- a/cli/azd/extensions/azure.ai.finetune/extension.yaml +++ b/cli/azd/extensions/azure.ai.finetune/extension.yaml @@ -3,12 +3,10 @@ namespace: ai.finetuning displayName: Foundry Fine Tuning (Preview) description: Extension for Foundry Fine Tuning. (Preview) usage: azd ai finetuning [options] -version: 0.0.7-preview +version: 0.0.8-preview language: go capabilities: - custom-commands - - lifecycle-events - - service-target-provider providers: - name: azure.ai.finetune type: service-target diff --git a/cli/azd/extensions/azure.ai.finetune/internal/cmd/converter.go b/cli/azd/extensions/azure.ai.finetune/internal/cmd/converter.go deleted file mode 100644 index e6cbdbef2c5..00000000000 --- a/cli/azd/extensions/azure.ai.finetune/internal/cmd/converter.go +++ /dev/null @@ -1,206 +0,0 @@ -// 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/listen.go b/cli/azd/extensions/azure.ai.finetune/internal/cmd/listen.go deleted file mode 100644 index ca78d5fe642..00000000000 --- a/cli/azd/extensions/azure.ai.finetune/internal/cmd/listen.go +++ /dev/null @@ -1,74 +0,0 @@ -// 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 index e90329f35a8..59fc90a1c51 100644 --- a/cli/azd/extensions/azure.ai.finetune/internal/cmd/operations.go +++ b/cli/azd/extensions/azure.ai.finetune/internal/cmd/operations.go @@ -14,7 +14,6 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/ux" "azure.ai.finetune/internal/services" - JobWrapper "azure.ai.finetune/internal/tools" "azure.ai.finetune/internal/utils" "azure.ai.finetune/pkg/models" ) @@ -31,32 +30,12 @@ func newOperationCommand() *cobra.Command { cmd.AddCommand(newOperationSubmitCommand()) cmd.AddCommand(newOperationShowCommand()) cmd.AddCommand(newOperationListCommand()) - cmd.AddCommand(newOperationActionCommand()) - cmd.AddCommand(newOperationDeployModelCommand()) + // cmd.AddCommand(newOperationActionCommand()) + // cmd.AddCommand(newOperationDeployModelCommand()) return cmd } -// getStatusSymbolFromString returns a symbol representation for job status -func getStatusSymbolFromString(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 == "" { @@ -356,163 +335,3 @@ func newOperationListCommand() *cobra.Command { 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", getStatusSymbolFromString(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 deleted file mode 100644 index e6db7f2d06c..00000000000 --- a/cli/azd/extensions/azure.ai.finetune/internal/cmd/prompt.go +++ /dev/null @@ -1,276 +0,0 @@ -// 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 index c82febf8804..b111f07e8ea 100644 --- a/cli/azd/extensions/azure.ai.finetune/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.finetune/internal/cmd/root.go @@ -43,7 +43,7 @@ func NewRootCommand() *cobra.Command { "accepts the default value instead of prompting, or fails if there is no default", ) - rootCmd.AddCommand(newListenCommand()) + // rootCmd.AddCommand(newListenCommand()) rootCmd.AddCommand(newVersionCommand()) rootCmd.AddCommand(newInitCommand(rootFlags)) rootCmd.AddCommand(newOperationCommand()) 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 deleted file mode 100644 index 7f9f6dc7275..00000000000 --- a/cli/azd/extensions/azure.ai.finetune/internal/fine_tuning_yaml/parser.go +++ /dev/null @@ -1,570 +0,0 @@ -// 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 deleted file mode 100644 index 3f4099273b4..00000000000 --- a/cli/azd/extensions/azure.ai.finetune/internal/fine_tuning_yaml/yaml.go +++ /dev/null @@ -1,323 +0,0 @@ -// 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 deleted file mode 100644 index b57f2d75c95..00000000000 --- a/cli/azd/extensions/azure.ai.finetune/internal/project/service_target_finetune.go +++ /dev/null @@ -1,140 +0,0 @@ -// 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/tools/deployment_wrapper.go b/cli/azd/extensions/azure.ai.finetune/internal/tools/deployment_wrapper.go deleted file mode 100644 index 69f8f81ea3a..00000000000 --- a/cli/azd/extensions/azure.ai.finetune/internal/tools/deployment_wrapper.go +++ /dev/null @@ -1,148 +0,0 @@ -// 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 deleted file mode 100644 index 9b760df9cb8..00000000000 --- a/cli/azd/extensions/azure.ai.finetune/internal/tools/job_wrapper.go +++ /dev/null @@ -1,422 +0,0 @@ -// 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"` -} - -// 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 -} - -// 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 -} From eacfee5aec4835af0ed96f06777d6167218d4caa Mon Sep 17 00:00:00 2001 From: Zubairuddin Mohammed Date: Wed, 7 Jan 2026 18:48:11 +0530 Subject: [PATCH 09/32] updating version to 0.0.7-preview (#6462) --- cli/azd/extensions/azure.ai.finetune/version.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/azd/extensions/azure.ai.finetune/version.txt b/cli/azd/extensions/azure.ai.finetune/version.txt index 65c144bf57b..97abdd882e0 100644 --- a/cli/azd/extensions/azure.ai.finetune/version.txt +++ b/cli/azd/extensions/azure.ai.finetune/version.txt @@ -1 +1 @@ -0.0.6-preview \ No newline at end of file +0.0.7-preview From 481edde9a8e52983e6cb837a289185dd4db5085e Mon Sep 17 00:00:00 2001 From: Zubairuddin Mohammed Date: Wed, 7 Jan 2026 18:54:12 +0530 Subject: [PATCH 10/32] Rename changelog.md to CHANGELOG.md --- cli/azd/extensions/azure.ai.finetune/CHANGELOG.md | 3 +++ cli/azd/extensions/azure.ai.finetune/changelog.md | 3 --- 2 files changed, 3 insertions(+), 3 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.finetune/CHANGELOG.md delete mode 100644 cli/azd/extensions/azure.ai.finetune/changelog.md 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..fe26d749ffa --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/CHANGELOG.md @@ -0,0 +1,3 @@ +# Release History + +## 0.0.1 - Initial Version diff --git a/cli/azd/extensions/azure.ai.finetune/changelog.md b/cli/azd/extensions/azure.ai.finetune/changelog.md deleted file mode 100644 index b88d613cce0..00000000000 --- a/cli/azd/extensions/azure.ai.finetune/changelog.md +++ /dev/null @@ -1,3 +0,0 @@ -# Release History - -## 0.0.1 - Initial Version \ No newline at end of file From 5155d1c8e02134cdf3f619e8e2bebf173efc893d Mon Sep 17 00:00:00 2001 From: Zubairuddin Mohammed Date: Thu, 8 Jan 2026 14:30:19 +0530 Subject: [PATCH 11/32] adding copyright header text --- cli/azd/extensions/azure.ai.finetune/internal/utils/common.go | 2 ++ cli/azd/extensions/azure.ai.finetune/internal/utils/parser.go | 3 +++ 2 files changed, 5 insertions(+) diff --git a/cli/azd/extensions/azure.ai.finetune/internal/utils/common.go b/cli/azd/extensions/azure.ai.finetune/internal/utils/common.go index 491e04a4a22..8b80157f5f8 100644 --- a/cli/azd/extensions/azure.ai.finetune/internal/utils/common.go +++ b/cli/azd/extensions/azure.ai.finetune/internal/utils/common.go @@ -1,3 +1,5 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. package utils func IsLocalFilePath(fileID string) bool { diff --git a/cli/azd/extensions/azure.ai.finetune/internal/utils/parser.go b/cli/azd/extensions/azure.ai.finetune/internal/utils/parser.go index 8c487a1b7a2..c909b4d5545 100644 --- a/cli/azd/extensions/azure.ai.finetune/internal/utils/parser.go +++ b/cli/azd/extensions/azure.ai.finetune/internal/utils/parser.go @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + package utils import ( From a738c5f69aeb54bb2d41c3d83d3f337cea4c4e0c Mon Sep 17 00:00:00 2001 From: Zubairuddin Mohammed Date: Thu, 8 Jan 2026 14:37:11 +0530 Subject: [PATCH 12/32] applying pr review suggestion: remove providers section from extension.yaml Co-authored-by: JeffreyCA --- cli/azd/extensions/azure.ai.finetune/extension.yaml | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/cli/azd/extensions/azure.ai.finetune/extension.yaml b/cli/azd/extensions/azure.ai.finetune/extension.yaml index 7fb622dc7c2..7a5fd27263b 100644 --- a/cli/azd/extensions/azure.ai.finetune/extension.yaml +++ b/cli/azd/extensions/azure.ai.finetune/extension.yaml @@ -7,10 +7,6 @@ version: 0.0.8-preview language: go capabilities: - custom-commands -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. @@ -18,9 +14,3 @@ examples: - name: deploy description: Deploy AI fine-tuning job to Azure. usage: azd ai finetuning deploy - - - - - - From 16eab721dd6c0c9717cef23a18f90a36cc1205c7 Mon Sep 17 00:00:00 2001 From: Zubairuddin Mohammed Date: Thu, 8 Jan 2026 14:38:42 +0530 Subject: [PATCH 13/32] updating version.txt to 0.0.8-preview --- cli/azd/extensions/azure.ai.finetune/version.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/azd/extensions/azure.ai.finetune/version.txt b/cli/azd/extensions/azure.ai.finetune/version.txt index 97abdd882e0..58842435b07 100644 --- a/cli/azd/extensions/azure.ai.finetune/version.txt +++ b/cli/azd/extensions/azure.ai.finetune/version.txt @@ -1 +1 @@ -0.0.7-preview +0.0.8-preview From 4a60e3d48d78515dfbb1b709fa1341cda92e7a4c Mon Sep 17 00:00:00 2001 From: Zubairuddin Mohammed Date: Thu, 8 Jan 2026 14:43:23 +0530 Subject: [PATCH 14/32] simplying utils for file check --- .../azure.ai.finetune/internal/utils/common.go | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/cli/azd/extensions/azure.ai.finetune/internal/utils/common.go b/cli/azd/extensions/azure.ai.finetune/internal/utils/common.go index 8b80157f5f8..016fb813d78 100644 --- a/cli/azd/extensions/azure.ai.finetune/internal/utils/common.go +++ b/cli/azd/extensions/azure.ai.finetune/internal/utils/common.go @@ -2,14 +2,10 @@ // Licensed under the MIT License. package utils +import "strings" + func IsLocalFilePath(fileID string) bool { - if fileID == "" { - return false - } - if len(fileID) > 6 && fileID[:6] == "local:" { - return true - } - return false + return strings.HasPrefix(fileID, "local:") } func GetLocalFilePath(fileID string) string { From 3bcb9a6f778d00f4a2c46c59a5d404aa73db47d2 Mon Sep 17 00:00:00 2001 From: Zubairuddin Mohammed Date: Thu, 8 Jan 2026 14:44:35 +0530 Subject: [PATCH 15/32] reanming _uploadFile to uploadFile Co-authored-by: JeffreyCA --- .../azure.ai.finetune/internal/services/finetune_service.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index b257e9f9ad8..e4aa325ce97 100644 --- a/cli/azd/extensions/azure.ai.finetune/internal/services/finetune_service.go +++ b/cli/azd/extensions/azure.ai.finetune/internal/services/finetune_service.go @@ -202,14 +202,14 @@ func (s *fineTuningServiceImpl) UploadFile(ctx context.Context, filePath string) if filePath == "" { return "", fmt.Errorf("file path cannot be empty") } - uploadedFileId, err := s._uploadFile(ctx, filePath) + uploadedFileId, err := s.uploadFile(ctx, filePath) if err != nil || uploadedFileId == "" { return "", fmt.Errorf("failed to upload file: %w", err) } return uploadedFileId, nil } -func (s *fineTuningServiceImpl) _uploadFile(ctx context.Context, filePath string) (string, error) { +func (s *fineTuningServiceImpl) uploadFile(ctx context.Context, filePath string) (string, error) { // validate file existence fileInfo, err := os.Stat(filePath) if err != nil { From 49ce76eb08c62901cb5f30cecaad1cee80ecd70f Mon Sep 17 00:00:00 2001 From: Zubairuddin Mohammed Date: Thu, 8 Jan 2026 14:50:19 +0530 Subject: [PATCH 16/32] removing duplicate import --- .../internal/services/finetune_service.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) 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 index e4aa325ce97..b0e8e8d9690 100644 --- a/cli/azd/extensions/azure.ai.finetune/internal/services/finetune_service.go +++ b/cli/azd/extensions/azure.ai.finetune/internal/services/finetune_service.go @@ -11,7 +11,6 @@ import ( "azure.ai.finetune/internal/providers" "azure.ai.finetune/internal/providers/factory" "azure.ai.finetune/internal/utils" - Utils "azure.ai.finetune/internal/utils" "azure.ai.finetune/pkg/models" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/fatih/color" @@ -54,10 +53,10 @@ func (s *fineTuningServiceImpl) CreateFineTuningJob(ctx context.Context, req *mo return nil, fmt.Errorf("training file is required") } - if Utils.IsLocalFilePath(req.TrainingFile) { + if utils.IsLocalFilePath(req.TrainingFile) { color.Green("\nuploading training file...") - trainingDataID, err := s.UploadFile(ctx, Utils.GetLocalFilePath(req.TrainingFile)) + trainingDataID, err := s.UploadFile(ctx, utils.GetLocalFilePath(req.TrainingFile)) if err != nil { return nil, fmt.Errorf("failed to upload training file: %w", err) } @@ -68,9 +67,9 @@ func (s *fineTuningServiceImpl) CreateFineTuningJob(ctx context.Context, req *mo // Upload validation file if provided if req.ValidationFile != nil && *req.ValidationFile != "" { - if Utils.IsLocalFilePath(*req.ValidationFile) { + if utils.IsLocalFilePath(*req.ValidationFile) { color.Green("\nuploading validation file...") - validationDataID, err := s.UploadFile(ctx, Utils.GetLocalFilePath(*req.ValidationFile)) + validationDataID, err := s.UploadFile(ctx, utils.GetLocalFilePath(*req.ValidationFile)) if err != nil { return nil, fmt.Errorf("failed to upload validation file: %w", err) } From f54c0fe95b211031ba9901c61069514e30c3af26 Mon Sep 17 00:00:00 2001 From: Zubairuddin Mohammed Date: Thu, 8 Jan 2026 15:21:16 +0530 Subject: [PATCH 17/32] removing unnecessary todo --- cli/azd/extensions/azure.ai.finetune/main.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/cli/azd/extensions/azure.ai.finetune/main.go b/cli/azd/extensions/azure.ai.finetune/main.go index c28d629a439..70b4dee7748 100644 --- a/cli/azd/extensions/azure.ai.finetune/main.go +++ b/cli/azd/extensions/azure.ai.finetune/main.go @@ -1,7 +1,5 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -// TODO: Remove -// Trivial change to test pipeline package main import ( From d8664b4feead83d5cc3e02f1070065e6cc3241e0 Mon Sep 17 00:00:00 2001 From: Zubairuddin Mohammed Date: Thu, 8 Jan 2026 15:30:34 +0530 Subject: [PATCH 18/32] Adding cspell dict --- cli/azd/extensions/azure.ai.finetune/cspell.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 cli/azd/extensions/azure.ai.finetune/cspell.yaml diff --git a/cli/azd/extensions/azure.ai.finetune/cspell.yaml b/cli/azd/extensions/azure.ai.finetune/cspell.yaml new file mode 100644 index 00000000000..6f2423ef132 --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/cspell.yaml @@ -0,0 +1,9 @@ +words: + - azureprovider + - finetune + - finetuning + - ftjob + - hyperparameters + - openaiprovider + - openaisdk + - wandb \ No newline at end of file From d11851d700bdb6b7d817413f57df38bb910f7262 Mon Sep 17 00:00:00 2001 From: Zubairuddin Mohammed Date: Thu, 8 Jan 2026 15:33:21 +0530 Subject: [PATCH 19/32] updating cspell.yaml --- cli/azd/extensions/azure.ai.finetune/cspell.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cli/azd/extensions/azure.ai.finetune/cspell.yaml b/cli/azd/extensions/azure.ai.finetune/cspell.yaml index 6f2423ef132..ac97ecedf2a 100644 --- a/cli/azd/extensions/azure.ai.finetune/cspell.yaml +++ b/cli/azd/extensions/azure.ai.finetune/cspell.yaml @@ -6,4 +6,5 @@ words: - hyperparameters - openaiprovider - openaisdk - - wandb \ No newline at end of file + - wandb + - Finetune \ No newline at end of file From 9c398570fea14aba50b85368137957ed8c94aac7 Mon Sep 17 00:00:00 2001 From: Zubairuddin Mohammed Date: Thu, 8 Jan 2026 15:37:31 +0530 Subject: [PATCH 20/32] updating cspell.yaml --- cli/azd/extensions/azure.ai.finetune/cspell.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cli/azd/extensions/azure.ai.finetune/cspell.yaml b/cli/azd/extensions/azure.ai.finetune/cspell.yaml index ac97ecedf2a..6f2423ef132 100644 --- a/cli/azd/extensions/azure.ai.finetune/cspell.yaml +++ b/cli/azd/extensions/azure.ai.finetune/cspell.yaml @@ -6,5 +6,4 @@ words: - hyperparameters - openaiprovider - openaisdk - - wandb - - Finetune \ No newline at end of file + - wandb \ No newline at end of file From 59530faab9bacec66f3cffe1ac6b073717b9108a Mon Sep 17 00:00:00 2001 From: Zubairuddin Mohammed Date: Thu, 8 Jan 2026 15:40:35 +0530 Subject: [PATCH 21/32] Updating finetune word in global cspell.yaml --- cli/azd/.vscode/cspell.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/cli/azd/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index f369c560619..9f919df736d 100644 --- a/cli/azd/.vscode/cspell.yaml +++ b/cli/azd/.vscode/cspell.yaml @@ -45,6 +45,7 @@ words: - jsonschema - rustc - figspec + - finetune languageSettings: - languageId: go ignoreRegExpList: From 439c5b844a0059722b3457fd4406ae7bc9335f22 Mon Sep 17 00:00:00 2001 From: Zubairuddin Mohammed Date: Fri, 9 Jan 2026 09:54:52 +0530 Subject: [PATCH 22/32] PR Review Changes: Updating ParseGithubUrl func (#6476) * Updating parseGithubUrl func * updating resource prompt logic * copilot suggestion to remove unused variable Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * gh copilot suggestion Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- cli/azd/extensions/azure.ai.finetune/go.mod | 15 +- cli/azd/extensions/azure.ai.finetune/go.sum | 30 ++-- .../azure.ai.finetune/internal/cmd/init.go | 131 ++++++------------ 3 files changed, 62 insertions(+), 114 deletions(-) diff --git a/cli/azd/extensions/azure.ai.finetune/go.mod b/cli/azd/extensions/azure.ai.finetune/go.mod index 199acd61bbd..245759a75d1 100644 --- a/cli/azd/extensions/azure.ai.finetune/go.mod +++ b/cli/azd/extensions/azure.ai.finetune/go.mod @@ -6,8 +6,8 @@ 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/azure/azure-dev/cli/azd v0.0.0-20260108010518-45071d9a61bc + github.com/braydonk/yaml v0.9.0 github.com/fatih/color v1.18.0 github.com/openai/openai-go/v3 v3.2.0 github.com/spf13/cobra v1.10.1 @@ -24,7 +24,6 @@ require ( 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 @@ -79,11 +78,11 @@ require ( 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 + golang.org/x/crypto v0.45.0 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/term v0.37.0 // indirect + golang.org/x/text v0.31.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 diff --git a/cli/azd/extensions/azure.ai.finetune/go.sum b/cli/azd/extensions/azure.ai.finetune/go.sum index b82a7c3a7f1..76f5dddebdf 100644 --- a/cli/azd/extensions/azure.ai.finetune/go.sum +++ b/cli/azd/extensions/azure.ai.finetune/go.sum @@ -13,12 +13,6 @@ github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDo 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= @@ -41,8 +35,8 @@ github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWp 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/azure/azure-dev/cli/azd v0.0.0-20260108010518-45071d9a61bc h1:Wei/1LT53Ojk96VwCL2SEXU9HxVtqU+a2DdAqYsLQlQ= +github.com/azure/azure-dev/cli/azd v0.0.0-20260108010518-45071d9a61bc/go.mod h1:j+bdvNwQPdYtSfFe/xbfWqYr8Guw9hiP1JOVpIBERj0= 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= @@ -228,8 +222,8 @@ 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/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= 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= @@ -237,8 +231,8 @@ golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73r 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/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= 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= @@ -253,18 +247,18 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc 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/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.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/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= 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/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= 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= diff --git a/cli/azd/extensions/azure.ai.finetune/internal/cmd/init.go b/cli/azd/extensions/azure.ai.finetune/internal/cmd/init.go index 0ef4edabe3a..cc211665842 100644 --- a/cli/azd/extensions/azure.ai.finetune/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.finetune/internal/cmd/init.go @@ -14,6 +14,7 @@ import ( "strings" "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "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" @@ -463,17 +464,28 @@ func ensureAzureContext( if envValueMap["AZURE_ACCOUNT_NAME"] == "" { - aiAccountResponse, err := azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ - Options: &azdext.PromptOptions{ - Message: "Please enter your Azure AI Account name", + foundryProjectResponse, err := azdClient.Prompt().PromptResourceGroupResource(ctx, &azdext.PromptResourceGroupResourceRequest{ + AzureContext: azureContext, + Options: &azdext.PromptResourceOptions{ + ResourceType: "Microsoft.CognitiveServices/accounts/projects", + ResourceTypeDisplayName: "AI Foundry project", + SelectOptions: &azdext.PromptResourceSelectOptions{ + AllowNewResource: to.Ptr(false), + Message: "Select a Foundry project", + LoadingMessage: "Fetching Foundry projects...", + }, }, }) - aiProjectName, err := azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ - Options: &azdext.PromptOptions{ - Message: "Please enter your Azure AI Project name", - }, - }) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to get Microsoft Foundry project: %w", err) + } + + fpDetails, err := extractProjectDetails(foundryProjectResponse.Resource.Id) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to parse Microsoft Foundry project ID: %w", err) + } + credential, err := azidentity.NewAzureDeveloperCLICredential(&azidentity.AzureDeveloperCLICredentialOptions{ TenantID: azureContext.Scope.TenantId, AdditionallyAllowedTenants: []string{"*"}, @@ -489,7 +501,7 @@ func ensureAzureContext( } // Get the Microsoft Foundry project - projectResp, err := projectsClient.Get(ctx, azureContext.Scope.ResourceGroup, aiAccountResponse.Value, aiProjectName.Value, nil) + projectResp, err := projectsClient.Get(ctx, azureContext.Scope.ResourceGroup, fpDetails.AiAccountName, fpDetails.AiProjectName, nil) if err != nil { return nil, nil, nil, fmt.Errorf("failed to get Microsoft Foundry project: %w", err) } @@ -498,7 +510,7 @@ func ensureAzureContext( _, err = azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ EnvName: env.Name, Key: "AZURE_ACCOUNT_NAME", - Value: aiAccountResponse.Value, + Value: fpDetails.AiAccountName, }) location := *projectResp.Location @@ -619,17 +631,33 @@ method: return fmt.Errorf("creating GitHub CLI: %w", err) } - urlInfo, err = parseGitHubUrl(a.flags.template) + // 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() + + // Call the ParseGitHubUrl RPC method + parseResponse, err := azdClient.Project().ParseGitHubUrl(ctx, &azdext.ParseGitHubUrlRequest{ + Url: a.flags.template, + }) if err != nil { - return err + return fmt.Errorf("parsing GitHub URL via azd extension: %w", err) + } + + // Map the response to GitHubUrlInfo + urlInfo = &GitHubUrlInfo{ + RepoSlug: parseResponse.RepoSlug, + Branch: parseResponse.Branch, + FilePath: parseResponse.FilePath, + Hostname: parseResponse.Hostname, } - 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) + err = downloadParentDirectory(ctx, urlInfo, cwd, ghCli, console) if err != nil { return fmt.Errorf("downloading parent directory: %w", err) } @@ -720,79 +748,6 @@ method: 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) From 6d1becbd5a8356af0b8a1793ee126fa1f549c855 Mon Sep 17 00:00:00 2001 From: Amit Chauhan <70937115+achauhan-scc@users.noreply.github.com> Date: Fri, 9 Jan 2026 10:53:53 +0530 Subject: [PATCH 23/32] Updating extension name in read me file. (#6483) * adding init command * update version * cleaning some code * updating readme --- cli/azd/extensions/azure.ai.finetune/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/azd/extensions/azure.ai.finetune/README.md b/cli/azd/extensions/azure.ai.finetune/README.md index 09d2eb6b8e8..fc3b38c37eb 100644 --- a/cli/azd/extensions/azure.ai.finetune/README.md +++ b/cli/azd/extensions/azure.ai.finetune/README.md @@ -1,3 +1,3 @@ -# `azd` Demo Extension +# `azd` Finetune Extension -An AZD Demo extension +An AZD Finetune extension From 6d288aab9c1d65f3ac1b6a533f101e9ebd5a9496 Mon Sep 17 00:00:00 2001 From: Zubairuddin Mohammed Date: Fri, 9 Jan 2026 11:00:05 +0530 Subject: [PATCH 24/32] resolving review comments --- .../azure.ai.finetune/internal/cmd/init.go | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/cli/azd/extensions/azure.ai.finetune/internal/cmd/init.go b/cli/azd/extensions/azure.ai.finetune/internal/cmd/init.go index cc211665842..ce7fa1473e0 100644 --- a/cli/azd/extensions/azure.ai.finetune/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.finetune/internal/cmd/init.go @@ -86,11 +86,6 @@ func newInitCommand(rootFlags rootFlagsDefinition) *cobra.Command { 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{"*"}, @@ -179,21 +174,25 @@ func extractProjectDetails(projectResourceId string) (*FoundryProject, error) { }, nil } -func getExistingEnvironment(ctx context.Context, flags *initFlags, azdClient *azdext.AzdClient) *azdext.Environment { +func getExistingEnvironment(ctx context.Context, flags *initFlags, azdClient *azdext.AzdClient) (*azdext.Environment, error) { var env *azdext.Environment if flags.env == "" { - if envResponse, err := azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}); err == nil { - env = envResponse.Environment + envResponse, err := azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}) + if err != nil { + return nil, fmt.Errorf("failed to get current environment: %w", err) } + env = envResponse.Environment } else { - if envResponse, err := azdClient.Environment().Get(ctx, &azdext.GetEnvironmentRequest{ + envResponse, err := azdClient.Environment().Get(ctx, &azdext.GetEnvironmentRequest{ Name: flags.env, - }); err == nil { - env = envResponse.Environment + }) + if err != nil { + return nil, fmt.Errorf("failed to get environment '%s': %w", flags.env, err) } + env = envResponse.Environment } - return env + return env, nil } func ensureEnvironment(ctx context.Context, flags *initFlags, azdClient *azdext.AzdClient) (*azdext.Environment, error) { @@ -239,7 +238,10 @@ func ensureEnvironment(ctx context.Context, flags *initFlags, azdClient *azdext. } // Get specified or current environment if it exists - existingEnv := getExistingEnvironment(ctx, flags, azdClient) + existingEnv, err := getExistingEnvironment(ctx, flags, azdClient) + if err != nil { + return nil, fmt.Errorf("failed to get existing environment: %w", err) + } 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.") @@ -271,9 +273,9 @@ func ensureEnvironment(ctx context.Context, flags *initFlags, azdClient *azdext. } // 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") + existingEnv, err = getExistingEnvironment(ctx, flags, azdClient) + if err != nil { + return nil, fmt.Errorf("failed to get environment after creation: %w", err) } } From 9a7c06d6db578bf575be64b90b9beb231ee1e9ec Mon Sep 17 00:00:00 2001 From: Zubairuddin Mohammed Date: Fri, 9 Jan 2026 11:21:42 +0530 Subject: [PATCH 25/32] resolving review comments --- .../azure.ai.finetune/internal/cmd/init.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/cli/azd/extensions/azure.ai.finetune/internal/cmd/init.go b/cli/azd/extensions/azure.ai.finetune/internal/cmd/init.go index ce7fa1473e0..f4beb19ad84 100644 --- a/cli/azd/extensions/azure.ai.finetune/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.finetune/internal/cmd/init.go @@ -174,9 +174,9 @@ func extractProjectDetails(projectResourceId string) (*FoundryProject, error) { }, nil } -func getExistingEnvironment(ctx context.Context, flags *initFlags, azdClient *azdext.AzdClient) (*azdext.Environment, error) { +func getExistingEnvironment(ctx context.Context, name *string, azdClient *azdext.AzdClient) (*azdext.Environment, error) { var env *azdext.Environment - if flags.env == "" { + if name == nil || *name == "" { envResponse, err := azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}) if err != nil { return nil, fmt.Errorf("failed to get current environment: %w", err) @@ -184,10 +184,10 @@ func getExistingEnvironment(ctx context.Context, flags *initFlags, azdClient *az env = envResponse.Environment } else { envResponse, err := azdClient.Environment().Get(ctx, &azdext.GetEnvironmentRequest{ - Name: flags.env, + Name: *name, }) if err != nil { - return nil, fmt.Errorf("failed to get environment '%s': %w", flags.env, err) + return nil, fmt.Errorf("failed to get environment '%s': %w", *name, err) } env = envResponse.Environment } @@ -238,7 +238,7 @@ func ensureEnvironment(ctx context.Context, flags *initFlags, azdClient *azdext. } // Get specified or current environment if it exists - existingEnv, err := getExistingEnvironment(ctx, flags, azdClient) + existingEnv, err := getExistingEnvironment(ctx, &flags.env, azdClient) if err != nil { return nil, fmt.Errorf("failed to get existing environment: %w", err) } @@ -273,7 +273,7 @@ func ensureEnvironment(ctx context.Context, flags *initFlags, azdClient *azdext. } // Re-fetch the environment after creation - existingEnv, err = getExistingEnvironment(ctx, flags, azdClient) + existingEnv, err = getExistingEnvironment(ctx, &flags.env, azdClient) if err != nil { return nil, fmt.Errorf("failed to get environment after creation: %w", err) } From 92d7b8c34b05ffada65050077b644477fdf1b8ff Mon Sep 17 00:00:00 2001 From: Zubairuddin Mohammed Date: Fri, 9 Jan 2026 11:38:37 +0530 Subject: [PATCH 26/32] using arm resourceId parser --- .../azure.ai.finetune/internal/cmd/init.go | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/cli/azd/extensions/azure.ai.finetune/internal/cmd/init.go b/cli/azd/extensions/azure.ai.finetune/internal/cmd/init.go index f4beb19ad84..0928f11b7f9 100644 --- a/cli/azd/extensions/azure.ai.finetune/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.finetune/internal/cmd/init.go @@ -10,10 +10,10 @@ import ( "net/url" "os" "path/filepath" - "regexp" "strings" "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "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/cognitiveservices/armcognitiveservices" @@ -152,25 +152,23 @@ type FoundryProject struct { } 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) + resourceId, err := arm.ParseResourceID(projectResourceId) if err != nil { - return nil, fmt.Errorf("failed to compile regex pattern: %w", err) + return nil, fmt.Errorf("failed to parse project resource ID: %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]") + // Validate that this is a Cognitive Services project resource + if resourceId.ResourceType.Namespace != "Microsoft.CognitiveServices" || len(resourceId.ResourceType.Types) != 2 || + resourceId.ResourceType.Types[0] != "accounts" || resourceId.ResourceType.Types[1] != "projects" { + return nil, fmt.Errorf("the given resource ID is not a Microsoft Foundry project. 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], + SubscriptionId: resourceId.SubscriptionID, + ResourceGroupName: resourceId.ResourceGroupName, + AiAccountName: resourceId.Parent.Name, + AiProjectName: resourceId.Name, }, nil } From da93fca855d091dbcc83d85753f5ea29d50bee35 Mon Sep 17 00:00:00 2001 From: Zubairuddin Mohammed Date: Fri, 9 Jan 2026 17:23:51 +0530 Subject: [PATCH 27/32] Finetuning CLU - Using OSS package go-retry instead of custom logic (#6485) * Using oss go-retry package * fixing retry count --- .../azure.ai.finetune/internal/utils/retry.go | 67 +++++-------------- 1 file changed, 17 insertions(+), 50 deletions(-) diff --git a/cli/azd/extensions/azure.ai.finetune/internal/utils/retry.go b/cli/azd/extensions/azure.ai.finetune/internal/utils/retry.go index 7ed7465d158..4f2c30b30f6 100644 --- a/cli/azd/extensions/azure.ai.finetune/internal/utils/retry.go +++ b/cli/azd/extensions/azure.ai.finetune/internal/utils/retry.go @@ -5,8 +5,9 @@ package utils import ( "context" - "fmt" "time" + + "github.com/sethvargo/go-retry" ) const ( @@ -16,59 +17,25 @@ const ( DefaultDelaySeconds = 2 ) -// RetryConfig holds configuration for retry operations -type RetryConfig struct { - MaxAttempts int - Delay time.Duration - BackoffFunc func(attempt int, delay time.Duration) time.Duration -} - -// DefaultRetryConfig returns a sensible default retry configuration -func DefaultRetryConfig() *RetryConfig { - return &RetryConfig{ - MaxAttempts: DefaultMaxAttempts, - Delay: DefaultDelaySeconds * time.Second, - BackoffFunc: func(attempt int, delay time.Duration) time.Duration { - // Exponential backoff: 2s, 4s, 8s - return delay * time.Duration(1<<(attempt-1)) - }, - } +// DefaultRetryConfig returns a default exponential backoff strategy +func DefaultRetryConfig() retry.Backoff { + return retry.WithMaxRetries( + DefaultMaxAttempts-1, + retry.NewExponential(DefaultDelaySeconds*time.Second), + ) } // RetryOperation executes the given operation with retry logic -// The operation should return an error if it should be retried -func RetryOperation(ctx context.Context, config *RetryConfig, operation func() error) error { - if config == nil { - config = DefaultRetryConfig() +// All errors returned by the operation are considered retryable +func RetryOperation(ctx context.Context, backoff retry.Backoff, operation func() error) error { + if backoff == nil { + backoff = DefaultRetryConfig() } - var lastErr error - - for attempt := 1; attempt <= config.MaxAttempts; attempt++ { - // Execute the operation - err := operation() - if err == nil { - return nil // Success! + return retry.Do(ctx, backoff, func(ctx context.Context) error { + if err := operation(); err != nil { + return retry.RetryableError(err) } - - lastErr = err - - // If this was the last attempt, don't wait - if attempt == config.MaxAttempts { - break - } - - // Calculate delay for this attempt - delay := config.BackoffFunc(attempt, config.Delay) - - // Wait before retrying, respecting context cancellation - select { - case <-time.After(delay): - // Continue to next attempt - case <-ctx.Done(): - return fmt.Errorf("operation cancelled: %w", ctx.Err()) - } - } - - return lastErr + return nil + }) } From e8962f5be6be03923be9f2d288cfdd15283f57cc Mon Sep 17 00:00:00 2001 From: Amit Chauhan <70937115+achauhan-scc@users.noreply.github.com> Date: Fri, 9 Jan 2026 21:09:50 +0530 Subject: [PATCH 28/32] Allowing user to override the fine tuning route (#6484) * adding init command * update version * cleaning some code * updating readme * allowing fine tuning routes to be overriden by envrionment variables --- .../azure.ai.finetune/internal/cmd/init.go | 15 +++++ .../providers/factory/provider_factory.go | 61 +++++++++++++++++-- .../internal/utils/environment.go | 12 ++-- 3 files changed, 79 insertions(+), 9 deletions(-) diff --git a/cli/azd/extensions/azure.ai.finetune/internal/cmd/init.go b/cli/azd/extensions/azure.ai.finetune/internal/cmd/init.go index 0928f11b7f9..de7262ea552 100644 --- a/cli/azd/extensions/azure.ai.finetune/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.finetune/internal/cmd/init.go @@ -316,6 +316,15 @@ func ensureEnvironment(ctx context.Context, flags *initFlags, azdClient *azdext. return nil, fmt.Errorf("failed to set AZURE_ACCOUNT_NAME in azd environment: %w", err) } + _, err = azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ + EnvName: existingEnv.Name, + Key: "AZURE_PROJECT_NAME", + Value: foundryProject.AiProjectName, + }) + if err != nil { + return nil, fmt.Errorf("failed to set AZURE_PROJECT_NAME in azd environment: %w", err) + } + _, err = azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ EnvName: existingEnv.Name, Key: "AZURE_LOCATION", @@ -513,6 +522,12 @@ func ensureAzureContext( Value: fpDetails.AiAccountName, }) + _, err = azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ + EnvName: env.Name, + Key: "AZURE_PROJECT_NAME", + Value: fpDetails.AiProjectName, + }) + location := *projectResp.Location // Set the location in the environment diff --git a/cli/azd/extensions/azure.ai.finetune/internal/providers/factory/provider_factory.go b/cli/azd/extensions/azure.ai.finetune/internal/providers/factory/provider_factory.go index 0294287189e..d29413dddd1 100644 --- a/cli/azd/extensions/azure.ai.finetune/internal/providers/factory/provider_factory.go +++ b/cli/azd/extensions/azure.ai.finetune/internal/providers/factory/provider_factory.go @@ -6,25 +6,28 @@ package factory import ( "context" "fmt" + "net/http" "azure.ai.finetune/internal/providers" azureprovider "azure.ai.finetune/internal/providers/azure" openaiprovider "azure.ai.finetune/internal/providers/openai" "azure.ai.finetune/internal/utils" "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" "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/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" + DefaultApiVersion = "2025-11-15-preview" // Azure cognitive services endpoint URL pattern - azureCognitiveServicesEndpoint = "https://%s.cognitiveservices.azure.com/openai" + DefaultCognitiveServicesEndpoint = "https://%s.services.ai.azure.com/api/projects/%s" + DefaultAzureFinetuningScope = "https://ai.azure.com/.default" ) func GetOpenAIClientFromAzdClient(ctx context.Context, azdClient *azdext.AzdClient) (*openai.Client, error) { @@ -53,17 +56,57 @@ func GetOpenAIClientFromAzdClient(ctx context.Context, azdClient *azdext.AzdClie // Get Azure credentials and endpoint - TODO // You'll need to get these from your environment or config accountName := envValueMap[utils.EnvAzureAccountName] - endpoint := fmt.Sprintf(azureCognitiveServicesEndpoint, accountName) + projectName := envValueMap[utils.EnvAzureOpenAIProjectName] + endpoint := envValueMap[utils.EnvFinetuningRoute] + if endpoint == "" { + endpoint = fmt.Sprintf(DefaultCognitiveServicesEndpoint, accountName, projectName) + } + + apiVersion := envValueMap[utils.EnvAPIVersion] + if apiVersion == "" { + apiVersion = DefaultApiVersion + } + + scope := envValueMap[utils.EnvFineturningTokenScope] + if scope == "" { + scope = DefaultAzureFinetuningScope + } // Create OpenAI client client := openai.NewClient( //azure.WithEndpoint(endpoint, apiVersion), option.WithBaseURL(endpoint), option.WithQuery("api-version", apiVersion), - azure.WithTokenCredential(credential), + WithTokenCredential(credential, scope), ) return &client, nil } +// WithTokenCredential configures this client to authenticate using an [Azure Identity] TokenCredential. +// This function should be paired with a call to [WithEndpoint] to point to your Azure OpenAI instance. +// +// [Azure Identity]: https://pkg.go.dev/github.com/Azure/azure-sdk-for-go/sdk/azidentity +func WithTokenCredential(tokenCredential azcore.TokenCredential, scope string) option.RequestOption { + bearerTokenPolicy := runtime.NewBearerTokenPolicy(tokenCredential, []string{scope}, nil) + // add in a middleware that uses the bearer token generated from the token credential + return option.WithMiddleware(func(req *http.Request, next option.MiddlewareNext) (*http.Response, error) { + pipeline := runtime.NewPipeline("azopenai-extensions", version, runtime.PipelineOptions{}, &policy.ClientOptions{ + InsecureAllowCredentialWithHTTP: true, // allow for plain HTTP proxies, etc.. + PerRetryPolicies: []policy.Policy{ + bearerTokenPolicy, + policyAdapter(next), + }, + }) + + req2, err := runtime.NewRequestFromRequest(req) + + if err != nil { + return nil, err + } + + return pipeline.Do(req2) + }) +} + // NewFineTuningProvider creates a FineTuningProvider based on provider type func NewFineTuningProvider(ctx context.Context, azdClient *azdext.AzdClient) (providers.FineTuningProvider, error) { client, err := GetOpenAIClientFromAzdClient(ctx, azdClient) @@ -82,3 +125,11 @@ func NewModelDeploymentProvider(subscriptionId string, credential azcore.TokenCr } return azureprovider.NewAzureProvider(clientFactory), err } + +type policyAdapter option.MiddlewareNext + +func (mp policyAdapter) Do(req *policy.Request) (*http.Response, error) { + return (option.MiddlewareNext)(mp)(req.Raw()) +} + +const version = "v.0.1.0" diff --git a/cli/azd/extensions/azure.ai.finetune/internal/utils/environment.go b/cli/azd/extensions/azure.ai.finetune/internal/utils/environment.go index 6e5b3c81204..e3e177f4c4c 100644 --- a/cli/azd/extensions/azure.ai.finetune/internal/utils/environment.go +++ b/cli/azd/extensions/azure.ai.finetune/internal/utils/environment.go @@ -11,10 +11,14 @@ import ( ) const ( - EnvAzureTenantID = "AZURE_TENANT_ID" - EnvAzureSubscriptionID = "AZURE_SUBSCRIPTION_ID" - EnvAzureLocation = "AZURE_LOCATION" - EnvAzureAccountName = "AZURE_ACCOUNT_NAME" + EnvAzureTenantID = "AZURE_TENANT_ID" + EnvAzureSubscriptionID = "AZURE_SUBSCRIPTION_ID" + EnvAzureLocation = "AZURE_LOCATION" + EnvAzureAccountName = "AZURE_ACCOUNT_NAME" + EnvAzureOpenAIProjectName = "AZURE_PROJECT_NAME" + EnvAPIVersion = "AZURE_API_VERSION" + EnvFinetuningRoute = "AZURE_FINETUNING_ROUTE" + EnvFineturningTokenScope = "AZURE_FINETUNING_TOKEN_SCOPE" ) // GetEnvironmentValues retrieves Azure environment configuration from azd client. From 6cd886a921003b09a7d6d5395b111fc1e65bc903 Mon Sep 17 00:00:00 2001 From: Amit Chauhan <70937115+achauhan-scc@users.noreply.github.com> Date: Fri, 9 Jan 2026 21:25:18 +0530 Subject: [PATCH 29/32] spell check fix (#6486) * adding init command * update version * cleaning some code * updating readme * allowing fine tuning routes to be overriden by envrionment variables * fixing spell check issues --- .../internal/providers/factory/provider_factory.go | 4 ++-- .../azure.ai.finetune/internal/utils/environment.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/azd/extensions/azure.ai.finetune/internal/providers/factory/provider_factory.go b/cli/azd/extensions/azure.ai.finetune/internal/providers/factory/provider_factory.go index d29413dddd1..969bec2f43c 100644 --- a/cli/azd/extensions/azure.ai.finetune/internal/providers/factory/provider_factory.go +++ b/cli/azd/extensions/azure.ai.finetune/internal/providers/factory/provider_factory.go @@ -67,7 +67,7 @@ func GetOpenAIClientFromAzdClient(ctx context.Context, azdClient *azdext.AzdClie apiVersion = DefaultApiVersion } - scope := envValueMap[utils.EnvFineturningTokenScope] + scope := envValueMap[utils.EnvFinetuningTokenScope] if scope == "" { scope = DefaultAzureFinetuningScope } @@ -89,7 +89,7 @@ func WithTokenCredential(tokenCredential azcore.TokenCredential, scope string) o bearerTokenPolicy := runtime.NewBearerTokenPolicy(tokenCredential, []string{scope}, nil) // add in a middleware that uses the bearer token generated from the token credential return option.WithMiddleware(func(req *http.Request, next option.MiddlewareNext) (*http.Response, error) { - pipeline := runtime.NewPipeline("azopenai-extensions", version, runtime.PipelineOptions{}, &policy.ClientOptions{ + pipeline := runtime.NewPipeline("finetune-extensions", version, runtime.PipelineOptions{}, &policy.ClientOptions{ InsecureAllowCredentialWithHTTP: true, // allow for plain HTTP proxies, etc.. PerRetryPolicies: []policy.Policy{ bearerTokenPolicy, diff --git a/cli/azd/extensions/azure.ai.finetune/internal/utils/environment.go b/cli/azd/extensions/azure.ai.finetune/internal/utils/environment.go index e3e177f4c4c..75c27f47251 100644 --- a/cli/azd/extensions/azure.ai.finetune/internal/utils/environment.go +++ b/cli/azd/extensions/azure.ai.finetune/internal/utils/environment.go @@ -18,7 +18,7 @@ const ( EnvAzureOpenAIProjectName = "AZURE_PROJECT_NAME" EnvAPIVersion = "AZURE_API_VERSION" EnvFinetuningRoute = "AZURE_FINETUNING_ROUTE" - EnvFineturningTokenScope = "AZURE_FINETUNING_TOKEN_SCOPE" + EnvFinetuningTokenScope = "AZURE_FINETUNING_TOKEN_SCOPE" ) // GetEnvironmentValues retrieves Azure environment configuration from azd client. From a0a6cc43d3494c4e617179b26fdfefac6c38ecca Mon Sep 17 00:00:00 2001 From: Saanika Gupta Date: Mon, 12 Jan 2026 10:41:27 +0530 Subject: [PATCH 30/32] Initial changes to add utils for output formatting - json, yaml, table --- .../internal/cmd/operations.go | 13 + .../internal/utils/output.go | 272 ++++++++++++++++++ .../azure.ai.finetune/pkg/models/finetune.go | 64 ++--- 3 files changed, 317 insertions(+), 32 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.finetune/internal/utils/output.go diff --git a/cli/azd/extensions/azure.ai.finetune/internal/cmd/operations.go b/cli/azd/extensions/azure.ai.finetune/internal/cmd/operations.go index 59fc90a1c51..19e814b5fa8 100644 --- a/cli/azd/extensions/azure.ai.finetune/internal/cmd/operations.go +++ b/cli/azd/extensions/azure.ai.finetune/internal/cmd/operations.go @@ -211,6 +211,17 @@ func newOperationShowCommand() *cobra.Command { fmt.Printf(" Learning Rate Multiplier: %f\n", job.Hyperparameters.LearningRateMultiplier) fmt.Printf(" N Epochs: %d\n", job.Hyperparameters.NEpochs) } + fmt.Println("saa1") + utils.PrintObject(job, utils.FormatTable) + if job.Hyperparameters != nil { + fmt.Println("\nConfiguration:") + utils.PrintObjectWithIndent(job.Hyperparameters, utils.FormatTable, " ") + } + fmt.Println("saa2") + utils.PrintObject(job, utils.FormatJSON) + fmt.Println("saa3") + utils.PrintObject(job, utils.FormatYAML) + fmt.Println("saa4") // Fetch and print events eventsSpinner := ux.NewSpinner(&ux.SpinnerOptions{ @@ -327,6 +338,8 @@ func newOperationListCommand() *cobra.Command { fmt.Printf("\nTotal jobs: %d\n", len(jobs)) + utils.PrintObject(jobs, utils.FormatTable) + utils.PrintObject(jobs, utils.FormatJSON) return nil }, } diff --git a/cli/azd/extensions/azure.ai.finetune/internal/utils/output.go b/cli/azd/extensions/azure.ai.finetune/internal/utils/output.go new file mode 100644 index 00000000000..1cc9ada93f1 --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/internal/utils/output.go @@ -0,0 +1,272 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package utils + +import ( + "encoding/json" + "fmt" + "os" + "reflect" + "strings" + "text/tabwriter" + + "gopkg.in/yaml.v3" +) + +// OutputFormat represents the output format type +type OutputFormat string + +const ( + FormatTable OutputFormat = "table" + FormatJSON OutputFormat = "json" + FormatYAML OutputFormat = "yaml" +) + +// PrintObject prints a struct or slice in the specified format +func PrintObject(obj interface{}, format OutputFormat) error { + switch format { + case FormatJSON: + return printJSON(obj) + case FormatYAML: + return printYAML(obj) + case FormatTable: + return printTable(obj) + default: + return fmt.Errorf("unsupported format: %s", format) + } +} + +// printJSON uses encoding/json which respects `json` tags +func printJSON(obj interface{}) error { + data, err := json.MarshalIndent(obj, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Println(string(data)) + return nil +} + +// printYAML uses gopkg.in/yaml.v3 which respects `yaml` tags +func printYAML(obj interface{}) error { + data, err := yaml.Marshal(obj) + if err != nil { + return fmt.Errorf("failed to marshal YAML: %w", err) + } + fmt.Print(string(data)) + return nil +} + +// printTable uses text/tabwriter and reads `table` tags +func printTable(obj interface{}) error { + v := reflect.ValueOf(obj) + if v.Kind() == reflect.Ptr { + v = v.Elem() + } + + if v.Kind() == reflect.Slice { + return printSliceAsTable(v) + } + + if v.Kind() == reflect.Struct { + return printStructAsKeyValue(v) + } + + return fmt.Errorf("table format requires a struct or slice, got %s", v.Kind()) +} + +// columnInfo holds table column metadata +type columnInfo struct { + header string + index int +} + +// getTableColumns extracts fields with `table` tags +func getTableColumns(t reflect.Type) []columnInfo { + var cols []columnInfo + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + if !field.IsExported() { + continue + } + tag := field.Tag.Get("table") + if tag != "" && tag != "-" { + cols = append(cols, columnInfo{header: tag, index: i}) + } + } + return cols +} + +// printSliceAsTable prints a slice of structs as a table with headers and rows +func printSliceAsTable(v reflect.Value) error { + if v.Len() == 0 { + fmt.Println("No items to display") + return nil + } + + // Get element type + firstElem := v.Index(0) + if firstElem.Kind() == reflect.Ptr { + firstElem = firstElem.Elem() + } + + if firstElem.Kind() != reflect.Struct { + return fmt.Errorf("slice elements must be structs, got %s", firstElem.Kind()) + } + + cols := getTableColumns(firstElem.Type()) + if len(cols) == 0 { + return fmt.Errorf("no fields with table tags found") + } + + // Create tabwriter + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + + // Print header row + headers := make([]string, len(cols)) + for i, col := range cols { + headers[i] = col.header + } + fmt.Fprintln(w, strings.Join(headers, "\t")) + + // Print separator row + separators := make([]string, len(cols)) + for i, col := range cols { + separators[i] = strings.Repeat("-", len(col.header)) + } + fmt.Fprintln(w, strings.Join(separators, "\t")) + + // Print data rows + for i := 0; i < v.Len(); i++ { + elem := v.Index(i) + if elem.Kind() == reflect.Ptr { + elem = elem.Elem() + } + + values := make([]string, len(cols)) + for j, col := range cols { + values[j] = formatFieldValue(elem.Field(col.index)) + } + fmt.Fprintln(w, strings.Join(values, "\t")) + } + + return w.Flush() +} + +// printStructAsKeyValue prints a single struct as key-value pairs +func printStructAsKeyValue(v reflect.Value) error { + t := v.Type() + cols := getTableColumns(t) + + if len(cols) == 0 { + return fmt.Errorf("no fields with table tags found") + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + + for _, col := range cols { + value := formatFieldValue(v.Field(col.index)) + fmt.Fprintf(w, "%s:\t%s\n", col.header, value) + } + + return w.Flush() +} + +// PrintObjectWithIndent prints a struct or slice in the specified format with indentation +func PrintObjectWithIndent(obj interface{}, format OutputFormat, indent string) error { + if format != FormatTable { + return PrintObject(obj, format) + } + + v := reflect.ValueOf(obj) + if v.Kind() == reflect.Ptr { + if v.IsNil() { + return nil + } + v = v.Elem() + } + + if v.Kind() != reflect.Struct { + return fmt.Errorf("expected struct, got %s", v.Kind()) + } + + t := v.Type() + cols := getTableColumns(t) + + if len(cols) == 0 { + return fmt.Errorf("no fields with table tags found") + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + + for _, col := range cols { + value := formatFieldValue(v.Field(col.index)) + fmt.Fprintf(w, "%s%s:\t%s\n", indent, col.header, value) + } + + return w.Flush() +} + +// formatFieldValue converts a reflect.Value to a string representation +func formatFieldValue(v reflect.Value) string { + if !v.IsValid() { + return "-" + } + + // Handle pointers + if v.Kind() == reflect.Ptr { + if v.IsNil() { + return "-" + } + v = v.Elem() + } + + // Handle time.Time + if v.Type().String() == "time.Time" { + if t, ok := v.Interface().(interface{ Format(string) string }); ok { + return t.Format("2006-01-02 15:04") + } + } + + switch v.Kind() { + case reflect.String: + if v.String() == "" { + return "-" + } + return v.String() + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return fmt.Sprintf("%d", v.Int()) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return fmt.Sprintf("%d", v.Uint()) + case reflect.Float32, reflect.Float64: + return fmt.Sprintf("%.4f", v.Float()) + case reflect.Bool: + return fmt.Sprintf("%t", v.Bool()) + default: + return fmt.Sprintf("%v", v.Interface()) + } +} + +// addSpaces inserts spaces before capital letters +func addSpaces(s string) string { + var result strings.Builder + for i, r := range s { + if i > 0 && r >= 'A' && r <= 'Z' { + result.WriteRune(' ') + } + result.WriteRune(r) + } + return result.String() +} + +// toTitleCase converts snake_case to Title Case +func toTitleCase(s string) string { + s = strings.ReplaceAll(s, "_", " ") + words := strings.Fields(s) + for i, word := range words { + if len(word) > 0 { + words[i] = strings.ToUpper(string(word[0])) + strings.ToLower(word[1:]) + } + } + return strings.Join(words, " ") +} diff --git a/cli/azd/extensions/azure.ai.finetune/pkg/models/finetune.go b/cli/azd/extensions/azure.ai.finetune/pkg/models/finetune.go index 1740ba8f946..96035140cf4 100644 --- a/cli/azd/extensions/azure.ai.finetune/pkg/models/finetune.go +++ b/cli/azd/extensions/azure.ai.finetune/pkg/models/finetune.go @@ -33,37 +33,37 @@ const ( // 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) + ID string `json:"id" table:"ID"` + VendorJobID string `json:"-" table:"-"` // Vendor-specific ID (e.g., OpenAI's ftjob-xxx) // Job details - Status JobStatus - BaseModel string - FineTunedModel string + Status JobStatus `json:"status" table:"STATUS"` + BaseModel string `json:"model" table:"MODEL"` + FineTunedModel string `json:"-" table:"-"` // Timestamps - CreatedAt time.Time - CompletedAt *time.Time + CreatedAt time.Time `json:"created_at" table:"CREATED"` + CompletedAt *time.Time `json:"-" table:"-"` // Files - TrainingFileID string - ValidationFileID string + TrainingFileID string `json:"-" table:"-"` + ValidationFileID string `json:"-" table:"-"` // Metadata - VendorMetadata map[string]interface{} // Store vendor-specific details - ErrorDetails *ErrorDetail + VendorMetadata map[string]interface{} `json:"-" table:"-"` // Store vendor-specific details + ErrorDetails *ErrorDetail `json:"-" table:"-"` } // Hyperparameters represents fine-tuning hyperparameters type Hyperparameters struct { - BatchSize int64 - LearningRateMultiplier float64 - NEpochs int64 - Beta float64 // For DPO - ComputeMultiplier float64 // For Reinforcement - EvalInterval int64 // For Reinforcement - EvalSamples int64 // For Reinforcement - ReasoningEffort string // For Reinforcement + BatchSize int64 `json:"batch_size" yaml:"batch_size" table:"BATCH SIZE"` + LearningRateMultiplier float64 `json:"learning_rate_multiplier" yaml:"learning_rate_multiplier" table:"LEARNING RATE MULTIPLIER"` + NEpochs int64 `json:"n_epochs" yaml:"n_epochs" table:"N EPOCHS"` + Beta float64 `json:"beta,omitempty" yaml:"beta,omitempty" table:"-"` // For DPO + ComputeMultiplier float64 `json:"compute_multiplier,omitempty" yaml:"compute_multiplier,omitempty" table:"-"` // For Reinforcement + EvalInterval int64 `json:"eval_interval,omitempty" yaml:"eval_interval,omitempty" table:"-"` // For Reinforcement + EvalSamples int64 `json:"eval_samples,omitempty" yaml:"eval_samples,omitempty" table:"-"` // For Reinforcement + ReasoningEffort string `json:"reasoning_effort,omitempty" yaml:"reasoning_effort,omitempty" table:"-"` // For Reinforcement } // ListFineTuningJobsRequest represents a request to list fine-tuning jobs @@ -74,19 +74,19 @@ type ListFineTuningJobsRequest struct { // 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{} - TrainingType string - Seed int64 + ID string `json:"id" yaml:"id" table:"ID"` + Status JobStatus `json:"status" yaml:"status" table:"STATUS"` + Model string `json:"model" yaml:"model" table:"MODEL"` + FineTunedModel string `json:"fine_tuned_model" yaml:"fine_tuned_model" table:"FINE-TUNED MODEL"` + CreatedAt time.Time `json:"created_at" yaml:"created_at" table:"CREATED"` + FinishedAt time.Time `json:"-" yaml:"-" table:"-"` + Method string `json:"training_type" yaml:"training_type" table:"TRAINING TYPE"` + TrainingFile string `json:"training_file" yaml:"training_file" table:"TRAINING FILE"` + ValidationFile string `json:"validation_file" yaml:"validation_file" table:"VALIDATION FILE"` + Hyperparameters *Hyperparameters `json:"hyperparameters" yaml:"hyperparameters" table:"-"` + VendorMetadata map[string]interface{} `json:"-" yaml:"-" table:"-"` + TrainingType string `json:"-" yaml:"-" table:"-"` + Seed int64 `json:"-" yaml:"-" table:"-"` } // JobEvent represents an event associated with a fine-tuning job From 1280c870064720bd394e9084d1587d059a95cde4 Mon Sep 17 00:00:00 2001 From: Saanika Gupta Date: Mon, 12 Jan 2026 13:57:00 +0530 Subject: [PATCH 31/32] Align jobs list command with specs --- .../internal/cmd/operations.go | 25 ++++++++----------- .../internal/providers/openai/conversions.go | 9 +++++++ .../internal/utils/output.go | 12 +++++++++ .../azure.ai.finetune/pkg/models/finetune.go | 23 ++++++++++++++++- 4 files changed, 54 insertions(+), 15 deletions(-) diff --git a/cli/azd/extensions/azure.ai.finetune/internal/cmd/operations.go b/cli/azd/extensions/azure.ai.finetune/internal/cmd/operations.go index 19e814b5fa8..7f2a06a2615 100644 --- a/cli/azd/extensions/azure.ai.finetune/internal/cmd/operations.go +++ b/cli/azd/extensions/azure.ai.finetune/internal/cmd/operations.go @@ -296,6 +296,7 @@ func newOperationShowCommand() *cobra.Command { func newOperationListCommand() *cobra.Command { var limit int var after string + var output string cmd := &cobra.Command{ Use: "list", Short: "List fine-tuning jobs.", @@ -309,7 +310,7 @@ func newOperationListCommand() *cobra.Command { // Show spinner while fetching jobs spinner := ux.NewSpinner(&ux.SpinnerOptions{ - Text: "Fetching fine-tuning jobs...", + Text: "Fine-tuning Jobs", }) if err := spinner.Start(ctx); err != nil { fmt.Printf("failed to start spinner: %v\n", err) @@ -324,27 +325,23 @@ func newOperationListCommand() *cobra.Command { jobs, err := fineTuneSvc.ListFineTuningJobs(ctx, limit, after) _ = spinner.Stop(ctx) + fmt.Print("\n\n") + if err != nil { - fmt.Println() return err } - // Display job list - 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, utils.GetStatusSymbol(job.Status), job.Status, job.BaseModel, - formatFineTunedModel(job.FineTunedModel), utils.FormatTime(job.CreatedAt)) + if output == "json" { + utils.PrintObject(jobs, utils.FormatJSON) + } else { + utils.PrintObject(jobs, utils.FormatTable) } - - fmt.Printf("\nTotal jobs: %d\n", len(jobs)) - - utils.PrintObject(jobs, utils.FormatTable) - utils.PrintObject(jobs, utils.FormatJSON) return nil }, } - cmd.Flags().IntVarP(&limit, "top", "t", 50, "Number of fine-tuning jobs to list") - cmd.Flags().StringVarP(&after, "after", "a", "", "Cursor for pagination") + cmd.Flags().IntVarP(&limit, "top", "t", 10, "Number of jobs to return") + cmd.Flags().StringVar(&after, "after", "", "Pagination cursor") + cmd.Flags().StringVarP(&output, "output", "o", "table", "Output format: table, json") return cmd } diff --git a/cli/azd/extensions/azure.ai.finetune/internal/providers/openai/conversions.go b/cli/azd/extensions/azure.ai.finetune/internal/providers/openai/conversions.go index 3fae0d4204a..bd6a3d9d23b 100644 --- a/cli/azd/extensions/azure.ai.finetune/internal/providers/openai/conversions.go +++ b/cli/azd/extensions/azure.ai.finetune/internal/providers/openai/conversions.go @@ -6,6 +6,7 @@ package openai import ( "encoding/json" "strings" + "time" "github.com/openai/openai-go/v3" "github.com/openai/openai-go/v3/packages/pagination" @@ -50,9 +51,17 @@ func convertOpenAIJobToModel(openaiJob openai.FineTuningJob) *models.FineTuningJ BaseModel: openaiJob.Model, FineTunedModel: openaiJob.FineTunedModel, CreatedAt: utils.UnixTimestampToUTC(openaiJob.CreatedAt), + Duration: models.Duration(calculateDuration(openaiJob.CreatedAt, openaiJob.FinishedAt)), } } +func calculateDuration(createdAt, finishedAt int64) time.Duration { + if finishedAt > 0 { + return time.Duration(finishedAt-createdAt) * time.Second + } + return 0 +} + // convertOpenAIJobToDetailModel converts OpenAI SDK job to detailed domain model func convertOpenAIJobToDetailModel(openaiJob *openai.FineTuningJob) *models.FineTuningJobDetail { // Extract hyperparameters from OpenAI job diff --git a/cli/azd/extensions/azure.ai.finetune/internal/utils/output.go b/cli/azd/extensions/azure.ai.finetune/internal/utils/output.go index 1cc9ada93f1..1a3d919aa14 100644 --- a/cli/azd/extensions/azure.ai.finetune/internal/utils/output.go +++ b/cli/azd/extensions/azure.ai.finetune/internal/utils/output.go @@ -10,6 +10,7 @@ import ( "reflect" "strings" "text/tabwriter" + "time" "gopkg.in/yaml.v3" ) @@ -228,6 +229,17 @@ func formatFieldValue(v reflect.Value) string { } } + // Handle time.Duration + if v.Type().String() == "time.Duration" || v.Type().String() == "models.Duration" { + d := time.Duration(v.Int()) + if d == 0 { + return "-" + } + h := int(d.Hours()) + m := int(d.Minutes()) % 60 + return fmt.Sprintf("%dh %02dm", h, m) + } + switch v.Kind() { case reflect.String: if v.String() == "" { diff --git a/cli/azd/extensions/azure.ai.finetune/pkg/models/finetune.go b/cli/azd/extensions/azure.ai.finetune/pkg/models/finetune.go index 96035140cf4..283d791bc34 100644 --- a/cli/azd/extensions/azure.ai.finetune/pkg/models/finetune.go +++ b/cli/azd/extensions/azure.ai.finetune/pkg/models/finetune.go @@ -30,6 +30,26 @@ const ( Reinforcement MethodType = "reinforcement" ) +type Duration time.Duration + +func (d Duration) MarshalJSON() ([]byte, error) { + if d == 0 { + return []byte(`"-"`), nil + } + h := int(time.Duration(d).Hours()) + m := int(time.Duration(d).Minutes()) % 60 + return []byte(fmt.Sprintf(`"%dh %02dm"`, h, m)), nil +} + +func (d Duration) MarshalYAML() (interface{}, error) { + if d == 0 { + return "-", nil + } + h := int(time.Duration(d).Hours()) + m := int(time.Duration(d).Minutes()) % 60 + return fmt.Sprintf("%dh %02dm", h, m), nil +} + // FineTuningJob represents a vendor-agnostic fine-tuning job type FineTuningJob struct { // Core identification @@ -37,12 +57,13 @@ type FineTuningJob struct { VendorJobID string `json:"-" table:"-"` // Vendor-specific ID (e.g., OpenAI's ftjob-xxx) // Job details - Status JobStatus `json:"status" table:"STATUS"` BaseModel string `json:"model" table:"MODEL"` + Status JobStatus `json:"status" table:"STATUS"` FineTunedModel string `json:"-" table:"-"` // Timestamps CreatedAt time.Time `json:"created_at" table:"CREATED"` + Duration Duration `json:"duration" table:"DURATION"` CompletedAt *time.Time `json:"-" table:"-"` // Files From 80ac49e6f1efa32e7e702a6fbad055e5b47ffaad Mon Sep 17 00:00:00 2001 From: Saanika Gupta Date: Mon, 12 Jan 2026 19:11:33 +0530 Subject: [PATCH 32/32] Align job show command with specs --- .../internal/cmd/operations.go | 157 +++++++-------- .../internal/providers/openai/conversions.go | 21 +- .../internal/utils/status.go | 4 + .../azure.ai.finetune/pkg/models/finetune.go | 42 ++-- .../azure.ai.finetune/pkg/models/views.go | 186 ++++++++++++++++++ 5 files changed, 303 insertions(+), 107 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.finetune/pkg/models/views.go diff --git a/cli/azd/extensions/azure.ai.finetune/internal/cmd/operations.go b/cli/azd/extensions/azure.ai.finetune/internal/cmd/operations.go index 7f2a06a2615..d42e69587ce 100644 --- a/cli/azd/extensions/azure.ai.finetune/internal/cmd/operations.go +++ b/cli/azd/extensions/azure.ai.finetune/internal/cmd/operations.go @@ -154,10 +154,12 @@ func newOperationSubmitCommand() *cobra.Command { // newOperationShowCommand creates a command to show the fine-tuning job details func newOperationShowCommand() *cobra.Command { var jobID string + var logs bool + var output string cmd := &cobra.Command{ Use: "show", - Short: "Show fine-tuning job details.", + Short: "Shows detailed information about a specific job.", RunE: func(cmd *cobra.Command, args []string) error { ctx := azdext.WithAccessToken(cmd.Context()) azdClient, err := azdext.NewAzdClient() @@ -168,7 +170,7 @@ func newOperationShowCommand() *cobra.Command { // Show spinner while fetching job spinner := ux.NewSpinner(&ux.SpinnerOptions{ - Text: fmt.Sprintf("Fetching fine-tuning job %s...", jobID), + Text: "Fine-Tuning Job Details", }) if err := spinner.Start(ctx); err != nil { fmt.Printf("failed to start spinner: %v\n", err) @@ -183,111 +185,97 @@ func newOperationShowCommand() *cobra.Command { job, err := fineTuneSvc.GetFineTuningJobDetails(ctx, jobID) _ = spinner.Stop(ctx) + fmt.Print("\n\n") if err != nil { - fmt.Println() return err } - // Display job details - color.Green("\nFine-tuning Job Details\n") - fmt.Printf("Job ID: %s\n", job.ID) - fmt.Printf("Status: %s %s\n", utils.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", utils.FormatTime(job.CreatedAt)) - if !job.FinishedAt.IsZero() { - fmt.Printf("Finished At: %s\n", utils.FormatTime(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) - } + switch output { + case "json": + utils.PrintObject(job, utils.FormatJSON) + case "yaml": + utils.PrintObject(job, utils.FormatYAML) + default: + views := job.ToDetailViews() + utils.PrintObjectWithIndent(views.Details, utils.FormatTable, " ") - // 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) - } - fmt.Println("saa1") - utils.PrintObject(job, utils.FormatTable) - if job.Hyperparameters != nil { - fmt.Println("\nConfiguration:") - utils.PrintObjectWithIndent(job.Hyperparameters, utils.FormatTable, " ") - } - fmt.Println("saa2") - utils.PrintObject(job, utils.FormatJSON) - fmt.Println("saa3") - utils.PrintObject(job, utils.FormatYAML) - fmt.Println("saa4") - - // 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) - } + fmt.Println("\nTimestamps:") + utils.PrintObjectWithIndent(views.Timestamps, utils.FormatTable, " ") - events, err := fineTuneSvc.GetJobEvents(ctx, jobID) - _ = eventsSpinner.Stop(ctx) + fmt.Println("\nConfiguration:") + utils.PrintObjectWithIndent(views.Configuration, utils.FormatTable, " ") - if err != nil { - fmt.Println() - return err - } else if events != nil && len(events.Data) > 0 { - fmt.Println("\nJob Events:") - for i, event := range events.Data { - fmt.Printf(" %d. Event ID: %s\n", i+1, event.ID) - fmt.Printf(" [%s] %s - %s\n", event.Level, utils.FormatTime(event.CreatedAt), event.Message) - } - if events.HasMore { - fmt.Println(" ... (more events available)") - } + fmt.Println("\nData:") + utils.PrintObjectWithIndent(views.Data, utils.FormatTable, " ") } - // Fetch and print checkpoints if job is completed - if job.Status == models.StatusSucceeded { - checkpointsSpinner := ux.NewSpinner(&ux.SpinnerOptions{ - Text: "Fetching job checkpoints...", + if logs { + fmt.Println() + // Fetch and print events + eventsSpinner := ux.NewSpinner(&ux.SpinnerOptions{ + Text: "Events:", }) - if err := checkpointsSpinner.Start(ctx); err != nil { + if err := eventsSpinner.Start(ctx); err != nil { fmt.Printf("failed to start spinner: %v\n", err) } - checkpoints, err := fineTuneSvc.GetJobCheckpoints(ctx, jobID) - _ = checkpointsSpinner.Stop(ctx) + events, err := fineTuneSvc.GetJobEvents(ctx, jobID) + _ = eventsSpinner.Stop(ctx) + fmt.Println() if err != nil { - fmt.Println() return 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", utils.FormatTime(checkpoint.CreatedAt)) - fmt.Printf(" Step Number: %d\n", checkpoint.StepNumber) - if checkpoint.Metrics != nil { - fmt.Printf(" Full Validation Loss: %.6f\n", checkpoint.Metrics.FullValidLoss) - } + } else if events != nil && len(events.Data) > 0 { + for _, event := range events.Data { + fmt.Printf(" [%s] %s\n", utils.FormatTime(event.CreatedAt), event.Message) } - if checkpoints.HasMore { - fmt.Println(" ... (more checkpoints available)") + if events.HasMore { + fmt.Println(" ... (more events available)") } } } - - fmt.Println(strings.Repeat("=", 120)) + // // Fetch and print checkpoints if job is completed + // if job.Status == models.StatusSucceeded { + // 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 := fineTuneSvc.GetJobCheckpoints(ctx, jobID) + // _ = checkpointsSpinner.Stop(ctx) + + // if err != nil { + // fmt.Println() + // return 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", utils.FormatTime(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") + cmd.Flags().StringVarP(&jobID, "id", "i", "", "Job ID") + cmd.Flags().BoolVar(&logs, "logs", false, "Include recent training logs") + cmd.Flags().StringVarP(&output, "output", "o", "table", "Output format: table, json, yaml") + cmd.MarkFlagRequired("id") return cmd } @@ -331,9 +319,10 @@ func newOperationListCommand() *cobra.Command { return err } - if output == "json" { + switch output { + case "json": utils.PrintObject(jobs, utils.FormatJSON) - } else { + default: utils.PrintObject(jobs, utils.FormatTable) } return nil diff --git a/cli/azd/extensions/azure.ai.finetune/internal/providers/openai/conversions.go b/cli/azd/extensions/azure.ai.finetune/internal/providers/openai/conversions.go index bd6a3d9d23b..7a71d3ac3a0 100644 --- a/cli/azd/extensions/azure.ai.finetune/internal/providers/openai/conversions.go +++ b/cli/azd/extensions/azure.ai.finetune/internal/providers/openai/conversions.go @@ -93,13 +93,30 @@ func convertOpenAIJobToDetailModel(openaiJob *openai.FineTuningJob) *models.Fine hyperparameters.NEpochs = openaiJob.Hyperparameters.NEpochs.OfInt } + status := mapOpenAIStatusToJobStatus(openaiJob.Status) + + // Only set FinishedAt for terminal states + var finishedAt *time.Time + if utils.IsTerminalStatus(status) && openaiJob.FinishedAt > 0 { + t := utils.UnixTimestampToUTC(openaiJob.FinishedAt) + finishedAt = &t + } + + // Only set EstimatedFinish for non-terminal states + var estimatedFinish *time.Time + if !utils.IsTerminalStatus(status) && openaiJob.EstimatedFinish > 0 { + t := utils.UnixTimestampToUTC(openaiJob.EstimatedFinish) + estimatedFinish = &t + } + jobDetail := &models.FineTuningJobDetail{ ID: openaiJob.ID, - Status: mapOpenAIStatusToJobStatus(openaiJob.Status), + Status: status, Model: openaiJob.Model, FineTunedModel: openaiJob.FineTunedModel, CreatedAt: utils.UnixTimestampToUTC(openaiJob.CreatedAt), - FinishedAt: utils.UnixTimestampToUTC(openaiJob.FinishedAt), + FinishedAt: finishedAt, + EstimatedFinish: estimatedFinish, Method: openaiJob.Method.Type, TrainingFile: openaiJob.TrainingFile, ValidationFile: openaiJob.ValidationFile, diff --git a/cli/azd/extensions/azure.ai.finetune/internal/utils/status.go b/cli/azd/extensions/azure.ai.finetune/internal/utils/status.go index 25e961dbcde..fd10e1eae48 100644 --- a/cli/azd/extensions/azure.ai.finetune/internal/utils/status.go +++ b/cli/azd/extensions/azure.ai.finetune/internal/utils/status.go @@ -24,3 +24,7 @@ func GetStatusSymbol(status models.JobStatus) string { return "❓" } } + +func IsTerminalStatus(s models.JobStatus) bool { + return s == models.StatusSucceeded || s == models.StatusFailed || s == models.StatusCancelled +} \ No newline at end of file diff --git a/cli/azd/extensions/azure.ai.finetune/pkg/models/finetune.go b/cli/azd/extensions/azure.ai.finetune/pkg/models/finetune.go index 283d791bc34..1ec3e7e4c2a 100644 --- a/cli/azd/extensions/azure.ai.finetune/pkg/models/finetune.go +++ b/cli/azd/extensions/azure.ai.finetune/pkg/models/finetune.go @@ -77,14 +77,14 @@ type FineTuningJob struct { // Hyperparameters represents fine-tuning hyperparameters type Hyperparameters struct { - BatchSize int64 `json:"batch_size" yaml:"batch_size" table:"BATCH SIZE"` - LearningRateMultiplier float64 `json:"learning_rate_multiplier" yaml:"learning_rate_multiplier" table:"LEARNING RATE MULTIPLIER"` - NEpochs int64 `json:"n_epochs" yaml:"n_epochs" table:"N EPOCHS"` - Beta float64 `json:"beta,omitempty" yaml:"beta,omitempty" table:"-"` // For DPO - ComputeMultiplier float64 `json:"compute_multiplier,omitempty" yaml:"compute_multiplier,omitempty" table:"-"` // For Reinforcement - EvalInterval int64 `json:"eval_interval,omitempty" yaml:"eval_interval,omitempty" table:"-"` // For Reinforcement - EvalSamples int64 `json:"eval_samples,omitempty" yaml:"eval_samples,omitempty" table:"-"` // For Reinforcement - ReasoningEffort string `json:"reasoning_effort,omitempty" yaml:"reasoning_effort,omitempty" table:"-"` // For Reinforcement + BatchSize int64 `json:"batch_size" yaml:"batch_size"` + LearningRateMultiplier float64 `json:"learning_rate_multiplier" yaml:"learning_rate_multiplier"` + NEpochs int64 `json:"n_epochs" yaml:"n_epochs"` + Beta float64 `json:"beta,omitempty" yaml:"beta,omitempty"` // For DPO + ComputeMultiplier float64 `json:"compute_multiplier,omitempty" yaml:"compute_multiplier,omitempty"` // For Reinforcement + EvalInterval int64 `json:"eval_interval,omitempty" yaml:"eval_interval,omitempty"` // For Reinforcement + EvalSamples int64 `json:"eval_samples,omitempty" yaml:"eval_samples,omitempty"` // For Reinforcement + ReasoningEffort string `json:"reasoning_effort,omitempty" yaml:"reasoning_effort,omitempty"` // For Reinforcement } // ListFineTuningJobsRequest represents a request to list fine-tuning jobs @@ -95,19 +95,19 @@ type ListFineTuningJobsRequest struct { // FineTuningJobDetail represents detailed information about a fine-tuning job type FineTuningJobDetail struct { - ID string `json:"id" yaml:"id" table:"ID"` - Status JobStatus `json:"status" yaml:"status" table:"STATUS"` - Model string `json:"model" yaml:"model" table:"MODEL"` - FineTunedModel string `json:"fine_tuned_model" yaml:"fine_tuned_model" table:"FINE-TUNED MODEL"` - CreatedAt time.Time `json:"created_at" yaml:"created_at" table:"CREATED"` - FinishedAt time.Time `json:"-" yaml:"-" table:"-"` - Method string `json:"training_type" yaml:"training_type" table:"TRAINING TYPE"` - TrainingFile string `json:"training_file" yaml:"training_file" table:"TRAINING FILE"` - ValidationFile string `json:"validation_file" yaml:"validation_file" table:"VALIDATION FILE"` - Hyperparameters *Hyperparameters `json:"hyperparameters" yaml:"hyperparameters" table:"-"` - VendorMetadata map[string]interface{} `json:"-" yaml:"-" table:"-"` - TrainingType string `json:"-" yaml:"-" table:"-"` - Seed int64 `json:"-" yaml:"-" table:"-"` + ID string `json:"id" yaml:"id"` + Status JobStatus `json:"status" yaml:"status"` + Model string `json:"model" yaml:"model"` + FineTunedModel string `json:"fine_tuned_model" yaml:"fine_tuned_model"` + CreatedAt time.Time `json:"created_at" yaml:"created_at"` + FinishedAt *time.Time `json:"finished_at,omitempty" yaml:"finished_at,omitempty"` + EstimatedFinish *time.Time `json:"estimated_finish,omitempty" yaml:"estimated_finish,omitempty"` + Method string `json:"training_type" yaml:"training_type"` + TrainingFile string `json:"training_file" yaml:"training_file"` + ValidationFile string `json:"validation_file,omitempty" yaml:"validation_file,omitempty"` + Hyperparameters *Hyperparameters `json:"hyperparameters" yaml:"hyperparameters"` + VendorMetadata map[string]interface{} `json:"-" yaml:"-"` + Seed int64 `json:"-" yaml:"-"` } // JobEvent represents an event associated with a fine-tuning job diff --git a/cli/azd/extensions/azure.ai.finetune/pkg/models/views.go b/cli/azd/extensions/azure.ai.finetune/pkg/models/views.go new file mode 100644 index 00000000000..e04a2baee39 --- /dev/null +++ b/cli/azd/extensions/azure.ai.finetune/pkg/models/views.go @@ -0,0 +1,186 @@ +package models + +import ( + "fmt" + "time" +) + +// FineTuningJobTableView is the table display representation for job listings +type FineTuningJobTableView struct { + ID string `table:"ID"` + Status JobStatus `table:"Status"` + BaseModel string `table:"Model"` + CreatedAt time.Time `table:"Created"` +} + +// JobDetailsView is the basic job info section +type JobDetailsView struct { + ID string `table:"ID"` + Status JobStatus `table:"Status"` + Model string `table:"Model"` + FineTunedModel string `table:"Fine-tuned Model"` +} + +// TimestampsView is the timestamps section +type TimestampsView struct { + Created string `table:"Created"` + Finished string `table:"Finished"` + EstimatedETA string `table:"Estimated ETA"` +} + +// BaseConfigurationView has fields common to all methods +type BaseConfigurationView struct { + TrainingType string `table:"Training Type"` + Epochs int64 `table:"Epochs"` + BatchSize int64 `table:"Batch Size"` + LearningRate string `table:"Learning Rate"` +} + +// DPOConfigurationView has DPO-specific fields +type DPOConfigurationView struct { + TrainingType string `table:"Training Type"` + Epochs int64 `table:"Epochs"` + BatchSize int64 `table:"Batch Size"` + LearningRate string `table:"Learning Rate"` + Beta string `table:"Beta"` +} + +// ReinforcementConfigurationView has reinforcement-specific fields +type ReinforcementConfigurationView struct { + TrainingType string `table:"Training Type"` + Epochs int64 `table:"Epochs"` + BatchSize int64 `table:"Batch Size"` + LearningRate string `table:"Learning Rate"` + ComputeMultiplier string `table:"Compute Multiplier"` + EvalInterval string `table:"Eval Interval"` + EvalSamples string `table:"Eval Samples"` + ReasoningEffort string `table:"Reasoning Effort"` +} + +// DataView is the training/validation data section +type DataView struct { + TrainingFile string `table:"Training File"` + ValidationFile string `table:"Validation File"` +} + +// JobDetailViews contains all view sections for a job detail display +type JobDetailViews struct { + Details *JobDetailsView + Timestamps *TimestampsView + Configuration interface{} // Can be Base, DPO, or Reinforcement view + Data *DataView +} + +// ToTableView converts a FineTuningJob to its table view (for list command) +func (j *FineTuningJob) ToTableView() *FineTuningJobTableView { + return &FineTuningJobTableView{ + ID: j.ID, + Status: j.Status, + BaseModel: j.BaseModel, + CreatedAt: j.CreatedAt, + } +} + +// ToDetailViews converts a FineTuningJobDetail to its sectioned views (for show command) +func (j *FineTuningJobDetail) ToDetailViews() *JobDetailViews { + fineTunedModel := j.FineTunedModel + if fineTunedModel == "" { + fineTunedModel = "-" + } + + // Build configuration view based on method type + var configView interface{} + switch j.Method { + case string(DPO): + configView = &DPOConfigurationView{ + TrainingType: j.Method, + Epochs: j.Hyperparameters.NEpochs, + BatchSize: j.Hyperparameters.BatchSize, + LearningRate: formatFloatOrDash(j.Hyperparameters.LearningRateMultiplier), + Beta: formatFloatOrDash(j.Hyperparameters.Beta), + } + case string(Reinforcement): + configView = &ReinforcementConfigurationView{ + TrainingType: j.Method, + Epochs: j.Hyperparameters.NEpochs, + BatchSize: j.Hyperparameters.BatchSize, + LearningRate: formatFloatOrDash(j.Hyperparameters.LearningRateMultiplier), + ComputeMultiplier: formatFloatOrDash(j.Hyperparameters.ComputeMultiplier), + EvalInterval: formatInt64OrDash(j.Hyperparameters.EvalInterval), + EvalSamples: formatInt64OrDash(j.Hyperparameters.EvalSamples), + ReasoningEffort: stringOrDash(j.Hyperparameters.ReasoningEffort), + } + default: // supervised or unknown + configView = &BaseConfigurationView{ + TrainingType: j.Method, + Epochs: j.Hyperparameters.NEpochs, + BatchSize: j.Hyperparameters.BatchSize, + LearningRate: formatFloatOrDash(j.Hyperparameters.LearningRateMultiplier), + } + } + + return &JobDetailViews{ + Details: &JobDetailsView{ + ID: j.ID, + Status: j.Status, + Model: j.Model, + FineTunedModel: fineTunedModel, + }, + Timestamps: &TimestampsView{ + Created: formatTimeOrDash(j.CreatedAt), + Finished: formatTimePointerOrDash(j.FinishedAt), + EstimatedETA: formatTimePointerOrDash(j.EstimatedFinish), + }, + Configuration: configView, + Data: &DataView{ + TrainingFile: j.TrainingFile, + ValidationFile: stringOrDash(j.ValidationFile), + }, + } +} + +// ToTableViews converts a slice of jobs to table views +func ToTableViews(job *FineTuningJob) *FineTuningJobTableView { + view := job.ToTableView() + return view +} + +func formatFloat(f float64) string { + return fmt.Sprintf("%g", f) +} + +func formatFloatOrDash(f float64) string { + if f == 0 { + return "-" + } + return fmt.Sprintf("%g", f) +} + +func formatInt64OrDash(i int64) string { + if i == 0 { + return "-" + } + return fmt.Sprintf("%d", i) +} + +func stringOrDash(s string) string { + if s == "" { + return "-" + } + return s +} + +// Add this helper +func formatTimeOrDash(t time.Time) string { + if t.IsZero() { + return "-" + } + return t.Format("2006-01-02 15:04") +} + +func formatTimePointerOrDash(t *time.Time) string { + if t == nil || t.IsZero() { + return "-" + } + return t.Format("2006-01-02 15:04") +}