mycode-cli 0.1.2__py3-none-any.whl → 0.2.0__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 (33) hide show
  1. mycode/cli/chat.py +59 -36
  2. mycode/cli/main.py +5 -7
  3. mycode/cli/render.py +8 -4
  4. mycode/cli/runtime.py +4 -0
  5. mycode/core/agent.py +24 -14
  6. mycode/core/config.py +52 -36
  7. mycode/core/messages.py +21 -5
  8. mycode/core/models.py +2 -0
  9. mycode/core/models_catalog.json +761 -353
  10. mycode/core/providers/anthropic_like.py +11 -3
  11. mycode/core/providers/base.py +42 -6
  12. mycode/core/providers/gemini.py +12 -11
  13. mycode/core/providers/openai_chat.py +25 -14
  14. mycode/core/providers/openai_responses.py +63 -25
  15. mycode/core/session.py +29 -16
  16. mycode/core/system_prompt.py +1 -1
  17. mycode/core/tools.py +20 -5
  18. mycode/core/utils.py +5 -0
  19. mycode/server/routers/chat.py +76 -35
  20. mycode/server/routers/sessions.py +2 -1
  21. mycode/server/run_manager.py +2 -3
  22. mycode/server/schemas.py +2 -1
  23. mycode/server/static/assets/{EditDiff-B_aujzJQ.js → EditDiff-HrQSuYB-.js} +1 -1
  24. mycode/server/static/assets/index-gc57yaYT.js +208 -0
  25. mycode/server/static/assets/index-rDD0Lk3o.css +1 -0
  26. mycode/server/static/index.html +2 -2
  27. {mycode_cli-0.1.2.dist-info → mycode_cli-0.2.0.dist-info}/METADATA +3 -3
  28. {mycode_cli-0.1.2.dist-info → mycode_cli-0.2.0.dist-info}/RECORD +31 -31
  29. mycode/server/static/assets/index-BhG63UMx.css +0 -1
  30. mycode/server/static/assets/index-DpmWOCHa.js +0 -206
  31. {mycode_cli-0.1.2.dist-info → mycode_cli-0.2.0.dist-info}/WHEEL +0 -0
  32. {mycode_cli-0.1.2.dist-info → mycode_cli-0.2.0.dist-info}/entry_points.txt +0 -0
  33. {mycode_cli-0.1.2.dist-info → mycode_cli-0.2.0.dist-info}/licenses/LICENSE +0 -0
mycode/cli/chat.py CHANGED
@@ -7,15 +7,18 @@ import html
7
7
  import re
8
8
  import shlex
9
9
  from base64 import b64encode
10
+ from collections.abc import Iterable
10
11
  from pathlib import Path
11
- from typing import Any
12
+ from typing import Any, override
12
13
 
13
14
  from prompt_toolkit import PromptSession
14
15
  from prompt_toolkit.application import Application, get_app
15
- from prompt_toolkit.completion import Completer, Completion
16
- from prompt_toolkit.formatted_text import ANSI
16
+ from prompt_toolkit.completion import CompleteEvent, Completer, Completion
17
+ from prompt_toolkit.document import Document
18
+ from prompt_toolkit.formatted_text import ANSI, StyleAndTextTuples
17
19
  from prompt_toolkit.history import FileHistory
18
20
  from prompt_toolkit.key_binding import KeyBindings
21
+ from prompt_toolkit.key_binding.key_processor import KeyPressEvent
19
22
  from prompt_toolkit.keys import Keys
20
23
  from prompt_toolkit.layout import Layout
21
24
  from prompt_toolkit.widgets import RadioList
@@ -23,9 +26,9 @@ from rich.text import Text
23
26
 
24
27
  from mycode.core.agent import Agent
25
28
  from mycode.core.config import resolve_mycode_home
26
- from mycode.core.messages import build_message, image_block, text_block
29
+ from mycode.core.messages import build_message, document_block, image_block, text_block
27
30
  from mycode.core.session import SessionStore
28
- from mycode.core.tools import detect_image_mime_type, resolve_path
31
+ from mycode.core.tools import detect_document_mime_type, detect_image_mime_type, resolve_path
29
32
 
30
33
  from .render import ReplyRenderer, TerminalView, format_local_timestamp
