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

1import json 

2import os 

3from dataclasses import dataclass, field 

4from pathlib import Path 

5 

6DEFAULT_CONFIG_PATH = Path.home() / ".kimi-usage" / "config.json" 

7 

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 

14 

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 

26 

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] 

30 

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

35 

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 

45 

46 # Parse JSON config 

47 providers_data = json_data.get("providers", {}) 

48 general_data = json_data.get("general", {}) 

49 

50 self.config.refresh_interval_minutes = general_data.get("refreshIntervalMinutes", 5) 

51 self.config.output_mode = general_data.get("outputMode", "rich") 

52 

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 

58 

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

62 

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 

68 

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 

78 

79 # 2. Setup providers 

80 all_providers = ["kimi", "openai", "anthropic", "openrouter"] 

81 

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 } 

89 

90 env_urls = { 

91 "kimi": "KIMI_BASE_URL", 

92 "openai": "OPENAI_BASE_URL", 

93 "anthropic": "ANTHROPIC_BASE_URL", 

94 "openrouter": "OPENROUTER_BASE_URL", 

95 } 

96 

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 } 

103 

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 

114 

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] 

121 

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 ) 

130 

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 

139 

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 ) 

146 

147 # 3. Determine provider ordering 

148 default_order = ["anthropic", "openai", "openrouter", "kimi"] 

149 json_providers = list(providers_data.keys()) 

150 

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) 

158 

159 self.config.provider_order = final_order 

160 

161 return self.config 

162 

163 

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. 

173 

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) 

179 

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 = {} 

187 

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 

199 

200 with open(path, "w", encoding="utf-8") as fh: 

201 json.dump(data, fh, indent=2, ensure_ascii=False)