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.
Files changed (36) hide show
  1. klaude_code/cli/config_cmd.py +1 -5
  2. klaude_code/cli/list_model.py +170 -129
  3. klaude_code/cli/main.py +37 -5
  4. klaude_code/cli/runtime.py +4 -6
  5. klaude_code/cli/self_update.py +2 -1
  6. klaude_code/cli/session_cmd.py +1 -1
  7. klaude_code/config/__init__.py +3 -1
  8. klaude_code/config/assets/__init__.py +1 -0
  9. klaude_code/config/assets/builtin_config.yaml +233 -0
  10. klaude_code/config/builtin_config.py +37 -0
  11. klaude_code/config/config.py +332 -112
  12. klaude_code/config/select_model.py +45 -8
  13. klaude_code/core/executor.py +4 -2
  14. klaude_code/core/manager/llm_clients_builder.py +4 -1
  15. klaude_code/core/tool/file/edit_tool.py +4 -4
  16. klaude_code/core/tool/file/write_tool.py +4 -4
  17. klaude_code/core/tool/shell/bash_tool.py +2 -2
  18. klaude_code/llm/openai_compatible/stream.py +2 -1
  19. klaude_code/session/export.py +1 -1
  20. klaude_code/session/selector.py +2 -2
  21. klaude_code/session/session.py +4 -4
  22. klaude_code/ui/modes/repl/completers.py +4 -4
  23. klaude_code/ui/modes/repl/event_handler.py +1 -1
  24. klaude_code/ui/modes/repl/input_prompt_toolkit.py +4 -4
  25. klaude_code/ui/modes/repl/key_bindings.py +4 -4
  26. klaude_code/ui/renderers/diffs.py +1 -1
  27. klaude_code/ui/renderers/metadata.py +2 -2
  28. klaude_code/ui/renderers/tools.py +1 -1
  29. klaude_code/ui/rich/markdown.py +1 -1
  30. klaude_code/ui/rich/theme.py +1 -1
  31. klaude_code/ui/terminal/color.py +1 -1
  32. klaude_code/ui/terminal/control.py +4 -4
  33. {klaude_code-1.2.26.dist-info → klaude_code-1.2.27.dist-info}/METADATA +121 -127
  34. {klaude_code-1.2.26.dist-info → klaude_code-1.2.27.dist-info}/RECORD +36 -33
  35. {klaude_code-1.2.26.dist-info → klaude_code-1.2.27.dist-info}/WHEEL +0 -0
  36. {klaude_code-1.2.26.dist-info → klaude_code-1.2.27.dist-info}/entry_points.txt +0 -0
@@ -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
- provider_list: list[llm_param.LLMConfigProviderParameter]
24
- model_list: list[ModelConfig]
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 get_main_model_config(self) -> llm_param.LLMConfigParameter:
43
- return self.get_model_config(self.main_model)
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
- model = next(
47
- (model for model in self.model_list if model.model_name == model_name),
48
- None,
49
- )
50
- if model is None:
51
- raise ValueError(f"Unknown model: {model_name}")
52
-
53
- provider = next(
54
- (provider for provider in self.provider_list if provider.provider_name == model.provider),
55
- None,
56
- )
57
- if provider is None:
58
- raise ValueError(f"Unknown provider: {model.provider}")
59
-
60
- return llm_param.LLMConfigParameter(
61
- **provider.model_dump(),
62
- **model.model_params.model_dump(),
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
- Save config to file.
68
- Notice: it won't preserve comments in the config file.
69
- """
70
- config_dict = self.model_dump(mode="json", exclude_none=True)
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() -> Config:
81
- return Config(
82
- main_model="opus",
83
- sub_agent_models={"explore": "haiku", "oracle": "gpt-5.1", "webagent": "haiku", "task": "opus"},
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
- llm_param.LLMConfigProviderParameter(
86
- provider_name="openai",
87
- protocol=llm_param.LLMClientProtocol.RESPONSES,
88
- api_key="your-openai-api-key",
89
- base_url="https://api.openai.com/v1",
90
- ),
91
- llm_param.LLMConfigProviderParameter(
92
- provider_name="openrouter",
93
- protocol=llm_param.LLMClientProtocol.OPENROUTER,
94
- api_key="your-openrouter-api-key",
95
- ),
96
- llm_param.LLMConfigProviderParameter(
97
- provider_name="anthropic",
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
- context_limit=200000,
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 _load_config_uncached() -> Config | None:
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
- config = Config.model_validate(config_dict)
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
- return config
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 | None:
391
+ def _load_config_cached() -> Config:
181
392
  return _load_config_uncached()
182
393
 
183
394
 
184
- def load_config() -> Config | None:
185
- """Load config from disk, caching only successful parses.
395
+ def load_config() -> Config:
396
+ """Load config from disk (builtin + user merged).
186
397
 
187
- Returns:
188
- Config object on success, or None when the config is missing/empty/commented out.
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
- config = _load_config_cached()
402
+ return _load_config_cached()
193
403
  except ValueError:
194
404
  _load_config_cached.cache_clear()
195
405
  raise
196
406
 
197
- if config is None:
198
- _load_config_cached.cache_clear()
199
- return config
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
- from klaude_code.config.config import ModelConfig, load_config
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
- assert config is not None
30
- models: list[ModelConfig] = sorted(config.model_list, key=lambda m: m.model_name.lower())
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
- raise ValueError("No models configured. Please update your config.yaml")
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[ModelConfig] = []
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
- title = f"{star}{m.model_name:<{max_model_name_length}} → {m.model_params.model or 'N/A'} @ {m.provider}"
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, falling back to default model, {e}")
133
- return preferred
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
@@ -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
- llm_config = config.get_model_config(model_override) if model_override else config.get_main_model_config()
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",