31
34
  from .runtime import (
@@ -62,17 +65,19 @@ _AT_PATH_RE = re.compile(r"(?<!\S)@(?:(?P<quote>['\"])(?P<quoted>[^'\"]*)|(?P<pl
62
65
  _FOCUSED_STYLE = "bold blue" if TERMINAL_THEME == "light" else "bold cyan"
63
66
 
64
67
 
65
- class _InlineRadioList[T](RadioList):
68
+ class _InlineRadioList[T](RadioList[T]):
66
69
  """Arrow-key list that shows > on the focused item and exits on Enter."""
67
70
 
71
+ @override
68
72
  def _handle_enter(self) -> None:
69
73
  # Only called by Enter/Space (not arrows), so safe to exit.
70
74
  self.current_value = self.values[self._selected_index][0]
71
75
  get_app().exit(result=self.current_value)
72
76
 
73
- def _get_text_fragments(self):
77
+ @override
78
+ def _get_text_fragments(self) -> StyleAndTextTuples:
74
79
  # Override rendering: show > based on focus, not checked state.
75
- result: list[tuple[str, str]] = []
80
+ result: StyleAndTextTuples = []
76
81
  for i, (_value, text) in enumerate(self.values):
77
82
  focused = i == self._selected_index
78
83
  style = _FOCUSED_STYLE if focused else ""
@@ -97,7 +102,7 @@ async def choose[T](options: list[tuple[T, str]], *, default: T | None = None) -
97
102
 
98
103
  @kb.add("c-c")
99
104
  @kb.add("escape")
100
- def _cancel(event) -> None:
105
+ def _cancel(event: KeyPressEvent) -> None:
101
106
  event.app.exit(result=None)
102
107
 
103
108
  app: Application[T | None] = Application(
@@ -116,7 +121,8 @@ class _PromptCompleter(Completer):
116
121
  def __init__(self, *, cwd: str | None = None) -> None:
117
122
  self._cwd = cwd
118
123
 
119
- def get_completions(self, document, complete_event):
124
+ def get_completions(self, document: Document, complete_event: CompleteEvent) -> Iterable[Completion]:
125
+ del complete_event
120
126
  text_before_cursor = document.text_before_cursor
121
127
  text = text_before_cursor.lstrip()
122
128
  if self._cwd:
@@ -186,16 +192,25 @@ def _build_chat_key_bindings() -> KeyBindings:
186
192
  """Build key bindings for the main chat prompt."""
187
193
  kb = KeyBindings()
188
194
 
189
- kb.add("c-l")(lambda event: event.app.renderer.clear())
195
+ def _clear(event: KeyPressEvent) -> None:
196
+ event.app.renderer.clear()
197
+
198
+ kb.add("c-l")(_clear)
190
199
 
191
200
  # In multiline mode the default Enter inserts a newline; override it to submit.
192
- kb.add("enter", eager=True)(lambda event: event.current_buffer.validate_and_handle())
201
+ def _submit(event: KeyPressEvent) -> None:
202
+ event.current_buffer.validate_and_handle()
203
+
204
+ kb.add("enter", eager=True)(_submit)
193
205
 
194
206
  # Esc+Enter (Meta+Enter) inserts a newline for multiline input.
195
- kb.add("escape", "enter")(lambda event: event.current_buffer.insert_text("\n"))
207
+ def _insert_newline(event: KeyPressEvent) -> None:
208
+ event.current_buffer.insert_text("\n")
209
+
210
+ kb.add("escape", "enter")(_insert_newline)
196
211
 
197
212
  @kb.add(Keys.BracketedPaste, eager=True)
198
- def _handle_bracketed_paste(event) -> None:
213
+ def _handle_bracketed_paste(event: KeyPressEvent) -> None:
199
214
  pasted = event.data.replace("\r\n", "\n").replace("\r", "\n")
200
215
  event.current_buffer.insert_text(_rewrite_pasted_file_paths(pasted) or pasted)
201
216
 
@@ -225,7 +240,7 @@ class TerminalChat:
225
240
  self.store = store
226
241
  self.session_id = session_id
227
242
  self.view = view or TerminalView()
228
- self.prompt_session = PromptSession(
243
+ self.prompt_session: PromptSession[str] = PromptSession(
229
244
  history=FileHistory(history_file_path()),
230
245
  completer=_PromptCompleter(cwd=self.agent.cwd),
231
246
  key_bindings=_build_chat_key_bindings(),
@@ -284,9 +299,9 @@ class TerminalChat:
284
299
  def _build_user_message(self, text: str) -> dict[str, Any]:
285
300
  """Build one user message with the raw prompt first, then resolved attachments.
286
301
 
287
- Text files are appended as extra text blocks in their final provider-facing
288
- form. Images are appended as image blocks. Only explicit `@path` tokens
289
- that resolve to real files are attached.
302
+ Text files are appended as extra text blocks. Images and PDFs become
303
+ native blocks only when the current model supports that input type.
304
+ Only explicit `@path` tokens that resolve to real files are attached.
290
305
  """
291
306
 
292
307
  blocks = [text_block(text)]
@@ -309,10 +324,27 @@ class TerminalChat:
309
324
  continue
310
325
  seen.add(path_text)
311
326
 
312
- image_mime_type = detect_image_mime_type(path)
313
- if image_mime_type:
314
- image_data = b64encode(path.read_bytes()).decode("utf-8")
315
- blocks.append(image_block(image_data, mime_type=image_mime_type, name=path.name))
327
+ # Detect image or document; bundle (kind, mime, supported) together.
328
+ media: tuple[str, str, bool] | None = None
329
+ if m := detect_image_mime_type(path):
330
+ media = ("image", m, self.agent.supports_image_input)
331
+ elif m := detect_document_mime_type(path):
332
+ media = ("document", m, self.agent.supports_pdf_input)
333
+
334
+ if media:
335
+ kind, mime_type, supported = media
336
+ if supported:
337
+ data = b64encode(path.read_bytes()).decode("utf-8")
338
+ fn = image_block if kind == "image" else document_block
339
+ blocks.append(fn(data, mime_type=mime_type, name=path.name))
340
+ else:
341
+ label = "image input" if kind == "image" else "PDF input"
342
+ blocks.append(
343
+ text_block(
344
+ f'<file name="{html.escape(path_text, quote=True)}" media_type="{mime_type}" kind="{kind}">Current model does not support {label}.</file>',
345
+ meta={"attachment": True, "path": path_text},
346
+ )
347
+ )
316
348
  continue
317
349
 
318
350
  # Reuse the existing read tool so attached text files follow the same
@@ -459,20 +491,16 @@ class TerminalChat:
459
491
 
460
492
  # Collect real user text messages (skip synthetic compact summaries
461
493
  # and tool-result-only user messages).
462
- user_turns: list[tuple[int, str]] = [] # (message_index, full_text)
494
+ user_turns: dict[int, str] = {} # message_index -> text
463
495
  for i, msg in enumerate(messages):
464
496
  if msg.get("role") != "user":
465
497
  continue
466
498
  if (msg.get("meta") or {}).get("synthetic"):
467
499
  continue
468
- blocks = msg.get("content") or []
469
- text = ""
470
- for b in blocks:
500
+ for b in msg.get("content") or []:
471
501
  if isinstance(b, dict) and b.get("type") == "text" and b.get("text"):
472
- text = str(b["text"]).strip()
502
+ user_turns[i] = str(b["text"]).strip()
473
503
  break
474
- if text:
475
- user_turns.append((i, text))
476
504
 
477
505
  if not user_turns:
478
506
  self.view.console.print("[dim]no user messages to rewind to[/dim]")
@@ -480,7 +508,7 @@ class TerminalChat:
480
508
 
481
509
  # Build selector options — most recent first.
482
510
  options: list[tuple[int, str]] = []
483
- for msg_index, text in reversed(user_turns):
511
+ for msg_index, text in reversed(list(user_turns.items())):
484
512
  preview = text.replace("\n", " ")[:60]
485
513
  if len(text) > 60:
486
514
  preview += "..."
@@ -490,12 +518,7 @@ class TerminalChat:
490
518
  if selected is None:
491
519
  return None
492
520
 
493
- # Look up the full text of the selected message for prefill.
494
- original_text = ""
495
- for msg_index, text in user_turns:
496
- if msg_index == selected:
497
- original_text = text
498
- break
521
+ original_text = user_turns.get(selected, "")
499
522
 
500
523
  # Persist the rewind event and truncate in-memory messages.
501
524
  await self.store.append_rewind(self.session_id, selected)
mycode/cli/main.py CHANGED
@@ -11,6 +11,7 @@ import typer
11
11
 
12
12
  from mycode.core.agent import Agent
13
13
  from mycode.core.config import get_settings, resolve_provider
14
+ from mycode.core.messages import ConversationMessage
14
15
  from mycode.core.session import SessionStore
15
16
 
16
17
  from .chat import TerminalChat
@@ -22,9 +23,6 @@ session_app = typer.Typer(help="Session management")
22
23
  app.add_typer(session_app, name="session")
23
24
 
24
25
 
25
- # -- Shared helpers ----------------------------------------------------------
26
-
27
-
28
26
  async def run_noninteractive(
29
27
  agent: Agent,
30
28
  *,
@@ -32,11 +30,11 @@ async def run_noninteractive(
32
30
  session_id: str,
33
31
  message: str,
34
32
  ) -> int:
35
- """Run one CLI message and print only the final assistant reply."""
33
+ """Run one message non-interactively and print only the final assistant reply."""
36
34
 
37
- latest_assistant: dict | None = None
35
+ latest_assistant: ConversationMessage | None = None
38
36
 
39
- async def persist(payload: dict) -> None:
37
+ async def persist(payload: ConversationMessage) -> None:
40
38
  nonlocal latest_assistant
41
39
  if payload.get("role") == "assistant":
42
40
  latest_assistant = payload
@@ -67,7 +65,7 @@ async def run_noninteractive(
67
65
 
68
66
 
69
67
  def _validate_session_options(session: str | None, continue_last: bool) -> None:
70
- """Reject conflicting session options."""
68
+ """Reject conflicting --session and --continue options."""
71
69
 
72
70
  if session and continue_last:
73
71
  raise typer.BadParameter("--session and --continue are mutually exclusive")
mycode/cli/render.py CHANGED
@@ -412,6 +412,8 @@ class ReplyRenderer:
412
412
  case "error":
413
413
  exit_code = 1
414
414
  self.error(event.data.get("message", ""))
415
+ case _:
416
+ pass
415
417
 
416
418
  self.finish()
417
419
  return exit_code
@@ -711,19 +713,21 @@ class ReplyRenderer:
711
713
  self._thinking_collapsed = False
712
714
  self._thinking_start_time = None
713
715
 
714
- def _build_live_renderable(self):
716
+ def _build_live_renderable(self) -> Spinner | _LeftMarkdown:
715
717
  """Build the Rich renderable used while a reply is streaming."""
716
718
 
717
719
  # No content yet: plain spinner
718
720
  if not self._reasoning and not self._text:
719
721
  return Spinner("dots", style="dim")
720
722
 
721
- # Thinking in progress: show rolling preview of reasoning content
723
+ # Thinking in progress: show rolling preview of reasoning content.
724
+ # Join only the tail to avoid O(full_length) work on every frame.
722
725
  if self._reasoning and not self._text:
723
- content = " ".join("".join(self._reasoning).split())
726
+ tail = "".join(self._reasoning[-30:])
727
+ content = " ".join(tail.split())
724
728
  if content:
725
729
  preview = content[-80:].strip()
726
- if len(content) > 80:
730
+ if len(self._reasoning) > 30 or len(content) > 80:
727
731
  preview = "…" + preview
728
732
  return Spinner("dots", text=Text(f" {preview}", style=THINKING), style="dim")
729
733
  return Spinner("dots", text=Text(" thinking…", style=THINKING), style="dim")
mycode/cli/runtime.py CHANGED
@@ -64,6 +64,7 @@ def build_agent(
64
64
  settings=settings,
65
65
  reasoning_effort=resolved_provider.reasoning_effort,
66
66
  supports_image_input=resolved_provider.supports_image_input,
67
+ supports_pdf_input=resolved_provider.supports_pdf_input,
67
68
  max_tokens=resolved_provider.max_tokens,
68
69
  context_window=resolved_provider.context_window,
69
70
  compact_threshold=settings.compact_threshold,
@@ -87,6 +88,7 @@ def clone_agent(agent: Agent, *, store: SessionStore, session_id: str, messages:
87
88
  max_tokens=agent.max_tokens,
88
89
  reasoning_effort=agent.reasoning_effort,
89
90
  supports_image_input=agent.supports_image_input,
91
+ supports_pdf_input=agent.supports_pdf_input,
90
92
  settings=agent.settings,
91
93
  )
92
94
 
@@ -255,6 +257,7 @@ async def update_agent_runtime(
255
257
  or agent.max_tokens != resolved.max_tokens
256
258
  or agent.context_window != resolved.context_window
257
259
  or agent.supports_image_input != resolved.supports_image_input
260
+ or agent.supports_pdf_input != resolved.supports_pdf_input
258
261
  )
259
262
 
260
263
  agent.provider = resolved.provider
@@ -265,6 +268,7 @@ async def update_agent_runtime(
265
268
  agent.max_tokens = resolved.max_tokens
266
269
  agent.context_window = resolved.context_window
267
270
  agent.supports_image_input = bool(resolved.supports_image_input)
271
+ agent.supports_pdf_input = bool(resolved.supports_pdf_input)
268
272
  agent.settings = settings
269
273
  if hasattr(agent, "tools") and hasattr(agent.tools, "supports_image_input"):
270
274
  agent.tools.supports_image_input = bool(agent.supports_image_input)
mycode/core/agent.py CHANGED
@@ -7,7 +7,7 @@ import logging
7
7
  from collections.abc import AsyncIterator, Awaitable, Callable
8
8
  from dataclasses import dataclass, field
9
9
  from pathlib import Path
10
- from typing import Any
10
+ from typing import Any, cast
11
11
 
12
12
  from mycode.core.config import Settings, get_settings
13
13
  from mycode.core.messages import (
@@ -62,6 +62,7 @@ class Agent:
62
62
  compact_threshold: float | None = None,
63
63
  reasoning_effort: str | None = None,
64
64
  supports_image_input: bool | None = None,
65
+ supports_pdf_input: bool | None = None,
65
66
  settings: Settings | None = None,
66
67
  system: str | None = None,
67
68
  tool_executor: ToolExecutor | None = None,
@@ -79,6 +80,7 @@ class Agent:
79
80
  self.compact_threshold = compact_threshold if compact_threshold is not None else DEFAULT_COMPACT_THRESHOLD
80
81
  self.reasoning_effort = reasoning_effort
81
82
  self.supports_image_input: bool = bool(supports_image_input)
83
+ self.supports_pdf_input: bool = bool(supports_pdf_input)
82
84
  self.settings = settings or get_settings(self.cwd)
83
85
  self.system = system or build_system_prompt(self.cwd, self.settings)
84
86
  self._cancel_event = asyncio.Event()
@@ -99,9 +101,7 @@ class Agent:
99
101
 
100
102
  @staticmethod
101
103
  def _tool_done_event(tool_id: str, result: ToolExecutionResult) -> Event:
102
- """Build the standard tool_done event payload."""
103
-
104
- data = {
104
+ data: dict[str, Any] = {
105
105
  "tool_use_id": tool_id,
106
106
  "model_text": result.model_text,
107
107
  "display_text": result.display_text,
@@ -109,10 +109,7 @@ class Agent:
109
109
  }
110
110
  if result.content:
111
111
  data["content"] = result.content
112
- return Event(
113
- "tool_done",
114
- data,
115
- )
112
+ return Event("tool_done", data)
116
113
 
117
114
  async def _run_streaming_tool(self, *, tool_id: str, name: str, args: dict[str, Any]) -> AsyncIterator[Event]:
118
115
  """Run one streaming tool and forward live output until it finishes."""
@@ -236,12 +233,16 @@ class Agent:
236
233
  """Iterate one provider turn with best-effort cancellation support."""
237
234
 
238
235
  provider_stream: AsyncIterator[ProviderStreamEvent] = adapter.stream_turn(request)
236
+
237
+ async def next_provider_event() -> ProviderStreamEvent:
238
+ return await anext(provider_stream)
239
+
239
240
  try:
240
241
  while True:
241
242
  if self._cancel_event.is_set():
242
243
  raise asyncio.CancelledError
243
244
 
244
- self._provider_event_task = asyncio.create_task(anext(provider_stream))
245
+ self._provider_event_task = asyncio.create_task(next_provider_event())
245
246
  try:
246
247
  yield await self._provider_event_task
247
248
  except StopAsyncIteration:
@@ -249,8 +250,8 @@ class Agent:
249
250
  finally:
250
251
  self._provider_event_task = None
251
252
  finally:
252
- close = getattr(provider_stream, "aclose", None)
253
- if callable(close):
253
+ close = cast(Callable[[], Awaitable[None]] | None, getattr(provider_stream, "aclose", None))
254
+ if close is not None:
254
255
  try:
255
256
  await close()
256
257
  except Exception:
@@ -271,17 +272,19 @@ class Agent:
271
272
 
272
273
  self._cancel_event.clear()
273
274
  supports_image_input = self.supports_image_input
275
+ supports_pdf_input = self.supports_pdf_input
274
276
  self.tools.supports_image_input = supports_image_input
275
277
 
276
278
  if isinstance(user_input, str):
277
279
  user_message = user_text_message(user_input)
278
280
  else:
279
- user_message = {
281
+ user_message: ConversationMessage = {
280
282
  "role": str(user_input.get("role") or "user"),
281
283
  "content": [dict(b) for b in user_input.get("content") or [] if isinstance(b, dict)],
282
284
  }
283
- if isinstance(user_input.get("meta"), dict):
284
- user_message["meta"] = dict(user_input["meta"])
285
+ raw_meta = user_input.get("meta")
286
+ if isinstance(raw_meta, dict):
287
+ user_message["meta"] = {str(k): v for k, v in raw_meta.items()}
285
288
 
286
289
  if user_message.get("role") != "user":
287
290
  yield Event("error", {"message": "user input must be a user message"})
@@ -292,6 +295,11 @@ class Agent:
292
295
  ):
293
296
  yield Event("error", {"message": "current model does not support image input"})
294
297
  return
298
+ if not supports_pdf_input and any(
299
+ isinstance(block, dict) and block.get("type") == "document" for block in user_message.get("content") or []
300
+ ):
301
+ yield Event("error", {"message": "current model does not support PDF input"})
302
+ return
295
303
 
296
304
  self.messages.append(user_message)
297
305
  if on_persist:
@@ -319,6 +327,7 @@ class Agent:
319
327
  api_base=self.api_base,
320
328
  reasoning_effort=self.reasoning_effort,
321
329
  supports_image_input=supports_image_input,
330
+ supports_pdf_input=supports_pdf_input,
322
331
  )
323
332
 
324
333
  try:
@@ -467,6 +476,7 @@ class Agent:
467
476
  api_key=self.api_key,
468
477
  api_base=self.api_base,
469
478
  supports_image_input=self.supports_image_input,
479
+ supports_pdf_input=self.supports_pdf_input,
470
480
  )
471
481
 
472
482
  summary_message: ConversationMessage | None = None
mycode/core/config.py CHANGED
@@ -40,6 +40,7 @@ class ModelConfig:
40
40
  max_output_tokens: int | None = None
41
41
  supports_reasoning: bool | None = None
42
42
  supports_image_input: bool | None = None
43
+ supports_pdf_input: bool | None = None
43
44
 
44
45
 
45
46
  @dataclass(frozen=True)
@@ -79,6 +80,7 @@ class ResolvedProvider:
79
80
  context_window: int | None = 128_000
80
81
  supports_reasoning: bool | None = None
81
82
  supports_image_input: bool | None = None
83
+ supports_pdf_input: bool | None = None
82
84
  provider_name: str | None = None
83
85
 
84
86
  @property
@@ -139,6 +141,7 @@ def _normalize_models(value: Any) -> dict[str, ModelConfig]:
139
141
  max_output_tokens=as_int(raw_config.get("max_output_tokens")),
140
142
  supports_reasoning=as_bool(raw_config.get("supports_reasoning")),
141
143
  supports_image_input=as_bool(raw_config.get("supports_image_input")),
144
+ supports_pdf_input=as_bool(raw_config.get("supports_pdf_input")),
142
145
  )
143
146
  return models
144
147
 
@@ -293,20 +296,21 @@ def get_settings(cwd: str | None = None) -> Settings:
293
296
 
294
297
  raw_providers[name] = merged
295
298
 
296
- default = data.get("default") if isinstance(data.get("default"), dict) else {}
297
- if "provider" in default:
298
- value = default.get("provider")
299
- default_provider = value if isinstance(value, str) else None
300
- if "model" in default:
301
- value = default.get("model")
302
- default_model = value if isinstance(value, str) else None
303
- if "reasoning_effort" in default:
304
- value = default.get("reasoning_effort")
305
- default_reasoning_effort = value if isinstance(value, str) else None
306
- if "compact_threshold" in default:
307
- parsed_threshold = _parse_compact_threshold(default.get("compact_threshold"))
308
- if parsed_threshold is not None:
309
- compact_threshold = parsed_threshold
299
+ default = data.get("default")
300
+ if isinstance(default, dict):
301
+ if "provider" in default:
302
+ v = default.get("provider")
303
+ default_provider = v if isinstance(v, str) else None
304
+ if "model" in default:
305
+ v = default.get("model")
306
+ default_model = v if isinstance(v, str) else None
307
+ if "reasoning_effort" in default:
308
+ v = default.get("reasoning_effort")
309
+ default_reasoning_effort = v if isinstance(v, str) else None
310
+ if "compact_threshold" in default:
311
+ parsed_threshold = _parse_compact_threshold(default.get("compact_threshold"))
312
+ if parsed_threshold is not None:
313
+ compact_threshold = parsed_threshold
310
314
 
311
315
  return Settings(
312
316
  providers=_build_providers(raw_providers),
@@ -341,24 +345,27 @@ def resolve_provider(
341
345
  api_base=api_base,
342
346
  )
343
347
 
344
- for available_name, _ in _available_provider_references(settings):
348
+ refs = _available_provider_references(settings)
349
+ if refs:
345
350
  return _resolve_provider_runtime(
346
351
  settings,
347
- selected_name=available_name,
352
+ selected_name=refs[0][0],
348
353
  model=model,
349
354
  api_key=api_key,
350
355
  api_base=api_base,
351
356
  )
352
357
 
353
- env_names: list[str] = []
354
- for provider_id in list_env_discoverable_providers():
355
- for env_name in provider_env_api_key_names(provider_id):
356
- if env_name not in env_names:
357
- env_names.append(env_name)
358
+ env_names = list(
359
+ dict.fromkeys(
360
+ env_name
361
+ for provider_id in list_env_discoverable_providers()
362
+ for env_name in provider_env_api_key_names(provider_id)
363
+ )
364
+ )
358
365
  checked = ", ".join(env_names) or "<api key env>"
359
366
  raise ValueError(
360
367
  "no available providers found; set one of the supported API key env vars "
361
- f"({checked}) or configure a provider in ~/.mycode/config.json or <workspace>/.mycode/config.json"
368
+ + f"({checked}) or configure a provider in ~/.mycode/config.json or <workspace>/.mycode/config.json"
362
369
  )
363
370
 
364
371
 
@@ -461,6 +468,7 @@ def _resolve_provider_runtime(
461
468
  max_output_tokens=model_config.max_output_tokens,
462
469
  supports_reasoning=model_config.supports_reasoning,
463
470
  supports_image_input=model_config.supports_image_input,
471
+ supports_pdf_input=model_config.supports_pdf_input,
464
472
  )
465
473
  else:
466
474
  # Per-field: use the config override when set, keep catalog value otherwise.
@@ -478,11 +486,16 @@ def _resolve_provider_runtime(
478
486
  supports_image_input=model_config.supports_image_input
479
487
  if model_config.supports_image_input is not None
480
488
  else model_metadata.supports_image_input,
489
+ supports_pdf_input=model_config.supports_pdf_input
490
+ if model_config.supports_pdf_input is not None
491
+ else model_metadata.supports_pdf_input,
481
492
  )
482
493
 
483
- configured_effort = settings.default_reasoning_effort
484
- if provider_config and provider_config.reasoning_effort is not None:
485
- configured_effort = provider_config.reasoning_effort
494
+ configured_effort = (
495
+ provider_config.reasoning_effort
496
+ if provider_config and provider_config.reasoning_effort is not None
497
+ else settings.default_reasoning_effort
498
+ )
486
499
 
487
500
  if configured_effort is not None and configured_effort not in _VALID_REASONING_EFFORTS:
488
501
  supported = ", ".join(_VALID_REASONING_EFFORTS)
@@ -490,16 +503,18 @@ def _resolve_provider_runtime(
490
503
 
491
504
  supports_reasoning = model_metadata.supports_reasoning if model_metadata else None
492
505
  supports_image_input = model_metadata.supports_image_input if model_metadata else None
506
+ supports_pdf_input = model_metadata.supports_pdf_input if model_metadata else None
493
507
  adapter = get_provider_adapter(provider_type)
494
- if (
495
- configured_effort is None
496
- or model_metadata is None
497
- or supports_reasoning is not True
498
- or not adapter.supports_reasoning_effort
499
- ):
500
- reasoning_effort = None
501
- else:
502
- reasoning_effort = configured_effort
508
+ reasoning_effort = (
509
+ configured_effort
510
+ if (
511
+ configured_effort is not None
512
+ and model_metadata is not None
513
+ and supports_reasoning is True
514
+ and adapter.supports_reasoning_effort
515
+ )
516
+ else None
517
+ )
503
518
 
504
519
  resolved_api_key = api_key
505
520
  if not resolved_api_key and provider_config:
@@ -521,10 +536,11 @@ def _resolve_provider_runtime(
521
536
  api_key=resolved_api_key,
522
537
  api_base=resolved_api_base,
523
538
  reasoning_effort=reasoning_effort,
524
- max_tokens=model_metadata.max_output_tokens if model_metadata and model_metadata.max_output_tokens else 16_384,
525
- context_window=model_metadata.context_window if model_metadata and model_metadata.context_window else 128_000,
539
+ max_tokens=(model_metadata.max_output_tokens if model_metadata else None) or 16_384,
540
+ context_window=(model_metadata.context_window if model_metadata else None) or 128_000,
526
541
  supports_reasoning=supports_reasoning,
527
542
  supports_image_input=supports_image_input,
543
+ supports_pdf_input=supports_pdf_input,
528
544
  )
529
545
 
530
546
 
mycode/core/messages.py CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  The runtime persists a single message shape everywhere:
4
4
 
5
- - user message: text blocks, image blocks, and tool_result blocks
5
+ - user message: text blocks, image blocks, document blocks, and tool_result blocks
6
6
  - assistant message: thinking blocks, text blocks, and tool_use blocks
7
7
 
8
8
  Provider adapters translate between this internal shape and provider-specific wire
@@ -21,6 +21,8 @@ from __future__ import annotations
21
21
 
22
22
  from typing import Any
23
23
 
24
+ from mycode.core.utils import omit_none
25
+
24
26
  ContentBlock = dict[str, Any]
25
27
  ConversationMessage = dict[str, Any]
26
28
 
@@ -54,6 +56,21 @@ def image_block(
54
56
  return block
55
57
 
56
58
 
59
+ def document_block(
60
+ data: str,
61
+ *,
62
+ mime_type: str,
63
+ name: str | None = None,
64
+ meta: dict[str, Any] | None = None,
65
+ ) -> ContentBlock:
66
+ block: ContentBlock = {"type": "document", "data": data, "mime_type": mime_type}
67
+ if name:
68
+ block["name"] = name
69
+ if meta:
70
+ block["meta"] = dict(meta)
71
+ return block
72
+
73
+
57
74
  def tool_use_block(
58
75
  *,
59
76
  tool_id: str,
@@ -141,7 +158,7 @@ def assistant_message(
141
158
  if usage is not None:
142
159
  meta["usage"] = usage
143
160
  if native_meta:
144
- native = {key: value for key, value in native_meta.items() if value is not None}
161
+ native = omit_none(native_meta)
145
162
  if native:
146
163
  meta["native"] = native
147
164
  return build_message("assistant", blocks, meta=meta or None)
@@ -159,8 +176,7 @@ def flatten_message_text(message: ConversationMessage, *, include_thinking: bool
159
176
  # Attached file snapshots should not become session titles or history labels.
160
177
  if meta.get("attachment"):
161
178
  continue
162
- if block.get("type") == "text":
163
- parts.append(str(block.get("text") or ""))
164
- elif include_thinking and block.get("type") == "thinking":
179
+ btype = block.get("type")
180
+ if btype == "text" or (include_thinking and btype == "thinking"):
165
181
  parts.append(str(block.get("text") or ""))
166
182
  return " ".join(part.strip() for part in parts if part and part.strip()).strip()