mycode-cli 0.8.2__py3-none-any.whl → 0.8.4__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.
mycode_cli/config.py CHANGED
@@ -3,7 +3,6 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
- import logging
7
6
  import os
8
7
  import re
9
8
  from dataclasses import dataclass, field
@@ -60,15 +59,6 @@ class ModelConfig:
60
59
  supports_pdf_input: bool | None = None
61
60
 
62
61
 
63
- _MODEL_OVERRIDE_KEYS = (
64
- "context_window",
65
- "max_output_tokens",
66
- "supports_reasoning",
67
- "supports_image_input",
68
- "supports_pdf_input",
69
- )
70
-
71
-
72
62
  @dataclass(frozen=True)
73
63
  class ProviderConfig:
74
64
  name: str
@@ -110,6 +100,7 @@ class ResolvedProvider:
110
100
  api_base: str | None
111
101
  reasoning_effort: str | None
112
102
  provider_name: str | None = None
103
+ model_config: ModelConfig | None = None
113
104
 
114
105
 
115
106
  def _load_json(path: Path) -> dict[str, Any] | None:
@@ -571,170 +562,5 @@ def _resolve_provider_runtime(
571
562
  api_key=resolved_api_key,
572
563
  api_base=resolved_api_base,
573
564
  reasoning_effort=reasoning_effort,
565
+ model_config=model_config,
574
566
  )
