diff --git a/cmd/nightshift/commands/doctor.go b/cmd/nightshift/commands/doctor.go index 1a0f0ee..2c41869 100644 --- a/cmd/nightshift/commands/doctor.go +++ b/cmd/nightshift/commands/doctor.go @@ -246,7 +246,7 @@ func checkProviders(cfg *config.Config, add func(string, checkStatus, string)) ( add("copilot.data_path", statusOK, path) } copilotProvider = providers.NewCopilotWithPath(path) - monthlyLimit := int64(cfg.GetProviderBudget("copilot")) + monthlyLimit := cfg.GetCopilotMonthlyLimit() if pct, err := copilotProvider.GetUsedPercent(mode, monthlyLimit); err != nil { add("copilot.usage", statusFail, err.Error()) } else { diff --git a/internal/agents/copilot.go b/internal/agents/copilot.go index fd94226..13db58b 100644 --- a/internal/agents/copilot.go +++ b/internal/agents/copilot.go @@ -243,7 +243,8 @@ func (a *CopilotAgent) extractJSON(output []byte) []byte { return nil } -// Available checks if the gh binary is available in PATH and copilot extension is installed. +// Available checks if the copilot binary is available. +// Supports both standalone "copilot" and built-in "gh copilot" (modern gh versions). func (a *CopilotAgent) Available() bool { // Check if binary is available if _, err := exec.LookPath(a.binaryPath); err != nil { @@ -251,21 +252,18 @@ func (a *CopilotAgent) Available() bool { } // If using standalone copilot binary, it's available - if a.binaryPath == "copilot" { + if a.binaryPath != "gh" { return true } - // If using gh, check if copilot extension is installed - // Run: gh extension list | grep copilot - cmd := exec.Command(a.binaryPath, "extension", "list") - output, err := cmd.Output() - if err != nil { - return false + // For gh: try running "gh copilot --version" to check if copilot is available + // (works for both built-in copilot and gh-copilot extension) + cmd := exec.Command(a.binaryPath, "copilot", "--version") + if err := cmd.Run(); err == nil { + return true } - // Look for github/gh-copilot in the extension list - return strings.Contains(string(output), "github/gh-copilot") || - strings.Contains(string(output), "gh-copilot") + return false } // Version returns the copilot CLI version. diff --git a/internal/budget/budget.go b/internal/budget/budget.go index 31a718c..207bd4b 100644 --- a/internal/budget/budget.go +++ b/internal/budget/budget.go @@ -312,10 +312,8 @@ func (m *Manager) GetUsedPercent(provider string) (float64, error) { if m.copilot == nil { return 0, fmt.Errorf("copilot provider not configured") } - // Copilot uses monthly request limits, not weekly token budgets - // Convert weekly budget to monthly limit for consistency - // Note: This is a simplification; actual monthly limits should be configured separately - monthlyLimit := weeklyBudget * 4 // Approximate: 4 weeks per month + // Use config-driven monthly limit (plan preset, explicit, or fallback) + monthlyLimit := m.cfg.GetCopilotMonthlyLimit() return m.copilot.GetUsedPercent(mode, monthlyLimit) default: diff --git a/internal/config/config.go b/internal/config/config.go index f0d7347..92495f0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -76,6 +76,17 @@ type ProviderConfig struct { DangerouslySkipPermissions bool `mapstructure:"dangerously_skip_permissions"` // DangerouslyBypassApprovalsAndSandbox tells the CLI to bypass approvals and sandboxing. DangerouslyBypassApprovalsAndSandbox bool `mapstructure:"dangerously_bypass_approvals_and_sandbox"` + + // MonthlyLimit sets the monthly premium request (PRU) limit for Copilot. + // If zero, falls back to CopilotPlan preset or weeklyTokens * 4. + // Only meaningful for the copilot provider. + MonthlyLimit int64 `mapstructure:"monthly_limit"` + + // CopilotPlan sets the GitHub Copilot plan for automatic PRU limit detection. + // Valid values: "free" (50), "pro" (300), "pro_plus" (1500), + // "business" (300), "enterprise" (1000). + // If empty, uses MonthlyLimit or falls back to weeklyTokens * 4. + CopilotPlan string `mapstructure:"copilot_plan"` } // ProjectConfig defines a project to manage. @@ -498,6 +509,34 @@ func (c *Config) GetProviderBudget(provider string) int { return c.Budget.WeeklyTokens } +// CopilotPlanLimits maps Copilot plan names to monthly PRU limits. +var CopilotPlanLimits = map[string]int64{ + "free": 50, + "pro": 300, + "pro_plus": 1500, + "business": 300, + "enterprise": 1000, +} + +// GetCopilotMonthlyLimit returns the monthly PRU limit for the Copilot provider. +// Resolution order: MonthlyLimit field > CopilotPlan preset > weeklyTokens * 4 fallback. +func (c *Config) GetCopilotMonthlyLimit() int64 { + // Explicit monthly limit takes priority + if c.Providers.Copilot.MonthlyLimit > 0 { + return c.Providers.Copilot.MonthlyLimit + } + + // Plan-based preset + if c.Providers.Copilot.CopilotPlan != "" { + if limit, ok := CopilotPlanLimits[c.Providers.Copilot.CopilotPlan]; ok { + return limit + } + } + + // Fallback: derive from weekly token budget + return int64(c.GetProviderBudget("copilot")) * 4 +} + // IsTaskEnabled checks if a task type is enabled. func (c *Config) IsTaskEnabled(task string) bool { // Check if explicitly disabled diff --git a/internal/config/config_test.go b/internal/config/config_test.go index df34cdc..73f3386 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -154,6 +154,99 @@ func TestGetProviderBudget(t *testing.T) { } } +func TestGetCopilotMonthlyLimit(t *testing.T) { + tests := []struct { + name string + cfg Config + expected int64 + }{ + { + name: "explicit monthly limit takes priority", + cfg: Config{ + Providers: ProvidersConfig{ + Copilot: ProviderConfig{ + MonthlyLimit: 500, + CopilotPlan: "pro", // ignored when MonthlyLimit is set + }, + }, + Budget: BudgetConfig{WeeklyTokens: 100}, + }, + expected: 500, + }, + { + name: "plan preset: free", + cfg: Config{ + Providers: ProvidersConfig{ + Copilot: ProviderConfig{CopilotPlan: "free"}, + }, + }, + expected: 50, + }, + { + name: "plan preset: pro", + cfg: Config{ + Providers: ProvidersConfig{ + Copilot: ProviderConfig{CopilotPlan: "pro"}, + }, + }, + expected: 300, + }, + { + name: "plan preset: pro_plus", + cfg: Config{ + Providers: ProvidersConfig{ + Copilot: ProviderConfig{CopilotPlan: "pro_plus"}, + }, + }, + expected: 1500, + }, + { + name: "plan preset: business", + cfg: Config{ + Providers: ProvidersConfig{ + Copilot: ProviderConfig{CopilotPlan: "business"}, + }, + }, + expected: 300, + }, + { + name: "plan preset: enterprise", + cfg: Config{ + Providers: ProvidersConfig{ + Copilot: ProviderConfig{CopilotPlan: "enterprise"}, + }, + }, + expected: 1000, + }, + { + name: "fallback to weekly tokens * 4", + cfg: Config{ + Budget: BudgetConfig{WeeklyTokens: 200}, + }, + expected: 800, + }, + { + name: "unknown plan falls back to weekly tokens * 4", + cfg: Config{ + Providers: ProvidersConfig{ + Copilot: ProviderConfig{CopilotPlan: "unknown_plan"}, + }, + Budget: BudgetConfig{WeeklyTokens: 150}, + }, + expected: 600, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.cfg.GetCopilotMonthlyLimit() + if got != tt.expected { + t.Errorf("GetCopilotMonthlyLimit() = %d, want %d", got, tt.expected) + } + }) + } +} + func TestNormalizeBudgetConfig(t *testing.T) { cfg := &Config{ Budget: BudgetConfig{ diff --git a/internal/providers/copilot.go b/internal/providers/copilot.go index fe69acf..dde29d0 100644 --- a/internal/providers/copilot.go +++ b/internal/providers/copilot.go @@ -15,16 +15,36 @@ import ( // Usage tracking approach: // GitHub Copilot CLI does not expose usage metrics via API or local files like // Codex or Claude. We track usage by counting requests made through this provider. -// Each request counts as 1 premium request. Premium requests reset monthly on the -// 1st at 00:00:00 UTC according to GitHub's documented behavior. +// Each request counts as 1 premium request (PRU). Premium requests reset monthly +// on the 1st at 00:00:00 UTC according to GitHub's documented behavior. +// +// PRU cost varies by model (multiplier applied to base request): +// - GPT-4.1: 0x (free for most tasks) +// - Claude Sonnet: 1x (standard) +// - GPT-4o: 1x (standard) +// - Claude Opus 4: 10x (expensive, use for complex tasks) +// - o3-pro: 50x (very expensive) // // Limitations: // - No authoritative usage data from GitHub API (not exposed) // - Request counting only tracks usage through nightshift, not external usage // - No way to query remaining quota from GitHub servers -// - Assumes each prompt execution = 1 premium request (conservative estimate) +// - PRU multiplier estimates may drift as GitHub updates pricing type Copilot struct { dataPath string // Path to ~/.copilot for tracking data + agent CopilotExecutor +} + +// CopilotExecutor is satisfied by agents.CopilotAgent (avoids import cycle). +type CopilotExecutor interface { + Execute(ctx context.Context, opts CopilotExecOptions) (stdout string, exitCode int, err error) +} + +// CopilotExecOptions mirrors agents.ExecuteOptions for the provider layer. +type CopilotExecOptions struct { + Prompt string + WorkDir string + Files []string } // CopilotUsageData persists usage tracking between sessions. @@ -34,6 +54,32 @@ type CopilotUsageData struct { Month string `json:"month"` // "YYYY-MM" of current tracking period } +// PRUMultiplier maps model identifiers to their premium request multipliers. +// Source: GitHub Copilot billing documentation. +var PRUMultiplier = map[string]float64{ + "gpt-4.1": 0, + "gpt-4.1-mini": 0.25, + "gpt-4o": 1, + "gpt-4o-mini": 0.25, + "claude-sonnet": 1, + "claude-haiku": 0.25, + "claude-opus": 10, + "o1": 10, + "o3-mini": 0.33, + "o3-pro": 50, + "gemini-flash": 0.25, + "gemini-pro": 1, +} + +// EstimatePRUCost returns the PRU cost for a single request using the given model. +// Unknown models default to 1 PRU (standard rate). +func EstimatePRUCost(model string) float64 { + if mult, ok := PRUMultiplier[model]; ok { + return mult + } + return 1 // conservative default +} + // NewCopilot creates a Copilot provider with default data path. func NewCopilot() *Copilot { home, _ := os.UserHomeDir() @@ -49,20 +95,41 @@ func NewCopilotWithPath(dataPath string) *Copilot { } } +// SetAgent attaches an executor (typically agents.CopilotAgent) for task execution. +func (c *Copilot) SetAgent(agent CopilotExecutor) { + c.agent = agent +} + // Name returns "copilot". func (c *Copilot) Name() string { return "copilot" } // Execute runs a task via GitHub Copilot CLI. -// Implementation note: GitHub Copilot CLI uses 'gh copilot' commands. +// Delegates to the attached CopilotExecutor (agent) and increments the request counter. func (c *Copilot) Execute(ctx context.Context, task Task) (Result, error) { - // TODO: Implement - spawn gh copilot CLI process - // According to GitHub docs, commands are: - // - gh copilot explain - // - gh copilot suggest - // For nightshift agent usage, we'd use 'gh copilot suggest' with prompts - return Result{}, nil + if c.agent == nil { + return Result{}, fmt.Errorf("copilot agent not configured; call SetAgent first") + } + + stdout, exitCode, err := c.agent.Execute(ctx, CopilotExecOptions{ + Prompt: task.Prompt, + }) + + // Track the request regardless of outcome + _ = c.IncrementRequestCount() + + if err != nil { + return Result{ + Output: stdout, + ExitCode: exitCode, + }, fmt.Errorf("copilot execution: %w", err) + } + + return Result{ + Output: stdout, + ExitCode: exitCode, + }, nil } // Cost returns Copilot's token pricing (cents per 1K tokens). diff --git a/internal/providers/copilot_test.go b/internal/providers/copilot_test.go index 4fe3c9a..2342882 100644 --- a/internal/providers/copilot_test.go +++ b/internal/providers/copilot_test.go @@ -1,11 +1,125 @@ package providers import ( + "context" + "fmt" "os" "testing" "time" ) +// mockCopilotExecutor implements CopilotExecutor for testing. +type mockCopilotExecutor struct { + stdout string + exitCode int + err error + called bool + lastOpts CopilotExecOptions +} + +func (m *mockCopilotExecutor) Execute(_ context.Context, opts CopilotExecOptions) (string, int, error) { + m.called = true + m.lastOpts = opts + return m.stdout, m.exitCode, m.err +} + +func TestCopilot_Execute_WithAgent(t *testing.T) { + tmpDir := t.TempDir() + provider := NewCopilotWithPath(tmpDir) + + mock := &mockCopilotExecutor{ + stdout: "fixed lint issues in main.go", + exitCode: 0, + } + provider.SetAgent(mock) + + result, err := provider.Execute(context.Background(), Task{Prompt: "fix lint"}) + if err != nil { + t.Fatalf("Execute() error: %v", err) + } + if !mock.called { + t.Fatal("expected agent Execute to be called") + } + if mock.lastOpts.Prompt != "fix lint" { + t.Errorf("prompt = %q, want %q", mock.lastOpts.Prompt, "fix lint") + } + if result.Output != "fixed lint issues in main.go" { + t.Errorf("output = %q, want %q", result.Output, "fixed lint issues in main.go") + } + if result.ExitCode != 0 { + t.Errorf("exit code = %d, want 0", result.ExitCode) + } + + // Should have incremented request count + count, err := provider.GetRequestCount() + if err != nil { + t.Fatal(err) + } + if count != 1 { + t.Errorf("request count = %d, want 1", count) + } +} + +func TestCopilot_Execute_NoAgent(t *testing.T) { + tmpDir := t.TempDir() + provider := NewCopilotWithPath(tmpDir) + + _, err := provider.Execute(context.Background(), Task{Prompt: "fix lint"}) + if err == nil { + t.Fatal("expected error when no agent configured") + } +} + +func TestCopilot_Execute_AgentError(t *testing.T) { + tmpDir := t.TempDir() + provider := NewCopilotWithPath(tmpDir) + + mock := &mockCopilotExecutor{ + stdout: "partial output", + exitCode: 1, + err: fmt.Errorf("process exited with code 1"), + } + provider.SetAgent(mock) + + result, err := provider.Execute(context.Background(), Task{Prompt: "fix lint"}) + if err == nil { + t.Fatal("expected error from agent failure") + } + // Should still capture partial output + if result.Output != "partial output" { + t.Errorf("output = %q, want %q", result.Output, "partial output") + } + + // Should still increment request count (we made the request) + count, _ := provider.GetRequestCount() + if count != 1 { + t.Errorf("request count = %d, want 1 (should count even on error)", count) + } +} + +func TestEstimatePRUCost(t *testing.T) { + tests := []struct { + model string + expected float64 + }{ + {"gpt-4.1", 0}, + {"gpt-4o", 1}, + {"claude-sonnet", 1}, + {"claude-opus", 10}, + {"o3-pro", 50}, + {"unknown-model", 1}, // default + } + + for _, tt := range tests { + t.Run(tt.model, func(t *testing.T) { + cost := EstimatePRUCost(tt.model) + if cost != tt.expected { + t.Errorf("EstimatePRUCost(%q) = %v, want %v", tt.model, cost, tt.expected) + } + }) + } +} + func TestNewCopilot_Defaults(t *testing.T) { provider := NewCopilot() if provider.Name() != "copilot" { diff --git a/internal/providers/provider.go b/internal/providers/provider.go index 07f53a7..ea9c204 100644 --- a/internal/providers/provider.go +++ b/internal/providers/provider.go @@ -18,10 +18,13 @@ type Provider interface { // Task represents work to be done by a provider. type Task struct { - // TODO: Add task fields (prompt, files, etc.) + Prompt string // The prompt/task description + WorkDir string // Working directory for execution + Files []string // Optional file paths to include as context } // Result holds the outcome of a provider execution. type Result struct { - // TODO: Add result fields (output, tokens used, etc.) + Output string // Agent's text output + ExitCode int // Process exit code }