scootcli 0.1.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.
- scootcli/__init__.py +4 -0
- scootcli/__main__.py +9 -0
- scootcli/activity.py +26 -0
- scootcli/agent.py +350 -0
- scootcli/approvals.py +167 -0
- scootcli/auth.py +59 -0
- scootcli/cli.py +276 -0
- scootcli/clipboard.py +89 -0
- scootcli/commands/__init__.py +56 -0
- scootcli/commands/approve.py +38 -0
- scootcli/commands/auth.py +107 -0
- scootcli/commands/base.py +31 -0
- scootcli/commands/compact.py +40 -0
- scootcli/commands/copy.py +23 -0
- scootcli/commands/exit.py +14 -0
- scootcli/commands/forget.py +28 -0
- scootcli/commands/help.py +29 -0
- scootcli/commands/init.py +50 -0
- scootcli/commands/logo.py +51 -0
- scootcli/commands/model.py +61 -0
- scootcli/commands/panel.py +28 -0
- scootcli/commands/reset.py +20 -0
- scootcli/commands/resume.py +31 -0
- scootcli/commands/save.py +29 -0
- scootcli/commands/sessions.py +42 -0
- scootcli/commands/status.py +59 -0
- scootcli/commands/verbosity.py +57 -0
- scootcli/commands/worktree.py +64 -0
- scootcli/commands/yolo.py +20 -0
- scootcli/config.py +241 -0
- scootcli/context.py +82 -0
- scootcli/credentials.py +79 -0
- scootcli/errors.py +87 -0
- scootcli/images.py +169 -0
- scootcli/keys.py +119 -0
- scootcli/lineeditor.py +577 -0
- scootcli/logo.py +116 -0
- scootcli/models.py +120 -0
- scootcli/panel.py +263 -0
- scootcli/preferences.py +87 -0
- scootcli/presets.py +38 -0
- scootcli/project.py +94 -0
- scootcli/prompts.py +100 -0
- scootcli/providers/__init__.py +20 -0
- scootcli/providers/base.py +370 -0
- scootcli/providers/openai_chat.py +142 -0
- scootcli/providers/openai_responses.py +248 -0
- scootcli/providers/registry.py +173 -0
- scootcli/rendering.py +86 -0
- scootcli/repl.py +801 -0
- scootcli/sessions.py +186 -0
- scootcli/status.py +71 -0
- scootcli/tools/__init__.py +68 -0
- scootcli/tools/base.py +152 -0
- scootcli/tools/edit_file.py +72 -0
- scootcli/tools/list_dir.py +47 -0
- scootcli/tools/read_file.py +56 -0
- scootcli/tools/run_shell.py +73 -0
- scootcli/tools/search.py +170 -0
- scootcli/tools/update_plan.py +104 -0
- scootcli/tools/write_file.py +61 -0
- scootcli/transport.py +312 -0
- scootcli/vision.py +167 -0
- scootcli/workspace.py +105 -0
- scootcli/worktree.py +114 -0
- scootcli-0.1.0.dist-info/METADATA +238 -0
- scootcli-0.1.0.dist-info/RECORD +71 -0
- scootcli-0.1.0.dist-info/WHEEL +5 -0
- scootcli-0.1.0.dist-info/entry_points.txt +2 -0
- scootcli-0.1.0.dist-info/licenses/LICENSE +21 -0
- scootcli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""OpenAI Chat Completions adapter (``POST {base}/chat/completions``).
|
|
2
|
+
|
|
3
|
+
Scoot's neutral format *is* this wire format, so translation is a pass-through apart from stripping
|
|
4
|
+
scoot-private keys. Kept for OpenAI-compatible endpoints that do not implement the Responses API
|
|
5
|
+
(``wire="openai_chat"`` on their registry row); nothing in the default set uses it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Callable, List
|
|
11
|
+
|
|
12
|
+
from ..errors import ApiError
|
|
13
|
+
from .base import (
|
|
14
|
+
BaseProvider,
|
|
15
|
+
ChatRequest,
|
|
16
|
+
ChatResult,
|
|
17
|
+
decode_json,
|
|
18
|
+
finish_stream,
|
|
19
|
+
iter_sse_json,
|
|
20
|
+
normalize_usage,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
_PRIVATE_KEYS = ("provider_items",)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def strip_private(messages: List[dict]) -> List[dict]:
|
|
27
|
+
"""Drop scoot-private keys the API would reject."""
|
|
28
|
+
out = []
|
|
29
|
+
for m in messages:
|
|
30
|
+
if any(k in m for k in _PRIVATE_KEYS):
|
|
31
|
+
m = {k: v for k, v in m.items() if k not in _PRIVATE_KEYS}
|
|
32
|
+
out.append(m)
|
|
33
|
+
return out
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class StreamAccumulator:
|
|
37
|
+
"""Reassembles a streamed chat completion from SSE ``choices[0].delta`` fragments."""
|
|
38
|
+
|
|
39
|
+
def __init__(self) -> None:
|
|
40
|
+
self._content: List[str] = []
|
|
41
|
+
self._tool_calls: dict = {} # index -> {id, type, function:{name, arguments}}
|
|
42
|
+
self.finish_reason = ""
|
|
43
|
+
self.model = ""
|
|
44
|
+
self.usage: dict = {}
|
|
45
|
+
|
|
46
|
+
def add(self, obj: dict) -> str:
|
|
47
|
+
"""Fold one SSE chunk in; return any new content text to emit live."""
|
|
48
|
+
if obj.get("model"):
|
|
49
|
+
self.model = obj["model"]
|
|
50
|
+
if obj.get("usage"):
|
|
51
|
+
self.usage = obj["usage"]
|
|
52
|
+
choices = obj.get("choices") or []
|
|
53
|
+
if not choices:
|
|
54
|
+
return ""
|
|
55
|
+
choice = choices[0]
|
|
56
|
+
if choice.get("finish_reason"):
|
|
57
|
+
self.finish_reason = choice["finish_reason"]
|
|
58
|
+
delta = choice.get("delta") or {}
|
|
59
|
+
for tc in delta.get("tool_calls") or []:
|
|
60
|
+
idx = tc.get("index", 0)
|
|
61
|
+
slot = self._tool_calls.setdefault(
|
|
62
|
+
idx, {"id": "", "type": "function", "function": {"name": "", "arguments": ""}}
|
|
63
|
+
)
|
|
64
|
+
if tc.get("id"):
|
|
65
|
+
slot["id"] = tc["id"]
|
|
66
|
+
if tc.get("type"):
|
|
67
|
+
slot["type"] = tc["type"]
|
|
68
|
+
fn = tc.get("function") or {}
|
|
69
|
+
if fn.get("name"):
|
|
70
|
+
slot["function"]["name"] = fn["name"]
|
|
71
|
+
if fn.get("arguments"):
|
|
72
|
+
slot["function"]["arguments"] += fn["arguments"]
|
|
73
|
+
text = delta.get("content") or ""
|
|
74
|
+
if text:
|
|
75
|
+
self._content.append(text)
|
|
76
|
+
return text
|
|
77
|
+
|
|
78
|
+
def result(self, model_sent: str) -> ChatResult:
|
|
79
|
+
calls = [self._tool_calls[i] for i in sorted(self._tool_calls)]
|
|
80
|
+
content = "".join(self._content)
|
|
81
|
+
message: dict = {"role": "assistant", "content": content}
|
|
82
|
+
if calls:
|
|
83
|
+
message["tool_calls"] = calls
|
|
84
|
+
return ChatResult(content=content, model=self.model or model_sent, tool_calls=calls,
|
|
85
|
+
finish_reason=self.finish_reason, usage=normalize_usage(self.usage),
|
|
86
|
+
raw_message=message)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def parse_chat(data: dict, model_sent: str) -> ChatResult:
|
|
90
|
+
try:
|
|
91
|
+
choice = data["choices"][0]
|
|
92
|
+
message = choice.get("message", {})
|
|
93
|
+
except (KeyError, IndexError, TypeError) as exc:
|
|
94
|
+
raise ApiError(f"unexpected chat response shape: {str(data)[:200]}") from exc
|
|
95
|
+
return ChatResult(
|
|
96
|
+
content=message.get("content") or "",
|
|
97
|
+
model=data.get("model", model_sent),
|
|
98
|
+
tool_calls=message.get("tool_calls") or [],
|
|
99
|
+
finish_reason=choice.get("finish_reason", ""),
|
|
100
|
+
usage=normalize_usage(data.get("usage", {})),
|
|
101
|
+
raw_message=message,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class OpenAIChatProvider(BaseProvider):
|
|
106
|
+
wire = "openai_chat"
|
|
107
|
+
|
|
108
|
+
def _payload(self, req: ChatRequest, stream: bool) -> dict:
|
|
109
|
+
payload: dict = {"model": req.model, "messages": strip_private(req.messages)}
|
|
110
|
+
if req.temperature is not None and self.spec.has("temperature"):
|
|
111
|
+
payload["temperature"] = req.temperature
|
|
112
|
+
if req.max_tokens is not None:
|
|
113
|
+
payload["max_tokens"] = req.max_tokens
|
|
114
|
+
if req.tools:
|
|
115
|
+
payload["tools"] = req.tools
|
|
116
|
+
payload["tool_choice"] = req.tool_choice or "auto"
|
|
117
|
+
if stream:
|
|
118
|
+
payload["stream"] = True
|
|
119
|
+
payload["stream_options"] = {"include_usage": True}
|
|
120
|
+
return payload
|
|
121
|
+
|
|
122
|
+
def _complete(self, req: ChatRequest) -> ChatResult:
|
|
123
|
+
payload = self._payload(req, stream=False)
|
|
124
|
+
status, body = self._http("POST", "/chat/completions", payload, req.cancel_event)
|
|
125
|
+
result = parse_chat(decode_json(status, body), req.model)
|
|
126
|
+
result.model = self.qualified(result.model)
|
|
127
|
+
return result
|
|
128
|
+
|
|
129
|
+
def _stream(self, req: ChatRequest, on_delta: Callable[[str], None]) -> ChatResult:
|
|
130
|
+
payload = self._payload(req, stream=True)
|
|
131
|
+
acc = StreamAccumulator()
|
|
132
|
+
holder: dict = {}
|
|
133
|
+
leftovers: List[str] = []
|
|
134
|
+
for obj in iter_sse_json(self._http_stream("/chat/completions", payload, req.cancel_event),
|
|
135
|
+
holder, leftovers):
|
|
136
|
+
text = acc.add(obj)
|
|
137
|
+
if text:
|
|
138
|
+
on_delta(text)
|
|
139
|
+
finish_stream(holder, leftovers, self.name)
|
|
140
|
+
result = acc.result(req.model)
|
|
141
|
+
result.model = self.qualified(result.model)
|
|
142
|
+
return result
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""OpenAI Responses API adapter (``POST {base}/responses``).
|
|
2
|
+
|
|
3
|
+
This is the wire OpenAI recommends for new work and the only one that serves its Codex models. Ollama
|
|
4
|
+
(0.32+) implements the same endpoint, so one adapter covers both. Translation from scoot's neutral
|
|
5
|
+
format:
|
|
6
|
+
|
|
7
|
+
* ``system`` messages → ``instructions``
|
|
8
|
+
* ``user`` messages → message items (``input_text`` / ``input_image`` parts)
|
|
9
|
+
* ``assistant`` messages → an ``output_text`` message item plus one ``function_call`` item per tool
|
|
10
|
+
call, or, when the message carries ``provider_items`` produced by this very model, those items are
|
|
11
|
+
replayed verbatim (that is how encrypted ``reasoning`` items travel across tool calls)
|
|
12
|
+
* ``tool`` messages → ``function_call_output`` items
|
|
13
|
+
* tool schemas → flat ``{"type": "function", "name", "description", "parameters"}``
|
|
14
|
+
|
|
15
|
+
Requests are stateless (``store: false``); scoot replays history itself.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
from typing import Callable, List, Optional, Tuple
|
|
22
|
+
|
|
23
|
+
from ..errors import ApiError
|
|
24
|
+
from .base import (
|
|
25
|
+
BaseProvider,
|
|
26
|
+
ChatRequest,
|
|
27
|
+
ChatResult,
|
|
28
|
+
decode_json,
|
|
29
|
+
finish_stream,
|
|
30
|
+
iter_sse_json,
|
|
31
|
+
normalize_usage,
|
|
32
|
+
raise_for_status,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# Models that accept the ``reasoning`` parameter (others reject it with a 400).
|
|
36
|
+
_REASONING_PREFIXES = ("gpt-5", "o1", "o3", "o4", "codex")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def is_reasoning_model(model: str) -> bool:
|
|
40
|
+
m = (model or "").lower()
|
|
41
|
+
return any(m.startswith(p) or p in m for p in _REASONING_PREFIXES)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ── request translation ─────────────────────────────────────────────────────────
|
|
45
|
+
def _content_parts(content, role: str) -> object:
|
|
46
|
+
"""Neutral content (str or OpenAI-chat parts) → Responses content for ``role``."""
|
|
47
|
+
if isinstance(content, str) or content is None:
|
|
48
|
+
return content or ""
|
|
49
|
+
text_type = "output_text" if role == "assistant" else "input_text"
|
|
50
|
+
parts = []
|
|
51
|
+
for part in content:
|
|
52
|
+
if not isinstance(part, dict):
|
|
53
|
+
continue
|
|
54
|
+
ptype = part.get("type")
|
|
55
|
+
if ptype == "text":
|
|
56
|
+
parts.append({"type": text_type, "text": part.get("text", "")})
|
|
57
|
+
elif ptype == "image_url":
|
|
58
|
+
img = part.get("image_url") or {}
|
|
59
|
+
url = img.get("url") if isinstance(img, dict) else img
|
|
60
|
+
item = {"type": "input_image", "image_url": url}
|
|
61
|
+
if isinstance(img, dict) and img.get("detail"):
|
|
62
|
+
item["detail"] = img["detail"]
|
|
63
|
+
parts.append(item)
|
|
64
|
+
elif ptype in ("input_text", "output_text", "input_image"):
|
|
65
|
+
parts.append(part)
|
|
66
|
+
return parts
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _arguments(raw) -> str:
|
|
70
|
+
if isinstance(raw, str):
|
|
71
|
+
return raw
|
|
72
|
+
return json.dumps(raw or {})
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def translate_messages(messages: List[dict], qualified_model: str) -> Tuple[str, List[dict]]:
|
|
76
|
+
"""Return ``(instructions, input_items)`` for the Responses request."""
|
|
77
|
+
instructions: List[str] = []
|
|
78
|
+
items: List[dict] = []
|
|
79
|
+
for m in messages:
|
|
80
|
+
role = m.get("role")
|
|
81
|
+
if role in ("system", "developer"):
|
|
82
|
+
text = m.get("content")
|
|
83
|
+
if isinstance(text, list):
|
|
84
|
+
text = "\n".join(p.get("text", "") for p in text if isinstance(p, dict))
|
|
85
|
+
if text:
|
|
86
|
+
instructions.append(str(text))
|
|
87
|
+
elif role == "user":
|
|
88
|
+
items.append({"role": "user", "content": _content_parts(m.get("content"), "user")})
|
|
89
|
+
elif role == "assistant":
|
|
90
|
+
replay = m.get("provider_items")
|
|
91
|
+
if isinstance(replay, dict) and replay.get("model") == qualified_model and replay.get("items"):
|
|
92
|
+
items.extend(replay["items"])
|
|
93
|
+
continue
|
|
94
|
+
text = m.get("content")
|
|
95
|
+
if isinstance(text, list):
|
|
96
|
+
items.append({"role": "assistant", "content": _content_parts(text, "assistant")})
|
|
97
|
+
elif text:
|
|
98
|
+
items.append({"role": "assistant",
|
|
99
|
+
"content": [{"type": "output_text", "text": str(text)}]})
|
|
100
|
+
for tc in m.get("tool_calls") or []:
|
|
101
|
+
fn = tc.get("function") or {}
|
|
102
|
+
items.append({
|
|
103
|
+
"type": "function_call",
|
|
104
|
+
"call_id": tc.get("id", ""),
|
|
105
|
+
"name": fn.get("name", ""),
|
|
106
|
+
"arguments": _arguments(fn.get("arguments")),
|
|
107
|
+
})
|
|
108
|
+
elif role == "tool":
|
|
109
|
+
content = m.get("content")
|
|
110
|
+
if not isinstance(content, str):
|
|
111
|
+
content = json.dumps(content) if content is not None else ""
|
|
112
|
+
items.append({"type": "function_call_output", "call_id": m.get("tool_call_id", ""),
|
|
113
|
+
"output": content})
|
|
114
|
+
return "\n\n".join(instructions), items
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def translate_tool(tool: dict) -> dict:
|
|
118
|
+
"""Neutral ``{"type": "function", "function": {...}}`` → flat Responses tool definition."""
|
|
119
|
+
fn = tool.get("function") if isinstance(tool, dict) else None
|
|
120
|
+
if not isinstance(fn, dict):
|
|
121
|
+
return tool
|
|
122
|
+
return {
|
|
123
|
+
"type": "function",
|
|
124
|
+
"name": fn.get("name", ""),
|
|
125
|
+
"description": fn.get("description", ""),
|
|
126
|
+
"parameters": fn.get("parameters") or {"type": "object", "properties": {}},
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ── response parsing ────────────────────────────────────────────────────────────
|
|
131
|
+
def _text_of(item: dict) -> str:
|
|
132
|
+
parts = item.get("content") or []
|
|
133
|
+
if isinstance(parts, str):
|
|
134
|
+
return parts
|
|
135
|
+
return "".join(p.get("text", "") for p in parts if isinstance(p, dict) and p.get("type") == "output_text")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _tool_call_of(item: dict) -> dict:
|
|
139
|
+
return {
|
|
140
|
+
"id": item.get("call_id") or item.get("id") or "",
|
|
141
|
+
"type": "function",
|
|
142
|
+
"function": {"name": item.get("name", ""), "arguments": item.get("arguments") or "{}"},
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def build_result(items: List[dict], model: str, usage: Optional[dict], status: str,
|
|
147
|
+
streamed_text: str = "") -> ChatResult:
|
|
148
|
+
"""Fold output items into a :class:`ChatResult`; ``provider_items`` keeps them for replay."""
|
|
149
|
+
texts = [_text_of(it) for it in items if it.get("type") == "message"]
|
|
150
|
+
tool_calls = [_tool_call_of(it) for it in items if it.get("type") == "function_call"]
|
|
151
|
+
content = streamed_text or "".join(texts)
|
|
152
|
+
if tool_calls:
|
|
153
|
+
finish = "tool_calls"
|
|
154
|
+
elif status == "incomplete":
|
|
155
|
+
finish = "length"
|
|
156
|
+
else:
|
|
157
|
+
finish = "stop"
|
|
158
|
+
message: dict = {"role": "assistant", "content": content}
|
|
159
|
+
if tool_calls:
|
|
160
|
+
message["tool_calls"] = tool_calls
|
|
161
|
+
if items:
|
|
162
|
+
message["provider_items"] = {"model": model, "items": items}
|
|
163
|
+
return ChatResult(content=content, model=model, tool_calls=tool_calls, finish_reason=finish,
|
|
164
|
+
usage=normalize_usage(usage), raw_message=message)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class _StreamState:
|
|
168
|
+
"""Accumulates Responses SSE events into output items + live text."""
|
|
169
|
+
|
|
170
|
+
def __init__(self) -> None:
|
|
171
|
+
self.items: List[dict] = []
|
|
172
|
+
self.text: List[str] = []
|
|
173
|
+
self.usage: dict = {}
|
|
174
|
+
self.model = ""
|
|
175
|
+
self.status = ""
|
|
176
|
+
self.error: Optional[dict] = None
|
|
177
|
+
|
|
178
|
+
def add(self, obj: dict) -> str:
|
|
179
|
+
etype = obj.get("type", "")
|
|
180
|
+
if etype == "response.output_text.delta":
|
|
181
|
+
delta = obj.get("delta") or ""
|
|
182
|
+
if delta:
|
|
183
|
+
self.text.append(delta)
|
|
184
|
+
return delta
|
|
185
|
+
if etype == "response.output_item.done":
|
|
186
|
+
item = obj.get("item")
|
|
187
|
+
if isinstance(item, dict):
|
|
188
|
+
self.items.append(item)
|
|
189
|
+
elif etype in ("response.completed", "response.incomplete", "response.failed"):
|
|
190
|
+
resp = obj.get("response") or {}
|
|
191
|
+
self.usage = resp.get("usage") or self.usage
|
|
192
|
+
self.model = resp.get("model") or self.model
|
|
193
|
+
self.status = resp.get("status") or etype.rsplit(".", 1)[-1]
|
|
194
|
+
if resp.get("error"):
|
|
195
|
+
self.error = resp["error"]
|
|
196
|
+
if not self.items and resp.get("output"):
|
|
197
|
+
self.items = list(resp["output"])
|
|
198
|
+
elif etype == "error":
|
|
199
|
+
self.error = {"message": obj.get("message") or obj.get("error") or "stream error",
|
|
200
|
+
"code": obj.get("code", "")}
|
|
201
|
+
return ""
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class OpenAIResponsesProvider(BaseProvider):
|
|
205
|
+
wire = "openai_responses"
|
|
206
|
+
|
|
207
|
+
def _payload(self, req: ChatRequest, stream: bool) -> dict:
|
|
208
|
+
instructions, items = translate_messages(req.messages, self.qualified(req.model))
|
|
209
|
+
payload: dict = {"model": req.model, "input": items, "store": False, "stream": stream}
|
|
210
|
+
if instructions:
|
|
211
|
+
payload["instructions"] = instructions
|
|
212
|
+
if req.tools:
|
|
213
|
+
payload["tools"] = [translate_tool(t) for t in req.tools]
|
|
214
|
+
payload["tool_choice"] = req.tool_choice or "auto"
|
|
215
|
+
if req.max_tokens:
|
|
216
|
+
payload["max_output_tokens"] = req.max_tokens
|
|
217
|
+
if req.temperature is not None and self.spec.has("temperature"):
|
|
218
|
+
payload["temperature"] = req.temperature
|
|
219
|
+
effort = getattr(self.config, "effort", "") or ""
|
|
220
|
+
if self.spec.has("reasoning") and is_reasoning_model(req.model) and effort:
|
|
221
|
+
payload["reasoning"] = {"effort": effort}
|
|
222
|
+
return payload
|
|
223
|
+
|
|
224
|
+
def _complete(self, req: ChatRequest) -> ChatResult:
|
|
225
|
+
payload = self._payload(req, stream=False)
|
|
226
|
+
status, body = self._http("POST", "/responses", payload, req.cancel_event)
|
|
227
|
+
data = decode_json(status, body)
|
|
228
|
+
if data.get("error"):
|
|
229
|
+
raise_for_status(400, json.dumps({"error": data["error"]}), self.name)
|
|
230
|
+
return build_result(data.get("output") or [], self.qualified(data.get("model") or req.model),
|
|
231
|
+
data.get("usage"), data.get("status", ""))
|
|
232
|
+
|
|
233
|
+
def _stream(self, req: ChatRequest, on_delta: Callable[[str], None]) -> ChatResult:
|
|
234
|
+
payload = self._payload(req, stream=True)
|
|
235
|
+
state = _StreamState()
|
|
236
|
+
holder: dict = {}
|
|
237
|
+
leftovers: List[str] = []
|
|
238
|
+
for obj in iter_sse_json(self._http_stream("/responses", payload, req.cancel_event), holder,
|
|
239
|
+
leftovers):
|
|
240
|
+
text = state.add(obj)
|
|
241
|
+
if text:
|
|
242
|
+
on_delta(text)
|
|
243
|
+
finish_stream(holder, leftovers, self.name)
|
|
244
|
+
if state.error:
|
|
245
|
+
raise_for_status(400, json.dumps({"error": state.error}), self.name)
|
|
246
|
+
raise ApiError(str(state.error))
|
|
247
|
+
return build_result(state.items, self.qualified(state.model or req.model), state.usage,
|
|
248
|
+
state.status, streamed_text="".join(state.text))
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Provider registry and the pool the application holds.
|
|
2
|
+
|
|
3
|
+
Adding a provider is one :func:`register` call with a :class:`ProviderSpec`; the wire adapters are
|
|
4
|
+
selected by ``spec.wire``. Base URLs can be overridden per provider with ``SCOOT_<NAME>_BASE_URL``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from dataclasses import replace
|
|
11
|
+
from typing import Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
from ..errors import ConfigError, ScootError
|
|
14
|
+
from .base import BaseProvider, ChatResult, ModelInfo, ProviderSpec, qualify, split_model_id
|
|
15
|
+
|
|
16
|
+
_SPECS: Dict[str, ProviderSpec] = {}
|
|
17
|
+
_WIRES: Dict[str, type] = {}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def register(spec: ProviderSpec) -> None:
|
|
21
|
+
_SPECS[spec.name] = spec
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get(name: str) -> Optional[ProviderSpec]:
|
|
25
|
+
load_builtins()
|
|
26
|
+
return _SPECS.get(name)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def names() -> List[str]:
|
|
30
|
+
load_builtins()
|
|
31
|
+
return list(_SPECS)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def all_specs() -> List[ProviderSpec]:
|
|
35
|
+
load_builtins()
|
|
36
|
+
return list(_SPECS.values())
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_loaded = False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def load_builtins() -> None:
|
|
43
|
+
"""Register the built-in providers and wire adapters (idempotent)."""
|
|
44
|
+
global _loaded
|
|
45
|
+
if _loaded:
|
|
46
|
+
return
|
|
47
|
+
from .openai_chat import OpenAIChatProvider
|
|
48
|
+
from .openai_responses import OpenAIResponsesProvider
|
|
49
|
+
|
|
50
|
+
_WIRES["openai_responses"] = OpenAIResponsesProvider
|
|
51
|
+
_WIRES["openai_chat"] = OpenAIChatProvider
|
|
52
|
+
register(ProviderSpec(
|
|
53
|
+
name="openai",
|
|
54
|
+
base_url="https://api.openai.com/v1",
|
|
55
|
+
wire="openai_responses",
|
|
56
|
+
key_env=("OPENAI_API_KEY",),
|
|
57
|
+
key_required=True,
|
|
58
|
+
preferred_models=("gpt-5.3-codex", "gpt-5.5", "gpt-5.4", "gpt-5", "gpt-4.1"),
|
|
59
|
+
vision_models=("gpt-5.3-codex", "gpt-5", "gpt-4.1", "gpt-4o"),
|
|
60
|
+
capabilities=frozenset({"tools", "streaming", "vision", "reasoning"}),
|
|
61
|
+
))
|
|
62
|
+
register(ProviderSpec(
|
|
63
|
+
name="ollama",
|
|
64
|
+
base_url="http://localhost:11434/v1",
|
|
65
|
+
wire="openai_responses",
|
|
66
|
+
key_env=("OLLAMA_API_KEY",),
|
|
67
|
+
key_required=False,
|
|
68
|
+
preferred_models=("llama3.2", "qwen3", "llama3.1", "mistral"),
|
|
69
|
+
vision_models=("qwen2.5vl", "llama3.2-vision", "llava", "gemma3", "minicpm-v"),
|
|
70
|
+
capabilities=frozenset({"tools", "streaming", "vision", "temperature"}),
|
|
71
|
+
))
|
|
72
|
+
_loaded = True
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def base_url_for(spec: ProviderSpec) -> str:
|
|
76
|
+
override = os.environ.get(f"SCOOT_{spec.name.upper()}_BASE_URL", "").strip()
|
|
77
|
+
return override or spec.base_url
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def make_provider(name: str, config, transport=None) -> BaseProvider:
|
|
81
|
+
"""Instantiate the adapter for ``name`` with any base-URL override applied."""
|
|
82
|
+
spec = get(name)
|
|
83
|
+
if spec is None:
|
|
84
|
+
raise ConfigError(f"unknown provider '{name}'", hint=f"known providers: {', '.join(names())}")
|
|
85
|
+
cls = _WIRES.get(spec.wire)
|
|
86
|
+
if cls is None:
|
|
87
|
+
raise ConfigError(f"provider '{name}' uses unknown wire '{spec.wire}'")
|
|
88
|
+
return cls(replace(spec, base_url=base_url_for(spec)), config, transport=transport)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def default_provider_name(config) -> str:
|
|
92
|
+
"""``SCOOT_PROVIDER`` if set, else the first provider with a key configured, else ``ollama``."""
|
|
93
|
+
from ..auth import is_configured
|
|
94
|
+
|
|
95
|
+
wanted = (getattr(config, "provider", "") or "").strip().lower()
|
|
96
|
+
if wanted:
|
|
97
|
+
if get(wanted) is None:
|
|
98
|
+
raise ConfigError(f"unknown provider '{wanted}'", hint=f"known providers: {', '.join(names())}")
|
|
99
|
+
return wanted
|
|
100
|
+
for spec in all_specs():
|
|
101
|
+
if spec.key_required and is_configured(spec):
|
|
102
|
+
return spec.name
|
|
103
|
+
return "ollama"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def fallback_model(config) -> str:
|
|
107
|
+
"""The qualified model ``auto`` resolves to before any live model list is known."""
|
|
108
|
+
name = default_provider_name(config)
|
|
109
|
+
spec = get(name)
|
|
110
|
+
head = spec.preferred_models[0] if spec and spec.preferred_models else "default"
|
|
111
|
+
return qualify(name, head)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class ProviderPool:
|
|
115
|
+
"""Holds one adapter per provider, built lazily, and dispatches by the ``provider/`` prefix.
|
|
116
|
+
|
|
117
|
+
Implements the same ``chat`` / ``chat_stream`` / ``list_models`` surface as a single provider, so
|
|
118
|
+
consumers never care whether one or many providers are in play. This is also where a routing
|
|
119
|
+
provider would plug in later: a router is just another entry the pool can dispatch to.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
def __init__(self, config, transport=None):
|
|
123
|
+
self.config = config
|
|
124
|
+
self._transport = transport
|
|
125
|
+
self._providers: Dict[str, BaseProvider] = {}
|
|
126
|
+
self.list_errors: Dict[str, str] = {}
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def default_name(self) -> str:
|
|
130
|
+
return default_provider_name(self.config)
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def spec(self) -> ProviderSpec:
|
|
134
|
+
return self.provider(self.default_name).spec
|
|
135
|
+
|
|
136
|
+
def provider(self, name: Optional[str] = None) -> BaseProvider:
|
|
137
|
+
name = name or self.default_name
|
|
138
|
+
if name not in self._providers:
|
|
139
|
+
self._providers[name] = make_provider(name, self.config, transport=self._transport)
|
|
140
|
+
return self._providers[name]
|
|
141
|
+
|
|
142
|
+
def for_model(self, model: Optional[str]) -> "tuple[BaseProvider, str]":
|
|
143
|
+
"""Resolve ``provider/model`` (or a bare name) to ``(adapter, bare_model)``."""
|
|
144
|
+
head, bare = split_model_id(model)
|
|
145
|
+
if head and get(head) is None:
|
|
146
|
+
raise ConfigError(f"unknown provider '{head}' in model '{model}'",
|
|
147
|
+
hint=f"known providers: {', '.join(names())}")
|
|
148
|
+
return self.provider(head), bare
|
|
149
|
+
|
|
150
|
+
def chat(self, messages, model=None, **kwargs) -> ChatResult:
|
|
151
|
+
p, bare = self.for_model(model or self.config.resolve_model())
|
|
152
|
+
return p.chat(messages, model=bare, **kwargs)
|
|
153
|
+
|
|
154
|
+
def chat_stream(self, messages, model=None, **kwargs) -> ChatResult:
|
|
155
|
+
p, bare = self.for_model(model or self.config.resolve_model())
|
|
156
|
+
return p.chat_stream(messages, model=bare, **kwargs)
|
|
157
|
+
|
|
158
|
+
def list_models(self, provider: Optional[str] = None, cancel_event=None) -> List[ModelInfo]:
|
|
159
|
+
"""Models from one provider, or from every configured provider (failures are recorded, not raised)."""
|
|
160
|
+
from ..auth import is_configured
|
|
161
|
+
|
|
162
|
+
if provider:
|
|
163
|
+
return self.provider(provider).list_models(cancel_event=cancel_event)
|
|
164
|
+
out: List[ModelInfo] = []
|
|
165
|
+
self.list_errors = {}
|
|
166
|
+
for spec in all_specs():
|
|
167
|
+
if not is_configured(spec):
|
|
168
|
+
continue
|
|
169
|
+
try:
|
|
170
|
+
out.extend(self.provider(spec.name).list_models(cancel_event=cancel_event))
|
|
171
|
+
except ScootError as exc:
|
|
172
|
+
self.list_errors[spec.name] = str(exc)
|
|
173
|
+
return out
|
scootcli/rendering.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Minimal stdlib rendering helpers: ANSI colors + secret redaction. No third-party deps."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import difflib
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
_COLOR_ENABLED = sys.stdout.isatty() and os.environ.get("NO_COLOR") is None
|
|
11
|
+
|
|
12
|
+
_CODES = {
|
|
13
|
+
"reset": "\033[0m",
|
|
14
|
+
"bold": "\033[1m",
|
|
15
|
+
"dim": "\033[2m",
|
|
16
|
+
"red": "\033[31m",
|
|
17
|
+
"green": "\033[32m",
|
|
18
|
+
"yellow": "\033[33m",
|
|
19
|
+
"blue": "\033[34m",
|
|
20
|
+
"magenta": "\033[35m",
|
|
21
|
+
"cyan": "\033[36m",
|
|
22
|
+
"gray": "\033[90m",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def color(text: str, *styles: str) -> str:
|
|
27
|
+
"""Wrap ``text`` in the given ANSI styles (no-op when color is disabled)."""
|
|
28
|
+
if not _COLOR_ENABLED or not styles:
|
|
29
|
+
return text
|
|
30
|
+
prefix = "".join(_CODES.get(s, "") for s in styles)
|
|
31
|
+
return f"{prefix}{text}{_CODES['reset']}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Redact anything that looks like a token/secret before printing (defense in depth): provider API
|
|
35
|
+
# keys (OpenAI ``sk-…``, Anthropic ``sk-ant-…``), GitHub tokens, Bearer values, and JWTs. Bearer values
|
|
36
|
+
# may contain separators, so the whole non-whitespace run is masked.
|
|
37
|
+
_SECRET_RE = re.compile(
|
|
38
|
+
r"(sk-[A-Za-z0-9_\-]{12,}|gh[opsu]_[A-Za-z0-9]{8,}|Bearer\s+\S+|tid=\S+|eyJ[A-Za-z0-9._\-]{20,})"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def redact(text: str) -> str:
|
|
43
|
+
"""Mask token-like substrings so secrets never reach the terminal/logs."""
|
|
44
|
+
return _SECRET_RE.sub("<redacted>", text)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def eprint(*args, **kwargs) -> None:
|
|
48
|
+
"""Print to stderr."""
|
|
49
|
+
print(*args, file=sys.stderr, **kwargs)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def diff_stats(old: str, new: str) -> "tuple[int, int]":
|
|
53
|
+
"""Return ``(added, removed)`` line counts between two texts."""
|
|
54
|
+
added = removed = 0
|
|
55
|
+
for line in difflib.unified_diff(old.splitlines(), new.splitlines(), lineterm=""):
|
|
56
|
+
if line.startswith("+") and not line.startswith("+++"):
|
|
57
|
+
added += 1
|
|
58
|
+
elif line.startswith("-") and not line.startswith("---"):
|
|
59
|
+
removed += 1
|
|
60
|
+
return added, removed
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def unified_diff(old: str, new: str, path: str = "", context: int = 3) -> str:
|
|
64
|
+
"""Return a colorized unified diff between ``old`` and ``new``."""
|
|
65
|
+
from_name = f"a/{path}" if path else "old"
|
|
66
|
+
to_name = f"b/{path}" if path else "new"
|
|
67
|
+
lines = difflib.unified_diff(
|
|
68
|
+
old.splitlines(), new.splitlines(),
|
|
69
|
+
fromfile=from_name, tofile=to_name, lineterm="", n=context,
|
|
70
|
+
)
|
|
71
|
+
out = []
|
|
72
|
+
for line in lines:
|
|
73
|
+
if line.startswith("+++") or line.startswith("---"):
|
|
74
|
+
out.append(color(line, "bold"))
|
|
75
|
+
elif line.startswith("@@"):
|
|
76
|
+
out.append(color(line, "cyan"))
|
|
77
|
+
elif line.startswith("+"):
|
|
78
|
+
out.append(color(line, "green"))
|
|
79
|
+
elif line.startswith("-"):
|
|
80
|
+
out.append(color(line, "red"))
|
|
81
|
+
else:
|
|
82
|
+
out.append(color(line, "gray"))
|
|
83
|
+
return "\n".join(out)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
|