575
-
576
-
577
- def is_api_key_env_ref(value: str) -> str | None:
578
- """Return the env var name when ``value`` is a ``${NAME}`` reference."""
579
-
580
- match = _API_KEY_ENV_REF_RE.fullmatch(value.strip())
581
- return match.group(1) if match else None
582
-
583
-
584
- def _optional_string(raw: dict[str, Any], key: str, label: str) -> str | None:
585
- """Read an optional string field. Returns the trimmed value, or ``None`` when
586
- absent / null / empty so callers can simply skip the key."""
587
-
588
- if key not in raw:
589
- return None
590
- value = raw[key]
591
- if value is None or value == "":
592
- return None
593
- if not isinstance(value, str):
594
- raise ValueError(f"{label} must be a string")
595
- return value.strip() or None
596
-
597
-
598
- def _validate_default(raw: Any) -> dict[str, Any]:
599
- if not isinstance(raw, dict):
600
- raise ValueError("default must be an object")
601
-
602
- out: dict[str, Any] = {}
603
- for key in ("provider", "model"):
604
- value = _optional_string(raw, key, f"default.{key}")
605
- if value:
606
- out[key] = value
607
-
608
- effort = raw.get("reasoning_effort")
609
- if effort not in (None, ""):
610
- normalize_reasoning_effort(effort)
611
- out["reasoning_effort"] = effort
612
-
613
- ct = raw.get("compact_threshold")
614
- if ct is False:
615
- out["compact_threshold"] = False
616
- elif ct is not None:
617
- if isinstance(ct, bool) or not isinstance(ct, int | float) or not 0 <= ct <= 1:
618
- raise ValueError("default.compact_threshold must be a number in [0, 1] or false")
619
- out["compact_threshold"] = float(ct)
620
-
621
- return out
622
-
623
-
624
- def _validate_permission(raw: Any) -> Any:
625
- if isinstance(raw, str):
626
- return normalize_permission_level(raw)
627
- if not isinstance(raw, dict):
628
- raise ValueError("permission must be a string or object")
629
-
630
- out: dict[str, Any] = {}
631
- if "level" in raw:
632
- out["level"] = normalize_permission_level(raw.get("level"))
633
- if "mode" in raw:
634
- out["mode"] = normalize_permission_mode(raw.get("mode"))
635
- return out
636
-
637
-
638
- def _validate_provider(name: str, raw: Any) -> dict[str, Any]:
639
- if not isinstance(raw, dict):
640
- raise ValueError(f"provider {name!r} must be an object")
641
-
642
- out: dict[str, Any] = {}
643
-
644
- raw_type = raw.get("type")
645
- if raw_type in (None, ""):
646
- # Built-in name → type fallback; otherwise the user must spell it out.
647
- if not is_supported_provider(name):
648
- raise ValueError(f"provider {name!r} must set 'type'")
649
- elif not isinstance(raw_type, str):
650
- raise ValueError(f"provider {name!r}: type must be a string")
651
- elif not is_supported_provider(raw_type):
652
- supported = ", ".join(list_supported_providers())
653
- raise ValueError(f"provider {name!r}: unsupported type {raw_type!r}; supported: {supported}")
654
- else:
655
- out["type"] = raw_type
656
-
657
- for key in ("api_key", "base_url"):
658
- value = _optional_string(raw, key, f"provider {name!r}: {key}")
659
- if value:
660
- out[key] = value
661
-
662
- effort = raw.get("reasoning_effort")
663
- if effort not in (None, ""):
664
- normalize_reasoning_effort(effort)
665
- out["reasoning_effort"] = effort
666
-
667
- raw_models = raw.get("models")
668
- if raw_models is not None:
669
- models = _validate_models(name, raw_models)
670
- if models:
671
- out["models"] = models
672
-
673
- return out
674
-
675
-
676
- def _validate_models(name: str, raw: Any) -> dict[str, dict[str, Any]]:
677
- # Both list (ids only) and dict (id → metadata overrides) are accepted; we
678
- # always normalise to the dict form for storage.
679
- if isinstance(raw, list):
680
- items: list[tuple[Any, Any]] = [(m, None) for m in raw]
681
- elif isinstance(raw, dict):
682
- items = list(raw.items())
683
- else:
684
- raise ValueError(f"provider {name!r}: models must be a list or object")
685
-
686
- out: dict[str, dict[str, Any]] = {}
687
- for model_id, overrides in items:
688
- if not isinstance(model_id, str) or not model_id.strip():
689
- raise ValueError(f"provider {name!r}: model id must be a non-empty string")
690
- key = model_id.strip()
691
- if overrides is None:
692
- out[key] = {}
693
- elif isinstance(overrides, dict):
694
- out[key] = {k: v for k, v in overrides.items() if k in _MODEL_OVERRIDE_KEYS and v is not None}
695
- else:
696
- raise ValueError(f"provider {name!r}: model {key!r} config must be an object")
697
- return out
698
-
699
-
700
- def validate_global_config(data: Any) -> dict[str, Any]:
701
- """Validate a raw global config payload. Returns a cleaned dict ready to persist.
702
-
703
- Empty / null fields are dropped. Raises ``ValueError`` on invalid input.
704
- """
705
-
706
- if data is None:
707
- return {}
708
- if not isinstance(data, dict):
709
- raise ValueError("config must be an object")
710
-
711
- out: dict[str, Any] = {}
712
-
713
- if data.get("default") is not None:
714
- default = _validate_default(data["default"])
715
- if default:
716
- out["default"] = default
717
-
718
- if data.get("permission") is not None:
719
- out["permission"] = _validate_permission(data["permission"])
720
-
721
- raw_providers = data.get("providers")
722
- if raw_providers is not None:
723
- if not isinstance(raw_providers, dict):
724
- raise ValueError("providers must be an object")
725
- providers: dict[str, dict[str, Any]] = {}
726
- for name, raw in raw_providers.items():
727
- if not isinstance(name, str) or not name.strip():
728
- raise ValueError("provider name must be a non-empty string")
729
- cleaned = name.strip()
730
- providers[cleaned] = _validate_provider(cleaned, raw)
731
- if providers:
732
- out["providers"] = providers
733
-
734
- return out
735
-
736
-
737
- def setup_logging() -> None:
738
- """Configure default logging."""
739
-
740
- logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
mycode_cli/main.py CHANGED
@@ -6,7 +6,8 @@ import asyncio
6
6
  import os
7
7
  import sys
8
8
  from dataclasses import dataclass, replace
9
- from typing import Annotated
9
+ from typing import Annotated, Any
10
+ from uuid import uuid4
10
11
 
11
12
  import typer
12
13
 
@@ -24,7 +25,7 @@ from mycode_cli.config import (
24
25
  )
25
26
  from mycode_cli.permissions import PERMISSION_DENIED_BY_USER_OUTPUT, PERMISSION_DENIED_OUTPUT
26
27
 
27
- from .runtime import ResolvedSession, build_agent, resolve_session
28
+ from .runtime import build_agent
28
29
  from .tui.chat import TerminalChat
29
30
  from .tui.render import TerminalView
30
31
 
@@ -33,6 +34,58 @@ session_app = typer.Typer(help="Session management")
33
34
  app.add_typer(session_app, name="session")
34
35
 
35
36
 
