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() +}