Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/nightshift/commands/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
20 changes: 9 additions & 11 deletions internal/agents/copilot.go
Original file line number Diff line number Diff line change
Expand Up @@ -243,29 +243,27 @@ 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 {
return false
}

// 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.
Expand Down
6 changes: 2 additions & 4 deletions internal/budget/budget.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
39 changes: 39 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
93 changes: 93 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
87 changes: 77 additions & 10 deletions internal/providers/copilot.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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()
Expand All @@ -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 <code>
// - gh copilot suggest <prompt>
// 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).
Expand Down
Loading