37
+ @dataclass
38
+ class ResolvedSession:
39
+ """The session selected for the current CLI run.
40
+
41
+ `mode` is either `"new"` or `"resumed"`.
42
+ """
43
+
44
+ session_id: str
45
+ session: dict[str, Any]
46
+ messages: list[dict[str, Any]]
47
+ mode: str
48
+
49
+
50
+ async def resolve_session(
51
+ *,
52
+ store: SessionStore,
53
+ cwd: str,
54
+ requested_session_id: str | None,
55
+ continue_last: bool,
56
+ ) -> ResolvedSession:
57
+ """Resolve which session the CLI should load before starting."""
58
+
59
+ if requested_session_id:
60
+ data = await store.load_session(requested_session_id)
61
+ if not data or not data.get("session"):
62
+ raise ValueError(f"Unknown session: {requested_session_id}")
63
+ return ResolvedSession(
64
+ requested_session_id,
65
+ data.get("session") or {},
66
+ data.get("messages") or [],
67
+ "resumed",
68
+ )
69
+
70
+ if continue_last:
71
+ latest = await store.latest_session(cwd=cwd)
72
+ if latest and latest.get("id"):
73
+ session_id = str(latest["id"])
74
+ data = await store.load_session(session_id)
75
+ if not data:
76
+ raise ValueError(f"Unknown session: {session_id}")
77
+ return ResolvedSession(
78
+ session_id,
79
+ data.get("session") or latest,
80
+ data.get("messages") or [],
81
+ "resumed",
82
+ )
83
+
84
+ # New sessions: the id is allocated here; the on-disk session is created
85
+ # lazily by Agent.achat on the first persist.
86
+ return ResolvedSession(uuid4().hex, {}, [], "new")
87
+
88
+
36
89
  def _show_version(value: bool) -> None:
37
90
  if not value:
38
91
  return
mycode_cli/runtime.py CHANGED
@@ -1,48 +1,15 @@
1
- """CLI session selection and runtime updates."""
1
+ """Build an Agent from CLI settings shared by both the TUI and the web server."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from dataclasses import dataclass
6
- from typing import Any
7
- from uuid import uuid4
8
-
9
5
  from mycode.agent import Agent
10
- from mycode.providers import (
11
- get_provider_adapter,
12
- list_env_discoverable_providers,
13
- provider_api_key_from_env,
14
- provider_default_models,
15
- )
16
6
  from mycode.session import SessionStore
17
7
  from mycode.tools import DEFAULT_TOOL_SPECS
18
- from mycode_cli.config import ResolvedProvider, Settings, provider_has_api_key
8
+ from mycode_cli.config import ResolvedProvider, Settings
19
9
  from mycode_cli.permissions import ToolReviewCallback, build_permission_hooks
20
10
  from mycode_cli.system_prompt import build_system_prompt
21
11
 
22
12
 
23
- @dataclass
24
- class ResolvedSession:
25
- """The session selected for the current CLI run.
26
-
27
- `mode` is either `"new"` or `"resumed"`.
28
- """
29
-
30
- session_id: str
31
- session: dict[str, Any]
32
- messages: list[dict[str, Any]]
33
- mode: str
34
-
35
-
36
- @dataclass(frozen=True)
37
- class ProviderOption:
38
- """A provider option shown in the interactive provider switcher."""
39
-
40
- name: str
41
- provider: str
42
- models: tuple[str, ...]
43
- api_base: str | None
44
-
45
-
46
13
  def build_agent(
47
14
  *,
48
15
  store: SessionStore,
@@ -60,8 +27,7 @@ def build_agent(
60
27
  the store; callers never pass messages explicitly.
61
28
  """
62
29
 
