klaude-code 1.2.26__py3-none-any.whl → 1.2.27__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- klaude_code/cli/config_cmd.py +1 -5
- klaude_code/cli/list_model.py +170 -129
- klaude_code/cli/main.py +37 -5
- klaude_code/cli/runtime.py +4 -6
- klaude_code/cli/self_update.py +2 -1
- klaude_code/cli/session_cmd.py +1 -1
- klaude_code/config/__init__.py +3 -1
- klaude_code/config/assets/__init__.py +1 -0
- klaude_code/config/assets/builtin_config.yaml +233 -0
- klaude_code/config/builtin_config.py +37 -0
- klaude_code/config/config.py +332 -112
- klaude_code/config/select_model.py +45 -8
- klaude_code/core/executor.py +4 -2
- klaude_code/core/manager/llm_clients_builder.py +4 -1
- klaude_code/core/tool/file/edit_tool.py +4 -4
- klaude_code/core/tool/file/write_tool.py +4 -4
- klaude_code/core/tool/shell/bash_tool.py +2 -2
- klaude_code/llm/openai_compatible/stream.py +2 -1
- klaude_code/session/export.py +1 -1
- klaude_code/session/selector.py +2 -2
- klaude_code/session/session.py +4 -4
- klaude_code/ui/modes/repl/completers.py +4 -4
- klaude_code/ui/modes/repl/event_handler.py +1 -1
- klaude_code/ui/modes/repl/input_prompt_toolkit.py +4 -4
- klaude_code/ui/modes/repl/key_bindings.py +4 -4
- klaude_code/ui/renderers/diffs.py +1 -1
- klaude_code/ui/renderers/metadata.py +2 -2
- klaude_code/ui/renderers/tools.py +1 -1
- klaude_code/ui/rich/markdown.py +1 -1
- klaude_code/ui/rich/theme.py +1 -1
- klaude_code/ui/terminal/color.py +1 -1
- klaude_code/ui/terminal/control.py +4 -4
- {klaude_code-1.2.26.dist-info → klaude_code-1.2.27.dist-info}/METADATA +121 -127
- {klaude_code-1.2.26.dist-info → klaude_code-1.2.27.dist-info}/RECORD +36 -33
- {klaude_code-1.2.26.dist-info → klaude_code-1.2.27.dist-info}/WHEEL +0 -0
- {klaude_code-1.2.26.dist-info → klaude_code-1.2.27.dist-info}/entry_points.txt +0 -0
klaude_code/config/config.py
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
2
4
|
from functools import lru_cache
|
|
3
5
|
from pathlib import Path
|
|
4
6
|
from typing import Any, cast
|
|
@@ -6,25 +8,143 @@ from typing import Any, cast
|
|
|
6
8
|
import yaml
|
|
7
9
|
from pydantic import BaseModel, Field, ValidationError, model_validator
|
|
8
10
|
|
|
11
|
+
from klaude_code.config.builtin_config import SUPPORTED_API_KEY_ENVS, get_builtin_provider_configs
|
|
9
12
|
from klaude_code.protocol import llm_param
|
|
10
13
|
from klaude_code.protocol.sub_agent import iter_sub_agent_profiles
|
|
11
14
|
from klaude_code.trace import log
|
|
12
15
|
|
|
16
|
+
# Pattern to match ${ENV_VAR} syntax
|
|
17
|
+
_ENV_VAR_PATTERN = re.compile(r"^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse_env_var_syntax(value: str | None) -> tuple[str | None, str | None]:
|
|
21
|
+
"""Parse a value that may use ${ENV_VAR} syntax.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
A tuple of (env_var_name, resolved_value).
|
|
25
|
+
- If value uses ${ENV_VAR} syntax: (env_var_name, os.environ.get(env_var_name))
|
|
26
|
+
- If value is a plain string: (None, value)
|
|
27
|
+
- If value is None: (None, None)
|
|
28
|
+
"""
|
|
29
|
+
if value is None:
|
|
30
|
+
return None, None
|
|
31
|
+
|
|
32
|
+
match = _ENV_VAR_PATTERN.match(value)
|
|
33
|
+
if match:
|
|
34
|
+
env_var_name = match.group(1)
|
|
35
|
+
return env_var_name, os.environ.get(env_var_name)
|
|
36
|
+
|
|
37
|
+
return None, value
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def is_env_var_syntax(value: str | None) -> bool:
|
|
41
|
+
"""Check if a value uses ${ENV_VAR} syntax."""
|
|
42
|
+
if value is None:
|
|
43
|
+
return False
|
|
44
|
+
return _ENV_VAR_PATTERN.match(value) is not None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def resolve_api_key(value: str | None) -> str | None:
|
|
48
|
+
"""Resolve an API key value, expanding ${ENV_VAR} syntax if present."""
|
|
49
|
+
_, resolved = parse_env_var_syntax(value)
|
|
50
|
+
return resolved
|
|
51
|
+
|
|
52
|
+
|
|
13
53
|
config_path = Path.home() / ".klaude" / "klaude-config.yaml"
|
|
14
54
|
|
|
15
55
|
|
|
16
56
|
class ModelConfig(BaseModel):
|
|
57
|
+
model_name: str
|
|
58
|
+
model_params: llm_param.LLMConfigModelParameter
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ProviderConfig(llm_param.LLMConfigProviderParameter):
|
|
62
|
+
"""Full provider configuration (used in merged config)."""
|
|
63
|
+
|
|
64
|
+
model_list: list[ModelConfig] = Field(default_factory=lambda: [])
|
|
65
|
+
|
|
66
|
+
def get_resolved_api_key(self) -> str | None:
|
|
67
|
+
"""Get the resolved API key, expanding ${ENV_VAR} syntax if present."""
|
|
68
|
+
return resolve_api_key(self.api_key)
|
|
69
|
+
|
|
70
|
+
def get_api_key_env_var(self) -> str | None:
|
|
71
|
+
"""Get the environment variable name if ${ENV_VAR} syntax is used."""
|
|
72
|
+
env_var, _ = parse_env_var_syntax(self.api_key)
|
|
73
|
+
return env_var
|
|
74
|
+
|
|
75
|
+
def is_api_key_missing(self) -> bool:
|
|
76
|
+
"""Check if the API key is missing (either not set or env var not found).
|
|
77
|
+
|
|
78
|
+
For codex protocol, checks OAuth login status instead of API key.
|
|
79
|
+
"""
|
|
80
|
+
from klaude_code.protocol.llm_param import LLMClientProtocol
|
|
81
|
+
|
|
82
|
+
if self.protocol == LLMClientProtocol.CODEX:
|
|
83
|
+
# Codex uses OAuth authentication, not API key
|
|
84
|
+
from klaude_code.auth.codex.token_manager import CodexTokenManager
|
|
85
|
+
|
|
86
|
+
token_manager = CodexTokenManager()
|
|
87
|
+
state = token_manager.get_state()
|
|
88
|
+
# Consider available if logged in and token not expired
|
|
89
|
+
return state is None or state.is_expired()
|
|
90
|
+
|
|
91
|
+
return self.get_resolved_api_key() is None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class UserProviderConfig(BaseModel):
|
|
95
|
+
"""User provider configuration (allows partial overrides).
|
|
96
|
+
|
|
97
|
+
Unlike ProviderConfig, protocol is optional here since user may only want
|
|
98
|
+
to add models to an existing builtin provider.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
provider_name: str
|
|
102
|
+
protocol: llm_param.LLMClientProtocol | None = None
|
|
103
|
+
base_url: str | None = None
|
|
104
|
+
api_key: str | None = None
|
|
105
|
+
is_azure: bool = False
|
|
106
|
+
azure_api_version: str | None = None
|
|
107
|
+
model_list: list[ModelConfig] = Field(default_factory=lambda: [])
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class ModelEntry(BaseModel):
|
|
17
111
|
model_name: str
|
|
18
112
|
provider: str
|
|
19
113
|
model_params: llm_param.LLMConfigModelParameter
|
|
20
114
|
|
|
21
115
|
|
|
116
|
+
class UserConfig(BaseModel):
|
|
117
|
+
"""User configuration (what gets saved to disk)."""
|
|
118
|
+
|
|
119
|
+
main_model: str | None = None
|
|
120
|
+
sub_agent_models: dict[str, str] = Field(default_factory=dict)
|
|
121
|
+
theme: str | None = None
|
|
122
|
+
provider_list: list[UserProviderConfig] = Field(default_factory=lambda: [])
|
|
123
|
+
|
|
124
|
+
@model_validator(mode="before")
|
|
125
|
+
@classmethod
|
|
126
|
+
def _normalize_sub_agent_models(cls, data: dict[str, Any]) -> dict[str, Any]:
|
|
127
|
+
raw_val: Any = data.get("sub_agent_models") or {}
|
|
128
|
+
raw_models: dict[str, Any] = cast(dict[str, Any], raw_val) if isinstance(raw_val, dict) else {}
|
|
129
|
+
normalized: dict[str, str] = {}
|
|
130
|
+
key_map = {p.name.lower(): p.name for p in iter_sub_agent_profiles()}
|
|
131
|
+
for key, value in dict(raw_models).items():
|
|
132
|
+
canonical = key_map.get(str(key).lower(), str(key))
|
|
133
|
+
normalized[canonical] = str(value)
|
|
134
|
+
data["sub_agent_models"] = normalized
|
|
135
|
+
return data
|
|
136
|
+
|
|
137
|
+
|
|
22
138
|
class Config(BaseModel):
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
main_model: str
|
|
139
|
+
"""Merged configuration (builtin + user) for runtime use."""
|
|
140
|
+
|
|
141
|
+
main_model: str | None = None
|
|
26
142
|
sub_agent_models: dict[str, str] = Field(default_factory=dict)
|
|
27
143
|
theme: str | None = None
|
|
144
|
+
provider_list: list[ProviderConfig] = Field(default_factory=lambda: [])
|
|
145
|
+
|
|
146
|
+
# Internal: reference to original user config for saving
|
|
147
|
+
_user_config: UserConfig | None = None
|
|
28
148
|
|
|
29
149
|
@model_validator(mode="before")
|
|
30
150
|
@classmethod
|
|
@@ -39,35 +159,62 @@ class Config(BaseModel):
|
|
|
39
159
|
data["sub_agent_models"] = normalized
|
|
40
160
|
return data
|
|
41
161
|
|
|
42
|
-
def
|
|
43
|
-
|
|
162
|
+
def set_user_config(self, user_config: UserConfig | None) -> None:
|
|
163
|
+
"""Set the user config reference for saving."""
|
|
164
|
+
object.__setattr__(self, "_user_config", user_config)
|
|
44
165
|
|
|
45
166
|
def get_model_config(self, model_name: str) -> llm_param.LLMConfigParameter:
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
167
|
+
for provider in self.provider_list:
|
|
168
|
+
# Resolve ${ENV_VAR} syntax for api_key
|
|
169
|
+
api_key = provider.get_resolved_api_key()
|
|
170
|
+
if not api_key:
|
|
171
|
+
continue
|
|
172
|
+
for model in provider.model_list:
|
|
173
|
+
if model.model_name == model_name:
|
|
174
|
+
provider_dump = provider.model_dump(exclude={"model_list"})
|
|
175
|
+
provider_dump["api_key"] = api_key
|
|
176
|
+
return llm_param.LLMConfigParameter(
|
|
177
|
+
**provider_dump,
|
|
178
|
+
**model.model_params.model_dump(),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
raise ValueError(f"Unknown model: {model_name}")
|
|
182
|
+
|
|
183
|
+
def iter_model_entries(self, only_available: bool = False) -> list[ModelEntry]:
|
|
184
|
+
"""Return all model entries with their provider names.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
only_available: If True, only return models from providers with valid API keys.
|
|
188
|
+
"""
|
|
189
|
+
return [
|
|
190
|
+
ModelEntry(
|
|
191
|
+
model_name=model.model_name,
|
|
192
|
+
provider=provider.provider_name,
|
|
193
|
+
model_params=model.model_params,
|
|
194
|
+
)
|
|
195
|
+
for provider in self.provider_list
|
|
196
|
+
if not only_available or not provider.is_api_key_missing()
|
|
197
|
+
for model in provider.model_list
|
|
198
|
+
]
|
|
64
199
|
|
|
65
200
|
async def save(self) -> None:
|
|
201
|
+
"""Save user config to file (excludes builtin providers).
|
|
202
|
+
|
|
203
|
+
Only saves user-specific settings like main_model and custom providers.
|
|
204
|
+
Builtin providers are never written to the user config file.
|
|
66
205
|
"""
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
206
|
+
# Get user config, creating one if needed
|
|
207
|
+
user_config = self._user_config
|
|
208
|
+
if user_config is None:
|
|
209
|
+
user_config = UserConfig()
|
|
210
|
+
|
|
211
|
+
# Sync user-modifiable fields from merged config to user config
|
|
212
|
+
user_config.main_model = self.main_model
|
|
213
|
+
user_config.sub_agent_models = self.sub_agent_models
|
|
214
|
+
user_config.theme = self.theme
|
|
215
|
+
# Note: provider_list is NOT synced - user providers are already in user_config
|
|
216
|
+
|
|
217
|
+
config_dict = user_config.model_dump(mode="json", exclude_none=True, exclude_defaults=True)
|
|
71
218
|
|
|
72
219
|
def _save_config() -> None:
|
|
73
220
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -77,126 +224,199 @@ class Config(BaseModel):
|
|
|
77
224
|
await asyncio.to_thread(_save_config)
|
|
78
225
|
|
|
79
226
|
|
|
80
|
-
def get_example_config() ->
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
227
|
+
def get_example_config() -> UserConfig:
|
|
228
|
+
"""Generate example config for user reference (will be commented out)."""
|
|
229
|
+
return UserConfig(
|
|
230
|
+
main_model="my-model",
|
|
231
|
+
sub_agent_models={"explore": "fast-model", "oracle": "smart-model", "webagent": "fast-model", "task": "opus"},
|
|
84
232
|
provider_list=[
|
|
85
|
-
|
|
86
|
-
provider_name="
|
|
87
|
-
protocol=llm_param.LLMClientProtocol.
|
|
88
|
-
api_key="
|
|
89
|
-
base_url="https://api.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
protocol=llm_param.LLMClientProtocol.ANTHROPIC,
|
|
99
|
-
api_key="your-anthropic-api-key",
|
|
100
|
-
),
|
|
101
|
-
],
|
|
102
|
-
model_list=[
|
|
103
|
-
ModelConfig(
|
|
104
|
-
model_name="gpt-5.1",
|
|
105
|
-
provider="openai",
|
|
106
|
-
model_params=llm_param.LLMConfigModelParameter(
|
|
107
|
-
model="gpt-5.1-2025-11-13",
|
|
108
|
-
verbosity="medium",
|
|
109
|
-
thinking=llm_param.Thinking(
|
|
110
|
-
reasoning_effort="high",
|
|
111
|
-
reasoning_summary="auto",
|
|
112
|
-
),
|
|
113
|
-
context_limit=400000,
|
|
114
|
-
),
|
|
115
|
-
),
|
|
116
|
-
ModelConfig(
|
|
117
|
-
model_name="opus",
|
|
118
|
-
provider="anthropic",
|
|
119
|
-
model_params=llm_param.LLMConfigModelParameter(
|
|
120
|
-
model="claude-opus-4-5-20251101",
|
|
121
|
-
verbosity="high",
|
|
122
|
-
thinking=llm_param.Thinking(
|
|
123
|
-
type="enabled",
|
|
124
|
-
budget_tokens=31999,
|
|
233
|
+
UserProviderConfig(
|
|
234
|
+
provider_name="my-provider",
|
|
235
|
+
protocol=llm_param.LLMClientProtocol.OPENAI,
|
|
236
|
+
api_key="${MY_API_KEY}",
|
|
237
|
+
base_url="https://api.example.com/v1",
|
|
238
|
+
model_list=[
|
|
239
|
+
ModelConfig(
|
|
240
|
+
model_name="my-model",
|
|
241
|
+
model_params=llm_param.LLMConfigModelParameter(
|
|
242
|
+
model="model-id-from-provider",
|
|
243
|
+
max_tokens=16000,
|
|
244
|
+
context_limit=200000,
|
|
245
|
+
),
|
|
125
246
|
),
|
|
126
|
-
|
|
127
|
-
),
|
|
128
|
-
),
|
|
129
|
-
ModelConfig(
|
|
130
|
-
model_name="haiku",
|
|
131
|
-
provider="openrouter",
|
|
132
|
-
model_params=llm_param.LLMConfigModelParameter(
|
|
133
|
-
model="anthropic/claude-haiku-4.5",
|
|
134
|
-
max_tokens=32000,
|
|
135
|
-
provider_routing=llm_param.OpenRouterProviderRouting(
|
|
136
|
-
sort="throughput",
|
|
137
|
-
),
|
|
138
|
-
context_limit=200000,
|
|
139
|
-
),
|
|
247
|
+
],
|
|
140
248
|
),
|
|
141
249
|
],
|
|
142
250
|
)
|
|
143
251
|
|
|
144
252
|
|
|
145
|
-
def
|
|
253
|
+
def _get_builtin_config() -> Config:
|
|
254
|
+
"""Load built-in provider configurations."""
|
|
255
|
+
# Re-validate to ensure compatibility with current ProviderConfig class
|
|
256
|
+
# (needed for tests that may monkeypatch the class)
|
|
257
|
+
providers = [ProviderConfig.model_validate(p.model_dump()) for p in get_builtin_provider_configs()]
|
|
258
|
+
return Config(provider_list=providers)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _merge_provider(builtin: ProviderConfig, user: UserProviderConfig) -> ProviderConfig:
|
|
262
|
+
"""Merge user provider config with builtin provider config.
|
|
263
|
+
|
|
264
|
+
Strategy:
|
|
265
|
+
- model_list: merge by model_name, user models override builtin models with same name
|
|
266
|
+
- Other fields (api_key, base_url, etc.): user config takes precedence if set
|
|
267
|
+
"""
|
|
268
|
+
# Merge model_list: builtin first, then user overrides/appends
|
|
269
|
+
merged_models: dict[str, ModelConfig] = {}
|
|
270
|
+
for m in builtin.model_list:
|
|
271
|
+
merged_models[m.model_name] = m
|
|
272
|
+
for m in user.model_list:
|
|
273
|
+
merged_models[m.model_name] = m
|
|
274
|
+
|
|
275
|
+
# For other fields, use user values if explicitly set, otherwise use builtin
|
|
276
|
+
# We check if user explicitly provided a value by comparing to defaults
|
|
277
|
+
merged_data = builtin.model_dump()
|
|
278
|
+
user_data = user.model_dump(exclude_defaults=True, exclude={"model_list"})
|
|
279
|
+
|
|
280
|
+
# Update with user's explicit settings
|
|
281
|
+
for key, value in user_data.items():
|
|
282
|
+
if value is not None:
|
|
283
|
+
merged_data[key] = value
|
|
284
|
+
|
|
285
|
+
merged_data["model_list"] = list(merged_models.values())
|
|
286
|
+
return ProviderConfig.model_validate(merged_data)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _merge_configs(user_config: UserConfig | None, builtin_config: Config) -> Config:
|
|
290
|
+
"""Merge user config with builtin config.
|
|
291
|
+
|
|
292
|
+
Strategy:
|
|
293
|
+
- provider_list: merge by provider_name
|
|
294
|
+
- Same name: merge model_list (user models override/append), other fields user takes precedence
|
|
295
|
+
- New name: add to list
|
|
296
|
+
- main_model: user config takes precedence
|
|
297
|
+
- sub_agent_models: merge, user takes precedence
|
|
298
|
+
- theme: user config takes precedence
|
|
299
|
+
|
|
300
|
+
The returned Config keeps a reference to user_config for saving.
|
|
301
|
+
"""
|
|
302
|
+
if user_config is None:
|
|
303
|
+
# No user config - return builtin with empty user config reference
|
|
304
|
+
merged = builtin_config.model_copy()
|
|
305
|
+
merged.set_user_config(None)
|
|
306
|
+
return merged
|
|
307
|
+
|
|
308
|
+
# Build lookup for builtin providers
|
|
309
|
+
builtin_providers: dict[str, ProviderConfig] = {p.provider_name: p for p in builtin_config.provider_list}
|
|
310
|
+
|
|
311
|
+
# Merge provider_list
|
|
312
|
+
merged_providers: dict[str, ProviderConfig] = dict(builtin_providers)
|
|
313
|
+
for user_provider in user_config.provider_list:
|
|
314
|
+
if user_provider.provider_name in builtin_providers:
|
|
315
|
+
# Merge with builtin provider
|
|
316
|
+
merged_providers[user_provider.provider_name] = _merge_provider(
|
|
317
|
+
builtin_providers[user_provider.provider_name], user_provider
|
|
318
|
+
)
|
|
319
|
+
else:
|
|
320
|
+
# New provider from user - must have protocol
|
|
321
|
+
if user_provider.protocol is None:
|
|
322
|
+
raise ValueError(
|
|
323
|
+
f"Provider '{user_provider.provider_name}' requires 'protocol' field (not a builtin provider)"
|
|
324
|
+
)
|
|
325
|
+
merged_providers[user_provider.provider_name] = ProviderConfig.model_validate(user_provider.model_dump())
|
|
326
|
+
|
|
327
|
+
# Merge sub_agent_models
|
|
328
|
+
merged_sub_agent_models = {**builtin_config.sub_agent_models, **user_config.sub_agent_models}
|
|
329
|
+
|
|
330
|
+
merged = Config(
|
|
331
|
+
main_model=user_config.main_model or builtin_config.main_model,
|
|
332
|
+
sub_agent_models=merged_sub_agent_models,
|
|
333
|
+
theme=user_config.theme or builtin_config.theme,
|
|
334
|
+
provider_list=list(merged_providers.values()),
|
|
335
|
+
)
|
|
336
|
+
# Keep reference to user config for saving
|
|
337
|
+
merged.set_user_config(user_config)
|
|
338
|
+
return merged
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _load_user_config() -> UserConfig | None:
|
|
342
|
+
"""Load user config from disk. Returns None if file doesn't exist or is empty."""
|
|
146
343
|
if not config_path.exists():
|
|
147
|
-
log(f"Config file not found: {config_path}")
|
|
148
|
-
example_config = get_example_config()
|
|
149
|
-
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
150
|
-
config_dict = example_config.model_dump(mode="json", exclude_none=True)
|
|
151
|
-
|
|
152
|
-
# Comment out all example config lines
|
|
153
|
-
yaml_str = yaml.dump(config_dict, default_flow_style=False, sort_keys=False) or ""
|
|
154
|
-
commented_yaml = "\n".join(f"# {line}" if line.strip() else "#" for line in yaml_str.splitlines())
|
|
155
|
-
_ = config_path.write_text(commented_yaml)
|
|
156
|
-
|
|
157
|
-
log(f"Example config created at: {config_path}")
|
|
158
|
-
log("Please edit the config file to set up your models", style="yellow bold")
|
|
159
344
|
return None
|
|
160
345
|
|
|
161
346
|
config_yaml = config_path.read_text()
|
|
162
347
|
config_dict = yaml.safe_load(config_yaml)
|
|
163
348
|
|
|
164
349
|
if config_dict is None:
|
|
165
|
-
log(f"Config file is empty or all commented: {config_path}", style="red bold")
|
|
166
|
-
log("Please edit the config file to set up your models", style="yellow bold")
|
|
167
350
|
return None
|
|
168
351
|
|
|
169
352
|
try:
|
|
170
|
-
|
|
353
|
+
return UserConfig.model_validate(config_dict)
|
|
171
354
|
except ValidationError as e:
|
|
172
355
|
log(f"Invalid config file: {config_path}", style="red bold")
|
|
173
356
|
log(str(e), style="red")
|
|
174
357
|
raise ValueError(f"Invalid config file: {config_path}") from e
|
|
175
358
|
|
|
176
|
-
|
|
359
|
+
|
|
360
|
+
def _ensure_config_file_exists() -> None:
|
|
361
|
+
"""Ensure config file exists with commented example."""
|
|
362
|
+
if config_path.exists():
|
|
363
|
+
return
|
|
364
|
+
|
|
365
|
+
example_config = get_example_config()
|
|
366
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
367
|
+
config_dict = example_config.model_dump(mode="json", exclude_none=True)
|
|
368
|
+
|
|
369
|
+
# Comment out all example config lines
|
|
370
|
+
yaml_str = yaml.dump(config_dict, default_flow_style=False, sort_keys=False) or ""
|
|
371
|
+
commented_yaml = "# Custom configuration (optional)\n"
|
|
372
|
+
commented_yaml += "# Built-in providers (anthropic, openai, openrouter, deepseek) are available automatically.\n"
|
|
373
|
+
commented_yaml += "# Just set the corresponding API key environment variable to use them.\n"
|
|
374
|
+
commented_yaml += "#\n"
|
|
375
|
+
commented_yaml += "# Example custom provider:\n"
|
|
376
|
+
commented_yaml += "\n".join(f"# {line}" if line.strip() else "#" for line in yaml_str.splitlines())
|
|
377
|
+
_ = config_path.write_text(commented_yaml)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _load_config_uncached() -> Config:
|
|
381
|
+
"""Load and merge builtin + user config. Always returns a valid Config."""
|
|
382
|
+
_ensure_config_file_exists()
|
|
383
|
+
|
|
384
|
+
builtin_config = _get_builtin_config()
|
|
385
|
+
user_config = _load_user_config()
|
|
386
|
+
|
|
387
|
+
return _merge_configs(user_config, builtin_config)
|
|
177
388
|
|
|
178
389
|
|
|
179
390
|
@lru_cache(maxsize=1)
|
|
180
|
-
def _load_config_cached() -> Config
|
|
391
|
+
def _load_config_cached() -> Config:
|
|
181
392
|
return _load_config_uncached()
|
|
182
393
|
|
|
183
394
|
|
|
184
|
-
def load_config() -> Config
|
|
185
|
-
"""Load config from disk
|
|
395
|
+
def load_config() -> Config:
|
|
396
|
+
"""Load config from disk (builtin + user merged).
|
|
186
397
|
|
|
187
|
-
|
|
188
|
-
|
|
398
|
+
Always returns a valid Config. Use config.iter_model_entries(only_available=True)
|
|
399
|
+
to check if any models are actually usable.
|
|
189
400
|
"""
|
|
190
|
-
|
|
191
401
|
try:
|
|
192
|
-
|
|
402
|
+
return _load_config_cached()
|
|
193
403
|
except ValueError:
|
|
194
404
|
_load_config_cached.cache_clear()
|
|
195
405
|
raise
|
|
196
406
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
407
|
+
|
|
408
|
+
def print_no_available_models_hint() -> None:
|
|
409
|
+
"""Print helpful message when no models are available due to missing API keys."""
|
|
410
|
+
log("No available models. Please set one of the following environment variables:", style="yellow")
|
|
411
|
+
log("")
|
|
412
|
+
for env_var in SUPPORTED_API_KEY_ENVS:
|
|
413
|
+
current_value = os.environ.get(env_var)
|
|
414
|
+
if current_value:
|
|
415
|
+
log(f" {env_var} = (set)", style="green")
|
|
416
|
+
else:
|
|
417
|
+
log(f" export {env_var}=<your-api-key>", style="dim")
|
|
418
|
+
log("")
|
|
419
|
+
log(f"Or add custom providers in: {config_path}", style="dim")
|
|
200
420
|
|
|
201
421
|
|
|
202
422
|
# Expose cache control for tests and callers that need to invalidate the cache.
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
from klaude_code.config.config import ModelEntry, load_config, print_no_available_models_hint
|
|
2
4
|
from klaude_code.trace import log
|
|
3
5
|
|
|
4
6
|
|
|
@@ -26,11 +28,15 @@ def select_model_from_config(preferred: str | None = None) -> str | None:
|
|
|
26
28
|
- Otherwise: fall through to interactive selection
|
|
27
29
|
"""
|
|
28
30
|
config = load_config()
|
|
29
|
-
|
|
30
|
-
models
|
|
31
|
+
|
|
32
|
+
# Only show models from providers with valid API keys
|
|
33
|
+
models: list[ModelEntry] = sorted(
|
|
34
|
+
config.iter_model_entries(only_available=True), key=lambda m: m.model_name.lower()
|
|
35
|
+
)
|
|
31
36
|
|
|
32
37
|
if not models:
|
|
33
|
-
|
|
38
|
+
print_no_available_models_hint()
|
|
39
|
+
return None
|
|
34
40
|
|
|
35
41
|
names: list[str] = [m.model_name for m in models]
|
|
36
42
|
|
|
@@ -54,7 +60,7 @@ def select_model_from_config(preferred: str | None = None) -> str | None:
|
|
|
54
60
|
|
|
55
61
|
# Normalized matching (e.g. gpt52 == gpt-5.2, gpt52 in gpt-5.2-2025-...)
|
|
56
62
|
preferred_norm = _normalize_model_key(preferred)
|
|
57
|
-
normalized_matches: list[
|
|
63
|
+
normalized_matches: list[ModelEntry] = []
|
|
58
64
|
if preferred_norm:
|
|
59
65
|
normalized_matches = [
|
|
60
66
|
m
|
|
@@ -91,15 +97,40 @@ def select_model_from_config(preferred: str | None = None) -> str | None:
|
|
|
91
97
|
# No matches: show all models without filter hint
|
|
92
98
|
preferred = None
|
|
93
99
|
|
|
100
|
+
# Non-interactive environments (CI/pipes) should never enter an interactive prompt.
|
|
101
|
+
# If we couldn't resolve to a single model deterministically above, fail with a clear hint.
|
|
102
|
+
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
|
103
|
+
log(("Error: cannot use interactive model selection without a TTY", "red"))
|
|
104
|
+
log(("Hint: pass --model <config-name> or set main_model in ~/.klaude/klaude-config.yaml", "yellow"))
|
|
105
|
+
if preferred:
|
|
106
|
+
log((f"Hint: '{preferred}' did not resolve to a single configured model", "yellow"))
|
|
107
|
+
return None
|
|
108
|
+
|
|
94
109
|
try:
|
|
95
110
|
import questionary
|
|
96
111
|
|
|
97
112
|
choices: list[questionary.Choice] = []
|
|
98
113
|
|
|
99
114
|
max_model_name_length = max(len(m.model_name) for m in filtered_models)
|
|
115
|
+
|
|
116
|
+
# Build model_id with thinking suffix as a single unit
|
|
117
|
+
def build_model_id_with_thinking(m: ModelEntry) -> str:
|
|
118
|
+
model_id = m.model_params.model or "N/A"
|
|
119
|
+
thinking = m.model_params.thinking
|
|
120
|
+
if thinking:
|
|
121
|
+
if thinking.reasoning_effort:
|
|
122
|
+
return f"{model_id} ({thinking.reasoning_effort})"
|
|
123
|
+
elif thinking.budget_tokens:
|
|
124
|
+
return f"{model_id} (think {thinking.budget_tokens})"
|
|
125
|
+
return model_id
|
|
126
|
+
|
|
127
|
+
model_id_with_thinking = {m.model_name: build_model_id_with_thinking(m) for m in filtered_models}
|
|
128
|
+
max_model_id_length = max(len(v) for v in model_id_with_thinking.values())
|
|
129
|
+
|
|
100
130
|
for m in filtered_models:
|
|
101
131
|
star = "★ " if m.model_name == config.main_model else " "
|
|
102
|
-
|
|
132
|
+
model_id_str = model_id_with_thinking[m.model_name]
|
|
133
|
+
title = f"{star}{m.model_name:<{max_model_name_length}} → {model_id_str:<{max_model_id_length}} @ {m.provider}"
|
|
103
134
|
choices.append(questionary.Choice(title=title, value=m.model_name))
|
|
104
135
|
|
|
105
136
|
try:
|
|
@@ -129,5 +160,11 @@ def select_model_from_config(preferred: str | None = None) -> str | None:
|
|
|
129
160
|
except KeyboardInterrupt:
|
|
130
161
|
return None
|
|
131
162
|
except Exception as e:
|
|
132
|
-
log(f"Failed to use questionary
|
|
133
|
-
return
|
|
163
|
+
log((f"Failed to use questionary for model selection: {e}", "yellow"))
|
|
164
|
+
# Never return an unvalidated model name here.
|
|
165
|
+
# If we can't interactively select, fall back to a known configured model.
|
|
166
|
+
if isinstance(preferred, str) and preferred in names:
|
|
167
|
+
return preferred
|
|
168
|
+
if config.main_model and config.main_model in names:
|
|
169
|
+
return config.main_model
|
|
170
|
+
return None
|
klaude_code/core/executor.py
CHANGED
|
@@ -229,8 +229,6 @@ class ExecutorContext:
|
|
|
229
229
|
async def handle_change_model(self, operation: op.ChangeModelOperation) -> None:
|
|
230
230
|
agent = await self._ensure_agent(operation.session_id)
|
|
231
231
|
config = load_config()
|
|
232
|
-
if config is None:
|
|
233
|
-
raise ValueError("Configuration must be initialized before changing model")
|
|
234
232
|
|
|
235
233
|
llm_config = config.get_model_config(operation.model_name)
|
|
236
234
|
llm_client = create_llm_client(llm_config)
|
|
@@ -239,6 +237,10 @@ class ExecutorContext:
|
|
|
239
237
|
agent.session.model_config_name = operation.model_name
|
|
240
238
|
agent.session.model_thinking = llm_config.thinking
|
|
241
239
|
|
|
240
|
+
# Save the selection as default main_model
|
|
241
|
+
config.main_model = operation.model_name
|
|
242
|
+
await config.save()
|
|
243
|
+
|
|
242
244
|
developer_item = model.DeveloperMessageItem(
|
|
243
245
|
content=f"Switched to: {llm_config.model}",
|
|
244
246
|
command_output=model.CommandOutput(command_name=commands.CommandName.MODEL),
|
|
@@ -19,7 +19,10 @@ def build_llm_clients(
|
|
|
19
19
|
"""Create an ``LLMClients`` bundle driven by application config."""
|
|
20
20
|
|
|
21
21
|
# Resolve main agent LLM config
|
|
22
|
-
|
|
22
|
+
model_name = model_override or config.main_model
|
|
23
|
+
if model_name is None:
|
|
24
|
+
raise ValueError("No model specified. Use --model or --select-model to specify a model.")
|
|
25
|
+
llm_config = config.get_model_config(model_name)
|
|
23
26
|
|
|
24
27
|
log_debug(
|
|
25
28
|
"Main LLM config",
|