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.
- {scootcli-0.14.0/src/scootcli.egg-info → scootcli-0.14.2}/PKG-INFO +1 -1
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/__init__.py +1 -1
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/cli.py +5 -1
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/commands/model.py +35 -8
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/providers/base.py +35 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/providers/openai_chat.py +8 -2
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/providers/openai_responses.py +4 -3
- {scootcli-0.14.0 → scootcli-0.14.2/src/scootcli.egg-info}/PKG-INFO +1 -1
- {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_preferences.py +30 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_providers.py +69 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/LICENSE +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/README.md +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/pyproject.toml +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/setup.cfg +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/__main__.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/activity.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/agent.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/approvals.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/auth.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/clipboard.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/commands/__init__.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/commands/approve.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/commands/auth.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/commands/base.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/commands/compact.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/commands/copy.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/commands/exit.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/commands/forget.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/commands/help.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/commands/hooks.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/commands/init.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/commands/logo.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/commands/panel.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/commands/reset.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/commands/resume.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/commands/route.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/commands/save.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/commands/scope.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/commands/sessions.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/commands/status.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/commands/verbosity.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/commands/worktree.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/commands/yolo.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/config.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/context.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/credentials.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/errors.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/headless.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/hooks.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/images.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/keys.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/lineeditor.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/logo.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/models.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/panel.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/preferences.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/presets.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/pricing.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/project.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/prompts.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/providers/__init__.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/providers/anthropic.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/providers/registry.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/providers/router.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/rendering.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/repl.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/resize.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/sessions.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/status.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/store.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/tools/__init__.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/tools/base.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/tools/edit_file.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/tools/list_dir.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/tools/open_editor.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/tools/read_file.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/tools/run_shell.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/tools/search.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/tools/update_plan.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/tools/write_file.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/transport.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/updates.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/vision.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/workspace.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli/worktree.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli.egg-info/SOURCES.txt +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli.egg-info/dependency_links.txt +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli.egg-info/entry_points.txt +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/src/scootcli.egg-info/top_level.txt +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_agent.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_anthropic.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_approvals.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_auth.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_clipboard.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_config.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_headless.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_hooks.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_images.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_lineeditor.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_logo.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_models.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_panel.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_readiness.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_rendering.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_repl.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_resilience.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_resize.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_router.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_scope.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_sessions.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_small_060.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_smoke.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_store.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_streaming.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_tools.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_transport_native.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_vision.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_workspace.py +0 -0
- {scootcli-0.14.0 → scootcli-0.14.2}/tests/test_worktree.py +0 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
return
|
|
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]]:
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|