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
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