scootcli 0.14.0__tar.gz → 0.14.2__tar.gz

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 (119) hide show
  1. {scootcli-0.14.0/src/scootcli.egg-info → scootcli-0.14.2}/PKG-INFO +1 -1
  2. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/__init__.py +1 -1
  3. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/cli.py +5 -1
  4. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/commands/model.py +35 -8
  5. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/providers/base.py +35 -0
  6. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/providers/openai_chat.py +8 -2
  7. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/providers/openai_responses.py +4 -3
  8. {scootcli-0.14.0 → scootcli-0.14.2/src/scootcli.egg-info}/PKG-INFO +1 -1
  9. {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_preferences.py +30 -0
  10. {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_providers.py +69 -0
  11. {scootcli-0.14.0 → scootcli-0.14.2}/LICENSE +0 -0
  12. {scootcli-0.14.0 → scootcli-0.14.2}/README.md +0 -0
  13. {scootcli-0.14.0 → scootcli-0.14.2}/pyproject.toml +0 -0
  14. {scootcli-0.14.0 → scootcli-0.14.2}/setup.cfg +0 -0
  15. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/__main__.py +0 -0
  16. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/activity.py +0 -0
  17. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/agent.py +0 -0
  18. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/approvals.py +0 -0
  19. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/auth.py +0 -0
  20. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/clipboard.py +0 -0
  21. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/commands/__init__.py +0 -0
  22. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/commands/approve.py +0 -0
  23. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/commands/auth.py +0 -0
  24. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/commands/base.py +0 -0
  25. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/commands/compact.py +0 -0
  26. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/commands/copy.py +0 -0
  27. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/commands/exit.py +0 -0
  28. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/commands/forget.py +0 -0
  29. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/commands/help.py +0 -0
  30. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/commands/hooks.py +0 -0
  31. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/commands/init.py +0 -0
  32. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/commands/logo.py +0 -0
  33. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/commands/panel.py +0 -0
  34. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/commands/reset.py +0 -0
  35. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/commands/resume.py +0 -0
  36. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/commands/route.py +0 -0
  37. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/commands/save.py +0 -0
  38. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/commands/scope.py +0 -0
  39. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/commands/sessions.py +0 -0
  40. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/commands/status.py +0 -0
  41. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/commands/verbosity.py +0 -0
  42. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/commands/worktree.py +0 -0
  43. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/commands/yolo.py +0 -0
  44. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/config.py +0 -0
  45. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/context.py +0 -0
  46. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/credentials.py +0 -0
  47. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/errors.py +0 -0
  48. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/headless.py +0 -0
  49. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/hooks.py +0 -0
  50. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/images.py +0 -0
  51. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/keys.py +0 -0
  52. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/lineeditor.py +0 -0
  53. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/logo.py +0 -0
  54. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/models.py +0 -0
  55. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/panel.py +0 -0
  56. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/preferences.py +0 -0
  57. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/presets.py +0 -0
  58. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/pricing.py +0 -0
  59. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/project.py +0 -0
  60. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/prompts.py +0 -0
  61. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/providers/__init__.py +0 -0
  62. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/providers/anthropic.py +0 -0
  63. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/providers/registry.py +0 -0
  64. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/providers/router.py +0 -0
  65. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/rendering.py +0 -0
  66. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/repl.py +0 -0
  67. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/resize.py +0 -0
  68. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/sessions.py +0 -0
  69. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/status.py +0 -0
  70. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/store.py +0 -0
  71. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/tools/__init__.py +0 -0
  72. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/tools/base.py +0 -0
  73. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/tools/edit_file.py +0 -0
  74. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/tools/list_dir.py +0 -0
  75. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/tools/open_editor.py +0 -0
  76. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/tools/read_file.py +0 -0
  77. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/tools/run_shell.py +0 -0
  78. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/tools/search.py +0 -0
  79. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/tools/update_plan.py +0 -0
  80. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/tools/write_file.py +0 -0
  81. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/transport.py +0 -0
  82. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/updates.py +0 -0
  83. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/vision.py +0 -0
  84. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/workspace.py +0 -0
  85. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/worktree.py +0 -0
  86. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli.egg-info/SOURCES.txt +0 -0
  87. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli.egg-info/dependency_links.txt +0 -0
  88. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli.egg-info/entry_points.txt +0 -0
  89. {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli.egg-info/top_level.txt +0 -0
  90. {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_agent.py +0 -0
  91. {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_anthropic.py +0 -0
  92. {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_approvals.py +0 -0
  93. {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_auth.py +0 -0
  94. {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_clipboard.py +0 -0
  95. {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_config.py +0 -0
  96. {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_headless.py +0 -0
  97. {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_hooks.py +0 -0
  98. {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_images.py +0 -0
  99. {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_lineeditor.py +0 -0
  100. {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_logo.py +0 -0
  101. {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_models.py +0 -0
  102. {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_panel.py +0 -0
  103. {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_readiness.py +0 -0
  104. {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_rendering.py +0 -0
  105. {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_repl.py +0 -0
  106. {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_resilience.py +0 -0
  107. {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_resize.py +0 -0
  108. {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_router.py +0 -0
  109. {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_scope.py +0 -0
  110. {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_sessions.py +0 -0
  111. {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_small_060.py +0 -0
  112. {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_smoke.py +0 -0
  113. {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_store.py +0 -0
  114. {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_streaming.py +0 -0
  115. {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_tools.py +0 -0
  116. {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_transport_native.py +0 -0
  117. {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_vision.py +0 -0
  118. {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_workspace.py +0 -0
  119. {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_worktree.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scootcli
3
- Version: 0.14.0
3
+ Version: 0.14.2
4
4
  Summary: A tiny coding agent that goes where you point it
5
5
  Author: Sergey Nes
6
6
  License-Expression: MIT
@@ -1,4 +1,4 @@
1
1
  """scoot: a tiny coding agent that goes where you point it."""
2
2
 
3
- __version__ = "0.14.0"
3
+ __version__ = "0.14.2"
4
4
 
@@ -139,11 +139,15 @@ def _cmd_models(pool: ProviderPool, as_json: bool, vision_only: bool = False,
139
139
  for m in models:
140
140
  by_provider.setdefault(m.provider, []).append(m)
141
141
  default = pool.default_name
142
+ cap = None if provider else 8 # a specific --provider shows all; the full grid caps per provider
142
143
  for name in sorted(by_provider):
143
144
  tag = " (default)" if name == default else ""
144
145
  print(color(f"{name}{tag}", "bold"))
145
- for m in sorted(by_provider[name], key=lambda x: x.name):
146
+ rows = sorted(by_provider[name], key=lambda x: x.name)
147
+ for m in (rows if cap is None else rows[:cap]):
146
148
  print(f" {color(m.id, 'cyan')}")
149
+ if cap is not None and len(rows) > cap:
150
+ print(color(f" … +{len(rows) - cap} more · scoot models --provider {name}", "gray"))
147
151
  return 0
148
152
 
149
153
 
@@ -10,11 +10,26 @@ explicit ``--model`` flag or ``SCOOT_MODEL`` still overrides it for that run.
10
10
  from __future__ import annotations
11
11
 
12
12
  from .. import preferences
13
+ from ..providers import registry
13
14
  from ..providers.base import qualify, split_model_id
14
15
  from ..rendering import color
15
16
  from . import register
16
17
  from .base import SlashCommand
17
18
 
19
+ _PER_PROVIDER = 8 # models shown per provider in the summary; the rest are behind `/model <provider>`
20
+
21
+
22
+ def _print_provider(head: str, models: list, active: str, cap: "int | None") -> None:
23
+ """Print one provider's models, active first, capped unless ``cap`` is None."""
24
+ ordered = ([m for m in models if m == active] + [m for m in models if m != active])
25
+ shown = ordered if cap is None else ordered[:cap]
26
+ print(color(f" {head}", "bold"))
27
+ for mid in shown:
28
+ mark = color(" ← active", "green") if mid == active else ""
29
+ print(" " + color(mid, "cyan") + mark)
30
+ if cap is not None and len(ordered) > cap:
31
+ print(color(f" … +{len(ordered) - cap} more · /model {head} to list them", "gray"))
32
+
18
33
  _USAGE = "/model <provider/model> · /model default · /model auto (pick per task) · add 'everywhere' for all folders · /model forget"
19
34
 
20
35
 
@@ -55,17 +70,29 @@ def _run(session, args: str):
55
70
  arg = " ".join(words)
56
71
  available = session.available_models()
57
72
 
73
+ def _grouped():
74
+ groups = {}
75
+ for mid in available:
76
+ head, _ = split_model_id(mid)
77
+ groups.setdefault(head or "?", []).append(mid)
78
+ return groups
79
+
80
+ # `/model <provider>` (a bare known provider name) lists just that provider, uncapped, so a long
81
+ # list stays scoped to one screen-friendly provider instead of flooding the terminal.
82
+ if arg and not everywhere and "/" not in arg and registry.get(arg) is not None:
83
+ models = _grouped().get(arg, [])
84
+ if not models:
85
+ print(color(f"no models listed for '{arg}' (is its key set? try /auth).", "yellow"))
86
+ else:
87
+ print(color(f"{arg} ({len(models)} models)", "bold"))
88
+ _print_provider(arg, models, session.active_model, cap=None)
89
+ return
90
+
58
91
  if not arg:
59
92
  print(color(f"models (preference: {session.model} → active: {session.active_model})", "bold"))
60
93
  print(color(f" {_saved_line(session)}", "gray"))
61
- current = ""
62
- for mid in available:
63
- head, _ = split_model_id(mid)
64
- if head != current:
65
- current = head
66
- print(color(f" {head}", "bold"))
67
- mark = color(" ← active", "green") if mid == session.active_model else ""
68
- print(" " + color(mid, "cyan") + mark)
94
+ for head, models in _grouped().items():
95
+ _print_provider(head, models, session.active_model, cap=_PER_PROVIDER)
69
96
  errors = getattr(getattr(session, "provider", None), "list_errors", None) or {}
70
97
  for name, err in sorted(errors.items()):
71
98
  print(color(f" {name}: unavailable ({err})", "yellow"))
@@ -363,6 +363,41 @@ class BaseProvider:
363
363
  time.sleep(0.05)
364
364
 
365
365
 
366
+ def valid_tool_arguments(raw) -> str:
367
+ """A tool call's ``arguments`` as a JSON *object* string, safe to replay to a provider that
368
+ validates them. A missing, empty, or malformed value (a stream truncated mid-JSON on a length turn)
369
+ becomes ``"{}"``, so one bad tool call cannot poison the session on every later request. The call
370
+ is never dropped, so it still pairs with its matching ``tool`` result and history stays valid.
371
+ """
372
+ if isinstance(raw, dict):
373
+ try:
374
+ return json.dumps(raw)
375
+ except (TypeError, ValueError):
376
+ return "{}"
377
+ if not raw or not isinstance(raw, str):
378
+ return "{}"
379
+ try:
380
+ parsed = json.loads(raw)
381
+ except (json.JSONDecodeError, ValueError):
382
+ return "{}"
383
+ return raw if isinstance(parsed, dict) else "{}"
384
+
385
+
386
+ def sanitized_tool_calls(calls) -> list:
387
+ """Copy ``calls`` with each ``function.arguments`` replaced by a valid JSON-object string."""
388
+ out = []
389
+ for c in calls or []:
390
+ if not isinstance(c, dict):
391
+ out.append(c)
392
+ continue
393
+ c = dict(c)
394
+ fn = dict(c.get("function") or {})
395
+ fn["arguments"] = valid_tool_arguments(fn.get("arguments"))
396
+ c["function"] = fn
397
+ out.append(c)
398
+ return out
399
+
400
+
366
401
  def iter_sse_json(lines: Iterator[str], status_holder: dict, leftovers: List[str]) -> Iterator[dict]:
367
402
  """Walk raw SSE lines: yield each ``data:`` JSON object, record the status sentinel, collect
368
403
  anything that is not SSE (an error body) into ``leftovers``."""
@@ -18,17 +18,23 @@ from .base import (
18
18
  finish_stream,
19
19
  iter_sse_json,
20
20
  normalize_usage,
21
+ sanitized_tool_calls,
21
22
  )
22
23
 
23
24
  _PRIVATE_KEYS = ("provider_items",)
24
25
 
25
26
 
26
27
  def strip_private(messages: List[dict]) -> List[dict]:
27
- """Drop scoot-private keys the API would reject."""
28
+ """Prepare messages for the wire: drop scoot-private keys the API would reject, and rewrite any
29
+ tool-call arguments that are not valid JSON objects (a truncated length-turn leaves a malformed
30
+ string that a validating provider would reject on every later request). Copies, never mutates."""
28
31
  out = []
29
32
  for m in messages:
30
- if any(k in m for k in _PRIVATE_KEYS):
33
+ calls = m.get("tool_calls")
34
+ if any(k in m for k in _PRIVATE_KEYS) or calls:
31
35
  m = {k: v for k, v in m.items() if k not in _PRIVATE_KEYS}
36
+ if calls:
37
+ m["tool_calls"] = sanitized_tool_calls(calls)
32
38
  out.append(m)
33
39
  return out
34
40
 
@@ -31,6 +31,7 @@ from .base import (
31
31
  iter_sse_json,
32
32
  normalize_usage,
33
33
  raise_for_status,
34
+ valid_tool_arguments,
34
35
  )
35
36
 
36
37
  # Models that accept the ``reasoning`` parameter (others reject it with a 400).
@@ -68,9 +69,9 @@ def _content_parts(content, role: str) -> object:
68
69
 
69
70
 
70
71
  def _arguments(raw) -> str:
71
- if isinstance(raw, str):
72
- return raw
73
- return json.dumps(raw or {})
72
+ # A valid JSON-object string, so a truncated/malformed tool call replayed to the Responses API
73
+ # cannot get the session stuck (mirrors the Chat wire and Anthropic's dict fallback).
74
+ return valid_tool_arguments(raw)
74
75
 
75
76
 
76
77
  def translate_messages(messages: List[dict], qualified_model: str) -> Tuple[str, List[dict]]:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scootcli
3
- Version: 0.14.0
3
+ Version: 0.14.2
4
4
  Summary: A tiny coding agent that goes where you point it
5
5
  Author: Sergey Nes
6
6
  License-Expression: MIT
@@ -264,3 +264,33 @@ def test_status_names_the_models_origin(tmp_path, capsys):
264
264
  session = _model_session(root)
265
265
  status_cmd._run(session, "")
266
266
  assert "(from saved for this folder)" in capsys.readouterr().out
267
+
268
+
269
+ def test_model_list_caps_per_provider_and_shows_active(tmp_path, capsys):
270
+ _fresh_config_dir()
271
+ from scootcli.commands import model as model_cmd
272
+
273
+ session = _model_session(tmp_path)
274
+ session._available = [f"openai/m{i:02d}" for i in range(12)] + ["ollama/llama3.2"]
275
+ session.model = "openai/m11"
276
+ session.active_model = "openai/m11"
277
+ model_cmd._run(session, "")
278
+ out = capsys.readouterr().out
279
+ assert "openai/m11 ← active" in out # the active model is shown first, even past the cap
280
+ assert out.index("openai/m11") < out.index("openai/m00")
281
+ assert "+4 more" in out and "/model openai to list them" in out
282
+ assert "ollama/llama3.2" in out # short providers list fully
283
+
284
+
285
+ def test_model_provider_filter_lists_one_provider_uncapped(tmp_path, capsys):
286
+ _fresh_config_dir()
287
+ from scootcli.commands import model as model_cmd
288
+
289
+ session = _model_session(tmp_path)
290
+ session._available = [f"openai/m{i:02d}" for i in range(12)] + ["ollama/llama3.2"]
291
+ session.active_model = "openai/m00"
292
+ model_cmd._run(session, "openai")
293
+ out = capsys.readouterr().out
294
+ assert out.count("openai/m") == 12 # every openai model, no cap
295
+ assert "ollama/llama3.2" not in out # scoped to the one provider
296
+ assert "more ·" not in out # nothing truncated
@@ -236,3 +236,72 @@ def test_xai_and_groq_are_registered_openai_chat_rows(monkeypatch, tmp_path):
236
236
  # A per-provider base-url override still works.
237
237
  monkeypatch.setenv("SCOOT_GROQ_BASE_URL", "https://proxy.example/v1")
238
238
  assert registry.base_url_for(registry.get("groq")) == "https://proxy.example/v1"
239
+
240
+
241
+ def test_cli_models_caps_per_provider_unless_filtered(capsys):
242
+ from scootcli.cli import _cmd_models
243
+ from scootcli.providers import ModelInfo
244
+
245
+ class _Pool:
246
+ default_name = "openai"
247
+ list_errors: dict = {}
248
+
249
+ def list_models(self, provider=None):
250
+ ms = [ModelInfo(id=f"openai/m{i:02d}", provider="openai", name=f"m{i:02d}") for i in range(12)]
251
+ return [m for m in ms if provider is None or m.provider == provider]
252
+
253
+ _cmd_models(_Pool(), as_json=False)
254
+ out = capsys.readouterr().out
255
+ assert "+4 more" in out and "scoot models --provider openai" in out
256
+ _cmd_models(_Pool(), as_json=False, provider="openai")
257
+ out = capsys.readouterr().out
258
+ assert "more ·" not in out # a single --provider shows all
259
+
260
+
261
+ # ── outbound tool-argument sanitizing: a malformed/truncated call cannot stick a session ──
262
+ def test_valid_tool_arguments_normalizes_to_a_json_object():
263
+ import json as _json
264
+ from scootcli.providers.base import valid_tool_arguments
265
+
266
+ assert valid_tool_arguments('{"path": "a.txt"}') == '{"path": "a.txt"}' # valid: unchanged
267
+ assert valid_tool_arguments("") == "{}" # empty
268
+ assert valid_tool_arguments(None) == "{}"
269
+ assert valid_tool_arguments('{"path": "a') == "{}" # truncated mid-JSON
270
+ assert valid_tool_arguments("[1, 2]") == "{}" # JSON but not an object
271
+ assert _json.loads(valid_tool_arguments({"x": 1})) == {"x": 1} # dict -> json string
272
+
273
+
274
+ def test_chat_wire_sanitizes_tool_args_and_keeps_pairing():
275
+ import json as _json
276
+ from scootcli.providers.openai_chat import strip_private
277
+
278
+ def call(cid, args):
279
+ return {"id": cid, "type": "function", "function": {"name": "write_file", "arguments": args}}
280
+
281
+ messages = [
282
+ {"role": "user", "content": "go"},
283
+ {"role": "assistant", "content": "", "tool_calls": [
284
+ call("c1", '{"ok": true}'), call("c2", ""), call("c3", '{"path": "x')]},
285
+ {"role": "tool", "tool_call_id": "c1", "content": "ok"},
286
+ {"role": "tool", "tool_call_id": "c2", "content": "ok"},
287
+ {"role": "tool", "tool_call_id": "c3", "content": "ok"},
288
+ ]
289
+ out = strip_private(messages)
290
+ calls = out[1]["tool_calls"]
291
+ assert len(calls) == 3 # no call dropped
292
+ assert [c["id"] for c in calls] == ["c1", "c2", "c3"]
293
+ for c in calls:
294
+ _json.loads(c["function"]["arguments"]) # every one is now valid JSON
295
+ assert [m.get("tool_call_id") for m in out if m["role"] == "tool"] == ["c1", "c2", "c3"] # pairing kept
296
+ assert messages[1]["tool_calls"][1]["function"]["arguments"] == "" # original history untouched (copied)
297
+
298
+
299
+ def test_responses_wire_sanitizes_reconstructed_tool_args():
300
+ import json as _json
301
+ from scootcli.providers.openai_responses import translate_messages
302
+
303
+ messages = [{"role": "assistant", "content": "", "tool_calls": [
304
+ {"id": "c1", "type": "function", "function": {"name": "read_file", "arguments": '{"path": "a'}}]}]
305
+ _instr, items = translate_messages(messages, "openai/gpt-5.3-codex")
306
+ fc = [it for it in items if it.get("type") == "function_call"]
307
+ assert len(fc) == 1 and _json.loads(fc[0]["arguments"]) == {} # truncated args replayed as {}
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes