diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..5913425 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,132 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/BurntSushi/toml" +) + +// Config holds all cburn configuration. +type Config struct { + General GeneralConfig `toml:"general"` + AdminAPI AdminAPIConfig `toml:"admin_api"` + Budget BudgetConfig `toml:"budget"` + Appearance AppearanceConfig `toml:"appearance"` + Pricing PricingOverrides `toml:"pricing"` +} + +// GeneralConfig holds general preferences. +type GeneralConfig struct { + DefaultDays int `toml:"default_days"` + IncludeSubagents bool `toml:"include_subagents"` + ClaudeDir string `toml:"claude_dir,omitempty"` +} + +// AdminAPIConfig holds Anthropic Admin API settings. +type AdminAPIConfig struct { + APIKey string `toml:"api_key,omitempty"` + BaseURL string `toml:"base_url,omitempty"` +} + +// BudgetConfig holds budget tracking settings. +type BudgetConfig struct { + MonthlyUSD *float64 `toml:"monthly_usd,omitempty"` +} + +// AppearanceConfig holds theme settings. +type AppearanceConfig struct { + Theme string `toml:"theme"` +} + +// PricingOverrides allows user-defined pricing for specific models. +type PricingOverrides struct { + Overrides map[string]ModelPricingOverride `toml:"overrides,omitempty"` +} + +// ModelPricingOverride holds per-model pricing overrides. +type ModelPricingOverride struct { + InputPerMTok *float64 `toml:"input_per_mtok,omitempty"` + OutputPerMTok *float64 `toml:"output_per_mtok,omitempty"` + CacheWrite5mPerMTok *float64 `toml:"cache_write_5m_per_mtok,omitempty"` + CacheWrite1hPerMTok *float64 `toml:"cache_write_1h_per_mtok,omitempty"` + CacheReadPerMTok *float64 `toml:"cache_read_per_mtok,omitempty"` +} + +// DefaultConfig returns the default configuration. +func DefaultConfig() Config { + return Config{ + General: GeneralConfig{ + DefaultDays: 30, + IncludeSubagents: true, + }, + Appearance: AppearanceConfig{ + Theme: "flexoki-dark", + }, + } +} + +// ConfigDir returns the XDG-compliant config directory. +func ConfigDir() string { + if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { + return filepath.Join(xdg, "cburn") + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".config", "cburn") +} + +// ConfigPath returns the full path to the config file. +func ConfigPath() string { + return filepath.Join(ConfigDir(), "config.toml") +} + +// Load reads the config file, returning defaults if it doesn't exist. +func Load() (Config, error) { + cfg := DefaultConfig() + + data, err := os.ReadFile(ConfigPath()) + if err != nil { + if os.IsNotExist(err) { + return cfg, nil + } + return cfg, fmt.Errorf("reading config: %w", err) + } + + if err := toml.Unmarshal(data, &cfg); err != nil { + return cfg, fmt.Errorf("parsing config: %w", err) + } + + return cfg, nil +} + +// Save writes the config to disk. +func Save(cfg Config) error { + dir := ConfigDir() + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("creating config dir: %w", err) + } + + f, err := os.Create(ConfigPath()) + if err != nil { + return fmt.Errorf("creating config file: %w", err) + } + defer f.Close() + + enc := toml.NewEncoder(f) + return enc.Encode(cfg) +} + +// GetAdminAPIKey returns the API key from env var or config, in that order. +func GetAdminAPIKey(cfg Config) string { + if key := os.Getenv("ANTHROPIC_ADMIN_KEY"); key != "" { + return key + } + return cfg.AdminAPI.APIKey +} + +// Exists returns true if a config file exists on disk. +func Exists() bool { + _, err := os.Stat(ConfigPath()) + return err == nil +} diff --git a/internal/config/plan.go b/internal/config/plan.go new file mode 100644 index 0000000..25a3cad --- /dev/null +++ b/internal/config/plan.go @@ -0,0 +1,40 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" +) + +// PlanInfo holds detected Claude subscription plan info. +type PlanInfo struct { + BillingType string + PlanCeiling float64 +} + +// DetectPlan reads ~/.claude/.claude.json to determine the billing plan. +func DetectPlan(claudeDir string) PlanInfo { + path := filepath.Join(claudeDir, ".claude.json") + data, err := os.ReadFile(path) + if err != nil { + return PlanInfo{PlanCeiling: 200} // default to Max plan + } + + var raw struct { + BillingType string `json:"billingType"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return PlanInfo{PlanCeiling: 200} + } + + info := PlanInfo{BillingType: raw.BillingType} + + switch raw.BillingType { + case "stripe_subscription": + info.PlanCeiling = 200 // Max plan default + default: + info.PlanCeiling = 100 // Pro plan default + } + + return info +} diff --git a/internal/config/pricing.go b/internal/config/pricing.go new file mode 100644 index 0000000..798d981 --- /dev/null +++ b/internal/config/pricing.go @@ -0,0 +1,135 @@ +package config + +import "strings" + +// ModelPricing holds per-million-token prices for a model. +type ModelPricing struct { + InputPerMTok float64 + OutputPerMTok float64 + CacheWrite5mPerMTok float64 + CacheWrite1hPerMTok float64 + CacheReadPerMTok float64 + // Long context overrides (>200K input tokens) + LongInputPerMTok float64 + LongOutputPerMTok float64 +} + +// DefaultPricing maps model base names to their pricing. +var DefaultPricing = map[string]ModelPricing{ + "claude-opus-4-6": { + InputPerMTok: 5.00, OutputPerMTok: 25.00, + CacheWrite5mPerMTok: 6.25, CacheWrite1hPerMTok: 10.00, CacheReadPerMTok: 0.50, + LongInputPerMTok: 10.00, LongOutputPerMTok: 37.50, + }, + "claude-opus-4-5": { + InputPerMTok: 5.00, OutputPerMTok: 25.00, + CacheWrite5mPerMTok: 6.25, CacheWrite1hPerMTok: 10.00, CacheReadPerMTok: 0.50, + LongInputPerMTok: 10.00, LongOutputPerMTok: 37.50, + }, + "claude-opus-4-1": { + InputPerMTok: 15.00, OutputPerMTok: 75.00, + CacheWrite5mPerMTok: 18.75, CacheWrite1hPerMTok: 30.00, CacheReadPerMTok: 1.50, + LongInputPerMTok: 30.00, LongOutputPerMTok: 112.50, + }, + "claude-opus-4": { + InputPerMTok: 15.00, OutputPerMTok: 75.00, + CacheWrite5mPerMTok: 18.75, CacheWrite1hPerMTok: 30.00, CacheReadPerMTok: 1.50, + LongInputPerMTok: 30.00, LongOutputPerMTok: 112.50, + }, + "claude-sonnet-4-6": { + InputPerMTok: 3.00, OutputPerMTok: 15.00, + CacheWrite5mPerMTok: 3.75, CacheWrite1hPerMTok: 6.00, CacheReadPerMTok: 0.30, + LongInputPerMTok: 6.00, LongOutputPerMTok: 22.50, + }, + "claude-sonnet-4-5": { + InputPerMTok: 3.00, OutputPerMTok: 15.00, + CacheWrite5mPerMTok: 3.75, CacheWrite1hPerMTok: 6.00, CacheReadPerMTok: 0.30, + LongInputPerMTok: 6.00, LongOutputPerMTok: 22.50, + }, + "claude-sonnet-4": { + InputPerMTok: 3.00, OutputPerMTok: 15.00, + CacheWrite5mPerMTok: 3.75, CacheWrite1hPerMTok: 6.00, CacheReadPerMTok: 0.30, + LongInputPerMTok: 6.00, LongOutputPerMTok: 22.50, + }, + "claude-haiku-4-5": { + InputPerMTok: 1.00, OutputPerMTok: 5.00, + CacheWrite5mPerMTok: 1.25, CacheWrite1hPerMTok: 2.00, CacheReadPerMTok: 0.10, + LongInputPerMTok: 2.00, LongOutputPerMTok: 7.50, + }, + "claude-haiku-3-5": { + InputPerMTok: 0.80, OutputPerMTok: 4.00, + CacheWrite5mPerMTok: 1.00, CacheWrite1hPerMTok: 1.60, CacheReadPerMTok: 0.08, + LongInputPerMTok: 1.60, LongOutputPerMTok: 6.00, + }, +} + +// NormalizeModelName strips date suffixes from model identifiers. +// e.g., "claude-opus-4-5-20251101" -> "claude-opus-4-5" +func NormalizeModelName(raw string) string { + // Models can have date suffixes like -20251101 (8 digits) + // Strategy: try progressively shorter prefixes against the pricing table + if _, ok := DefaultPricing[raw]; ok { + return raw + } + + // Strip last segment if it looks like a date (all digits) + parts := strings.Split(raw, "-") + if len(parts) >= 2 { + last := parts[len(parts)-1] + if isAllDigits(last) && len(last) >= 8 { + candidate := strings.Join(parts[:len(parts)-1], "-") + if _, ok := DefaultPricing[candidate]; ok { + return candidate + } + } + } + + return raw +} + +func isAllDigits(s string) bool { + for _, c := range s { + if c < '0' || c > '9' { + return false + } + } + return len(s) > 0 +} + +// LookupPricing returns the pricing for a model, normalizing the name first. +// Returns zero pricing and false if the model is unknown. +func LookupPricing(model string) (ModelPricing, bool) { + normalized := NormalizeModelName(model) + p, ok := DefaultPricing[normalized] + return p, ok +} + +// CalculateCost computes the estimated cost in USD for a single API call. +func CalculateCost(model string, inputTokens, outputTokens, cache5m, cache1h, cacheRead int64) float64 { + pricing, ok := LookupPricing(model) + if !ok { + return 0 + } + + // Use standard context pricing (long context detection would need total + // input context size which we don't have per-call; standard is the default) + cost := float64(inputTokens) * pricing.InputPerMTok / 1_000_000 + cost += float64(outputTokens) * pricing.OutputPerMTok / 1_000_000 + cost += float64(cache5m) * pricing.CacheWrite5mPerMTok / 1_000_000 + cost += float64(cache1h) * pricing.CacheWrite1hPerMTok / 1_000_000 + cost += float64(cacheRead) * pricing.CacheReadPerMTok / 1_000_000 + + return cost +} + +// CalculateCacheSavings computes how much the cache reads saved vs full input pricing. +func CalculateCacheSavings(model string, cacheReadTokens int64) float64 { + pricing, ok := LookupPricing(model) + if !ok { + return 0 + } + // Cache reads at cache rate vs what they would have cost at full input rate + fullCost := float64(cacheReadTokens) * pricing.InputPerMTok / 1_000_000 + actualCost := float64(cacheReadTokens) * pricing.CacheReadPerMTok / 1_000_000 + return fullCost - actualCost +}