Coverage for src/kimi_code_usage/config.py: 100%
121 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-20 20:05 +0800
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-20 20:05 +0800
1import json
2import os
3from dataclasses import dataclass, field
4from pathlib import Path
6DEFAULT_CONFIG_PATH = Path.home() / ".kimi-usage" / "config.json"
8@dataclass
9class ProviderConfig:
10 api_key: str | None = None
11 base_url: str | None = None
12 enabled: bool = True
13 management_key: str | None = None
15@dataclass
16class AppConfig:
17 providers: dict[str, ProviderConfig] = field(default_factory=dict)
18 refresh_interval_minutes: int = 5
19 output_mode: str = "rich"
20 provider_order: list[str] = field(default_factory=list)
21 theme: str = "blue-dark"
22 language: str | None = None
23 visible_providers: list[str] | None = None
24 or_metric: str = "requests"
25 days_window: int = 30
27 @property
28 def enabled_providers(self) -> list[str]:
29 return [name for name, p_conf in self.providers.items() if p_conf.api_key and p_conf.enabled]
31class ConfigResolver:
32 def __init__(self, config_path: str | None = None):
33 self.config_path = Path(config_path) if config_path else DEFAULT_CONFIG_PATH
34 self.config = AppConfig()
36 def resolve(self) -> AppConfig:
37 # 1. Load from JSON if exists
38 json_data = {}
39 if self.config_path.exists():
40 try:
41 with open(self.config_path, encoding="utf-8") as f:
42 json_data = json.load(f)
43 except Exception:
44 pass # Ignore invalid JSON, fall back to default/env
46 # Parse JSON config
47 providers_data = json_data.get("providers", {})
48 general_data = json_data.get("general", {})
50 self.config.refresh_interval_minutes = general_data.get("refreshIntervalMinutes", 5)
51 self.config.output_mode = general_data.get("outputMode", "rich")
53 # Get theme from JSON, else ENV, default blue-dark
54 theme = general_data.get("theme")
55 if not theme:
56 theme = os.getenv("KIMI_USAGE_THEME", "blue-dark")
57 self.config.theme = theme
59 # Get language and visible providers from JSON
60 self.config.language = general_data.get("language")
61 self.config.visible_providers = general_data.get("visibleProviders")
63 # Get OpenRouter metric preference, default requests
64 or_metric = general_data.get("orMetric", "requests")
65 if or_metric not in ("spend", "requests", "tokens"):
66 or_metric = "requests"
67 self.config.or_metric = or_metric
69 # Get days window for daily chart, default 30
70 days_window = general_data.get("daysWindow", 30)
71 try:
72 days_window = int(days_window)
73 except (TypeError, ValueError):
74 days_window = 30
75 if days_window not in (7, 14, 30, 60, 90):
76 days_window = 30
77 self.config.days_window = days_window
79 # 2. Setup providers
80 all_providers = ["kimi", "openai", "anthropic", "openrouter"]
82 # Envs map
83 env_keys = {
84 "kimi": ("KIMI_API_KEY", "KIMI_CODING_API_KEY"),
85 "openai": ("OPENAI_API_KEY",),
86 "anthropic": ("ANTHROPIC_API_KEY",),
87 "openrouter": ("OPENROUTER_API_KEY", "OPENROUTER_ADMIN_KEY"),
88 }
90 env_urls = {
91 "kimi": "KIMI_BASE_URL",
92 "openai": "OPENAI_BASE_URL",
93 "anthropic": "ANTHROPIC_BASE_URL",
94 "openrouter": "OPENROUTER_BASE_URL",
95 }
97 default_urls = {
98 "kimi": "https://api.kimi.com/coding/v1",
99 "openai": "https://api.openai.com",
100 "anthropic": "https://api.anthropic.com",
101 "openrouter": "https://openrouter.ai/api",
102 }
104 for p in all_providers:
105 p_data = providers_data.get(p, {})
106 # Get API Key from JSON, else ENV
107 api_key = p_data.get("apiKey")
108 if not api_key:
109 for env_var in env_keys[p]:
110 val = os.getenv(env_var)
111 if val:
112 api_key = val
113 break
115 # Get Base URL from JSON, else ENV, else Default
116 base_url = p_data.get("baseUrl")
117 if not base_url:
118 base_url = os.getenv(env_urls[p])
119 if not base_url:
120 base_url = default_urls[p]
122 # Get optional management key for OpenRouter (activity endpoint)
123 management_key = None
124 if p == "openrouter":
125 management_key = (
126 p_data.get("managementApiKey")
127 or os.getenv("OPENROUTER_MANAGEMENT_KEY")
128 or os.getenv("OPENROUTER_ADMIN_KEY")
129 )
131 # Get Enabled status from JSON, else ENV, default True
132 enabled = p_data.get("enabled")
133 if enabled is None:
134 env_enabled = os.getenv(f"{p.upper()}_ENABLED")
135 if env_enabled is not None:
136 enabled = env_enabled.lower() not in ("false", "0", "no")
137 else:
138 enabled = True
140 self.config.providers[p] = ProviderConfig(
141 api_key=api_key if api_key else None,
142 base_url=base_url,
143 enabled=enabled,
144 management_key=management_key if management_key else None,
145 )
147 # 3. Determine provider ordering
148 default_order = ["anthropic", "openai", "openrouter", "kimi"]
149 json_providers = list(providers_data.keys())
151 final_order = []
152 for p in json_providers:
153 if p in all_providers and p not in final_order:
154 final_order.append(p)
155 for p in default_order:
156 if p not in final_order:
157 final_order.append(p)
159 self.config.provider_order = final_order
161 return self.config
164def save_theme(
165 theme_name: str,
166 language: str | None = None,
167 visible_providers: list[str] | None = None,
168 or_metric: str | None = None,
169 days_window: int | None = None,
170 config_path: Path | None = None
171) -> None:
172 """Persist settings into ``general`` in the JSON config file.
174 All other settings in the file are preserved. The config directory is
175 created automatically if it does not exist yet.
176 """
177 path = config_path or DEFAULT_CONFIG_PATH
178 path.parent.mkdir(parents=True, exist_ok=True)
180 data: dict = {}
181 if path.exists():
182 try:
183 with open(path, encoding="utf-8") as fh:
184 data = json.load(fh)
185 except Exception:
186 data = {}
188 general = data.get("general", {})
189 general["theme"] = theme_name
190 if language is not None:
191 general["language"] = language
192 if visible_providers is not None:
193 general["visibleProviders"] = visible_providers
194 if or_metric is not None:
195 general["orMetric"] = or_metric
196 if days_window is not None:
197 general["daysWindow"] = days_window
198 data["general"] = general
200 with open(path, "w", encoding="utf-8") as fh:
201 json.dump(data, fh, indent=2, ensure_ascii=False)