63
- provider_config = settings.providers.get(resolved_provider.provider_name or "")
64
- model_config = provider_config.models.get(resolved_provider.model) if provider_config else None
30
+ model_config = resolved_provider.model_config
65
31
  agent = Agent(
66
32
  model=resolved_provider.model,
67
33
  provider=resolved_provider.provider,
@@ -83,162 +49,3 @@ def build_agent(
83
49
  )
84
50
  agent.hooks = build_permission_hooks(settings, review=review, on_user_denied=agent.cancel)
85
51
  return agent
86
-
87
-
88
- def clone_agent(agent: Agent, *, store: SessionStore, session_id: str) -> Agent:
89
- """Keep the current runtime config while swapping session state.
90
-
91
- History auto-loads from disk when ``session_id`` exists under the store.
92
- """
93
-
94
- return Agent(
95
- model=agent.model,
96
- provider=agent.provider,
97
- cwd=agent.cwd,
98
- session_dir=store.data_dir,
99
- session_id=session_id,
100
- api_key=agent.api_key,
101
- api_base=agent.api_base,
102
- max_turns=agent.max_turns,
103
- max_tokens=agent.max_tokens,
104
- context_window=agent.context_window,
105
- compact_threshold=agent.compact_threshold,
106
- reasoning_effort=agent.reasoning_effort,
107
- supports_reasoning=agent.supports_reasoning,
108
- supports_image_input=agent.supports_image_input,
109
- supports_pdf_input=agent.supports_pdf_input,
110
- system=agent.system,
111
- tools=DEFAULT_TOOL_SPECS,
112
- hooks=agent.hooks,
113
- )
114
-
115
-
116
- def list_provider_options(settings: Settings) -> list[ProviderOption]:
117
- """Return configured providers plus env-discovered built-ins."""
118
-
119
- options: list[ProviderOption] = []
120
- configured_types: set[str] = set()
121
-
122
- for name, config in settings.providers.items():
123
- models = tuple(config.models)
124
- options.append(
125
- ProviderOption(
126
- name=name,
127
- provider=config.type,
128
- models=models,
129
- api_base=config.base_url,
130
- )
131
- )
132
- if provider_has_api_key(config):
133
- configured_types.add(config.type)
134
-
135
- for provider_name in list_env_discoverable_providers():
136
- if provider_name in configured_types or not provider_api_key_from_env(provider_name):
137
- continue
138
- options.append(
139
- ProviderOption(
140
- name=provider_name,
141
- provider=provider_name,
142
- models=provider_default_models(provider_name),
143
- api_base=None,
144
- )
145
- )
146
-
147
- return options
148
-
149
-
150
- def get_provider_option(settings: Settings, *, provider: str, api_base: str | None) -> ProviderOption | None:
151
- """Return the current selectable provider option."""
152
-
153
- for option in list_provider_options(settings):
154
- if option.provider == provider and option.api_base == api_base:
155
- return option
156
- return None
157
-
158
-
159
- def list_model_options(settings: Settings, *, provider: str, api_base: str | None, current_model: str) -> list[str]:
160
- """Return the selectable model list for the current provider runtime."""
161
-
162
- option = get_provider_option(settings, provider=provider, api_base=api_base)
163
- models = option.models if option else provider_default_models(provider)
164
- return list(dict.fromkeys([current_model, *models]))
165
-
166
-
167
- def supports_reasoning_effort(agent: Agent) -> bool:
168
- """Return whether the current agent provider+model supports reasoning effort."""
169
-
170
- return agent.supports_reasoning is True and get_provider_adapter(agent.provider).supports_reasoning_effort
171
-
172
-
173
- async def resolve_session(
174
- *,
175
- store: SessionStore,
176
- cwd: str,
177
- requested_session_id: str | None,
178
- continue_last: bool,
179
- ) -> ResolvedSession:
180
- """Resolve which session the CLI should load before starting."""
181
-
182
- if requested_session_id:
183
- data = await store.load_session(requested_session_id)
184
- if not data or not data.get("session"):
185
- raise ValueError(f"Unknown session: {requested_session_id}")
186
- return ResolvedSession(
187
- requested_session_id,
188
- data.get("session") or {},
189
- data.get("messages") or [],
190
- "resumed",
191
- )
192
-
193
- if continue_last:
194
- latest = await store.latest_session(cwd=cwd)
195
- if latest and latest.get("id"):
196
- session_id = str(latest["id"])
197
- data = await store.load_session(session_id)
198
- if not data:
199
- raise ValueError(f"Unknown session: {session_id}")
200
- return ResolvedSession(
201
- session_id,
202
- data.get("session") or latest,
203
- data.get("messages") or [],
204
- "resumed",
205
- )
206
-
207
- # New sessions: the id is allocated here; the on-disk session is created
208
- # lazily by Agent.achat on the first persist.
209
- return ResolvedSession(uuid4().hex, {}, [], "new")
210
-
211
-
212
- def apply_resolved_provider(agent: Agent, resolved: ResolvedProvider, settings: Settings) -> bool:
213
- """Copy runtime settings from a resolved provider onto an active agent.
214
-
215
- Returns whether any field actually changed. Does not touch session state.
216
- Re-derives model capability fields from metadata when the provider or model
217
- changes so the agent reports accurate support flags.
218
- """
219
-
220
- runtime_changed = (
221
- agent.provider != resolved.provider
222
- or agent.model != resolved.model
223
- or agent.api_base != resolved.api_base
224
- or agent.api_key != resolved.api_key
225
- or agent.reasoning_effort != resolved.reasoning_effort
226
- )
227
-
228
- agent.provider = resolved.provider
229
- agent.model = resolved.model
230
- agent.api_key = resolved.api_key
231
- agent.api_base = resolved.api_base
232
- agent.reasoning_effort = resolved.reasoning_effort
233
-
234
- if runtime_changed:
235
- provider_config = settings.providers.get(resolved.provider_name or "")
236
- model_config = provider_config.models.get(resolved.model) if provider_config else None
237
- agent.refresh_capabilities(
238
- max_tokens=model_config.max_output_tokens if model_config else None,
239
- context_window=model_config.context_window if model_config else None,
240
- supports_reasoning=model_config.supports_reasoning if model_config else None,
241
- supports_image_input=model_config.supports_image_input if model_config else None,
242
- supports_pdf_input=model_config.supports_pdf_input if model_config else None,
243
- )
244
- return runtime_changed
mycode_cli/server/app.py CHANGED
@@ -7,13 +7,10 @@ from fastapi import FastAPI
7
7
  from fastapi.middleware.cors import CORSMiddleware
8
8
  from fastapi.staticfiles import StaticFiles
9
9
 
10
- from mycode_cli.config import setup_logging
11
- from mycode_cli.server.routers import (
12
- chat_router,
13
- sessions_router,
14
- settings_router,
15
- workspaces_router,
16
- )
10
+ from mycode_cli.server.routers.chat import router as chat_router
11
+ from mycode_cli.server.routers.sessions import router as sessions_router
12
+ from mycode_cli.server.routers.settings import router as settings_router
13
+ from mycode_cli.server.routers.workspaces import router as workspaces_router
17
14
 
18
15
  logger = logging.getLogger(__name__)
19
16
 
@@ -26,7 +23,7 @@ def web_static_path() -> Path:
26
23
 
27
24
  def create_app(*, serve_web: bool = True) -> FastAPI:
28
25
  """Create the FastAPI app."""
29
- setup_logging()
26
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
30
27
  application = FastAPI(title="mycode")
31
28
 
32
29
  application.add_middleware(
@@ -1,8 +0,0 @@
1
- """API routers package."""
2
-
3
- from mycode_cli.server.routers.chat import router as chat_router
4
- from mycode_cli.server.routers.sessions import router as sessions_router
5
- from mycode_cli.server.routers.settings import router as settings_router
6
- from mycode_cli.server.routers.workspaces import router as workspaces_router
7
-
8
- __all__ = ["chat_router", "sessions_router", "settings_router", "workspaces_router"]
@@ -95,7 +95,12 @@ async def chat(chat: ChatRequest, store: StoreDep, runs: RunManagerDep):
95
95
  if chat.message and chat.input:
96
96
  raise HTTPException(status_code=400, detail="message and input are mutually exclusive")
97
97
 
98
- if chat.input:
98
+ if not chat.input:
99
+ message_text = str(chat.message or "").strip()
100
+ if not message_text:
101
+ raise HTTPException(status_code=400, detail="message or input is required")
102
+ user_message = build_message("user", [text_block(message_text)])
103
+ else:
99
104
  blocks: list[dict[str, Any]] = []
100
105
  for block in chat.input:
101
106
  if block.type == "text":
@@ -110,59 +115,45 @@ async def chat(chat: ChatRequest, store: StoreDep, runs: RunManagerDep):
110
115
  )
111
116
  elif text:
112
117
  blocks.append(text_block(text))
113
- continue
114
118
 
115
- if block.type == "document":
119
+ elif block.type == "document":
116
120
  if block.data:
117
121
  mime_type = block.mime_type or "application/pdf"
118
122
  if mime_type != "application/pdf":
119
123
  raise HTTPException(status_code=400, detail="unsupported document mime_type")
120
124
  blocks.append(document_block(block.data, mime_type=mime_type, name=block.name or "document.pdf"))
121
- continue
122
-
123
- if not block.path:
124
- raise HTTPException(status_code=400, detail="document input requires path or data")
125
- document_path = Path(resolve_path(block.path, cwd=cwd))
126
- if not document_path.is_file():
127
- raise HTTPException(status_code=400, detail=f"document file not found: {block.path}")
128
- mime_type = block.mime_type or detect_document_mime_type(document_path)
129
- if mime_type != "application/pdf":
130
- raise HTTPException(status_code=400, detail=f"unsupported document file: {block.path}")
131
- document_data = b64encode(document_path.read_bytes()).decode("utf-8")
132
- blocks.append(
133
- document_block(
134
- document_data,
135
- mime_type=mime_type,
136
- name=block.name or document_path.name,
137
- )
138
- )
139
- continue
140
-
141
- if block.data:
142
- if not block.mime_type:
143
- raise HTTPException(status_code=400, detail="image data requires mime_type")
144
- blocks.append(image_block(block.data, mime_type=block.mime_type, name=block.name or "image"))
145
- continue
125
+ else:
126
+ if not block.path:
127
+ raise HTTPException(status_code=400, detail="document input requires path or data")
128
+ path = Path(resolve_path(block.path, cwd=cwd))
129
+ if not path.is_file():
130
+ raise HTTPException(status_code=400, detail=f"document file not found: {block.path}")
131
+ mime_type = block.mime_type or detect_document_mime_type(path)
132
+ if mime_type != "application/pdf":
133
+ raise HTTPException(status_code=400, detail=f"unsupported document file: {block.path}")
134
+ data = b64encode(path.read_bytes()).decode("utf-8")
135
+ blocks.append(document_block(data, mime_type=mime_type, name=block.name or path.name))
146
136
 
147
- if not block.path:
148
- raise HTTPException(status_code=400, detail="image input requires path or data")
149
- image_path = Path(resolve_path(block.path, cwd=cwd))
150
- if not image_path.is_file():
151
- raise HTTPException(status_code=400, detail=f"image file not found: {block.path}")
152
- mime_type = block.mime_type or detect_image_mime_type(image_path)
153
- if not mime_type:
154
- raise HTTPException(status_code=400, detail=f"unsupported image file: {block.path}")
155
- image_data = b64encode(image_path.read_bytes()).decode("utf-8")
156
- blocks.append(image_block(image_data, mime_type=mime_type, name=block.name or image_path.name))
137
+ else: # image
138
+ if block.data:
139
+ if not block.mime_type:
140
+ raise HTTPException(status_code=400, detail="image data requires mime_type")
141
+ blocks.append(image_block(block.data, mime_type=block.mime_type, name=block.name or "image"))
142
+ else:
143
+ if not block.path:
144
+ raise HTTPException(status_code=400, detail="image input requires path or data")
145
+ path = Path(resolve_path(block.path, cwd=cwd))
146
+ if not path.is_file():
147
+ raise HTTPException(status_code=400, detail=f"image file not found: {block.path}")
148
+ mime_type = block.mime_type or detect_image_mime_type(path)
149
+ if not mime_type:
150
+ raise HTTPException(status_code=400, detail=f"unsupported image file: {block.path}")
151
+ data = b64encode(path.read_bytes()).decode("utf-8")
152
+ blocks.append(image_block(data, mime_type=mime_type, name=block.name or path.name))
157
153
 
158
154
  if not blocks:
159
155
  raise HTTPException(status_code=400, detail="input must include at least one non-empty block")
160
156
  user_message = build_message("user", blocks)
161
- else:
162
- message_text = str(chat.message or "").strip()
163
- if not message_text:
164
- raise HTTPException(status_code=400, detail="message or input is required")
165
- user_message = build_message("user", [text_block(message_text)])
166
157
 
167
158
  data = await store.load_session(session_id)
168
159
  session = (data or {}).get("session")
@@ -197,8 +188,7 @@ async def chat(chat: ChatRequest, store: StoreDep, runs: RunManagerDep):
197
188
 
198
189
  # Capability check before any disk mutation — a failed check must not
199
190
  # leave an empty session on disk or land a premature rewind marker.
200
- provider_config = settings.providers.get(resolved.provider_name or "")
201
- model_config = provider_config.models.get(resolved.model) if provider_config else None
191
+ model_config = resolved.model_config
202
192
  model_meta = resolve_model_metadata(
203
193
  provider=resolved.provider,
204
194
  model=resolved.model,