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
scootcli/project.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Project learning for /init: scan the workspace and generate an AGENTS.md via the model.
|
|
2
|
+
|
|
3
|
+
The scan is intentionally cheap and read-only: a bounded file tree plus the contents of common
|
|
4
|
+
manifest/entry files. The model turns that digest into a concise AGENTS.md that is then auto-loaded
|
|
5
|
+
into the system prompt on every run (PLAN §6, §15).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import threading
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import List, Optional
|
|
13
|
+
|
|
14
|
+
_SKIP_DIRS = {
|
|
15
|
+
".git", ".hg", ".svn", "__pycache__", ".mypy_cache", ".pytest_cache", ".ruff_cache",
|
|
16
|
+
"node_modules", ".venv", "venv", "dist", "build", ".idea", ".vscode", ".cache",
|
|
17
|
+
}
|
|
18
|
+
_MANIFESTS = [
|
|
19
|
+
"README.md", "README.rst", "README", "pyproject.toml", "setup.cfg", "setup.py",
|
|
20
|
+
"requirements.txt", "package.json", "tsconfig.json", "pom.xml", "build.gradle",
|
|
21
|
+
"build.gradle.kts", "go.mod", "Cargo.toml", "Gemfile", "Makefile", "Dockerfile",
|
|
22
|
+
]
|
|
23
|
+
_MAX_TREE_ENTRIES = 200
|
|
24
|
+
_MAX_MANIFEST_BYTES = 4000
|
|
25
|
+
|
|
26
|
+
_INIT_SYSTEM = (
|
|
27
|
+
"You are documenting a software project for an AI coding assistant. Given a file tree and key "
|
|
28
|
+
"files, write a concise AGENTS.md in Markdown with these sections: Overview (what the project is), "
|
|
29
|
+
"Layout (key directories/files), Build & Test (exact commands if discoverable), Conventions, and "
|
|
30
|
+
"Notes. Be specific and terse. Output only the Markdown."
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _iter_tree(root: Path) -> List[str]:
|
|
35
|
+
entries: List[str] = []
|
|
36
|
+
for path in sorted(root.rglob("*")):
|
|
37
|
+
if any(part in _SKIP_DIRS for part in path.relative_to(root).parts):
|
|
38
|
+
continue
|
|
39
|
+
rel = path.relative_to(root).as_posix()
|
|
40
|
+
entries.append(rel + ("/" if path.is_dir() else ""))
|
|
41
|
+
if len(entries) >= _MAX_TREE_ENTRIES:
|
|
42
|
+
entries.append("… [tree truncated]")
|
|
43
|
+
break
|
|
44
|
+
return entries
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _collect_manifests(root: Path) -> List[str]:
|
|
48
|
+
chunks: List[str] = []
|
|
49
|
+
for name in _MANIFESTS:
|
|
50
|
+
path = root / name
|
|
51
|
+
if path.exists() and path.is_file():
|
|
52
|
+
try:
|
|
53
|
+
text = path.read_text("utf-8", "replace")[:_MAX_MANIFEST_BYTES]
|
|
54
|
+
except OSError:
|
|
55
|
+
continue
|
|
56
|
+
chunks.append(f"### {name}\n```\n{text}\n```")
|
|
57
|
+
return chunks
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def scan(root: Path) -> str:
|
|
61
|
+
"""Return a text digest of the project (tree + manifests) for the model."""
|
|
62
|
+
tree = "\n".join(_iter_tree(root))
|
|
63
|
+
manifests = "\n\n".join(_collect_manifests(root)) or "(no common manifest files found)"
|
|
64
|
+
return f"# File tree\n{tree}\n\n# Key files\n{manifests}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _strip_code_fence(text: str) -> str:
|
|
68
|
+
"""If the whole response is wrapped in a ``` fence, unwrap it."""
|
|
69
|
+
t = text.strip()
|
|
70
|
+
if t.startswith("```"):
|
|
71
|
+
lines = t.splitlines()
|
|
72
|
+
lines = lines[1:] # drop opening ```lang
|
|
73
|
+
if lines and lines[-1].strip().startswith("```"):
|
|
74
|
+
lines = lines[:-1] # drop closing ```
|
|
75
|
+
return "\n".join(lines).strip()
|
|
76
|
+
return t
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def generate_agents_md(session, cancel_event: Optional[threading.Event] = None) -> str:
|
|
80
|
+
"""Scan the workspace root and ask the model to produce AGENTS.md content."""
|
|
81
|
+
root = session.config.root
|
|
82
|
+
digest = scan(root)
|
|
83
|
+
result = session.provider.chat(
|
|
84
|
+
[
|
|
85
|
+
{"role": "system", "content": _INIT_SYSTEM},
|
|
86
|
+
{"role": "user", "content": digest},
|
|
87
|
+
],
|
|
88
|
+
model=session.active_model,
|
|
89
|
+
max_tokens=1200,
|
|
90
|
+
cancel_event=cancel_event,
|
|
91
|
+
)
|
|
92
|
+
session.account(result.usage)
|
|
93
|
+
return _strip_code_fence((result.content or "").strip())
|
|
94
|
+
|
scootcli/prompts.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""System prompts.
|
|
2
|
+
|
|
3
|
+
``CHAT_SYSTEM_PROMPT`` powers the simple one-shot ``scoot "..."`` path. ``AGENT_SYSTEM_PROMPT``
|
|
4
|
+
(PLAN §15) powers the interactive agentic loop: intent inference, planning, tool use, and the DONE
|
|
5
|
+
sentinel. Placeholders use ``{{name}}`` and are filled by :func:`build_agent_system_prompt`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import platform
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
CHAT_SYSTEM_PROMPT = (
|
|
14
|
+
"You are scoot, a concise terminal coding assistant for a software engineer. "
|
|
15
|
+
"Answer clearly and briefly. Prefer code and concrete steps over prose. "
|
|
16
|
+
"If a request is ambiguous, ask one short clarifying question."
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
AGENT_SYSTEM_PROMPT = """\
|
|
20
|
+
You are scoot, a terminal coding assistant for a software engineer.
|
|
21
|
+
You run inside the user's workspace and act through tools. Be concise, correct, and safe.
|
|
22
|
+
|
|
23
|
+
ENVIRONMENT
|
|
24
|
+
- Working directory (root): {{workspace_root}}
|
|
25
|
+
- OS: {{os}} Model: {{model}}
|
|
26
|
+
- All file paths you use must stay inside the root. Never touch paths outside it.
|
|
27
|
+
|
|
28
|
+
WORKSPACE (auto-generated map of the current repo — use it to orient before listing/reading files)
|
|
29
|
+
{{workspace}}
|
|
30
|
+
|
|
31
|
+
PROJECT CONTEXT (from AGENTS.md, if present)
|
|
32
|
+
{{agent_md}}
|
|
33
|
+
|
|
34
|
+
INTENT - READ THE USER, DON'T WAIT FOR COMMANDS
|
|
35
|
+
The user just types natural language. Infer what they want and act:
|
|
36
|
+
- A question ("what does X do?", "how do I...") -> answer directly. Read a file first only if you
|
|
37
|
+
need its contents to be accurate. Do NOT modify anything for a question.
|
|
38
|
+
- "Explain / review X" -> read the relevant file(s), then explain. No edits.
|
|
39
|
+
- "Add / change / fix / rename / refactor X" -> read what you need, then make the change via the
|
|
40
|
+
edit_file or write_file tool.
|
|
41
|
+
- "Create a new file/script" -> use write_file.
|
|
42
|
+
- If the request is ambiguous or could destroy work, ask ONE short clarifying question instead of
|
|
43
|
+
guessing. Prefer acting when the intent is clear.
|
|
44
|
+
|
|
45
|
+
PLANNING
|
|
46
|
+
- For a multi-step task, call the update_plan tool with a short plan (max ~6 steps), then execute it,
|
|
47
|
+
calling update_plan again to mark each step in_progress / completed as you go (one in_progress step).
|
|
48
|
+
- For a trivial request (a question, a one-line change), skip the plan and just do it.
|
|
49
|
+
- Keep the user oriented: briefly say what you're about to do before a tool call.
|
|
50
|
+
|
|
51
|
+
TOOLS
|
|
52
|
+
You have these tools:
|
|
53
|
+
{{tool_list}}
|
|
54
|
+
- Use tools to gather context instead of assuming. Read before you edit.
|
|
55
|
+
- Make the smallest change that satisfies the request. Preserve existing style.
|
|
56
|
+
- After editing, if feasible, verify (re-read the file or run a quick check via run_shell).
|
|
57
|
+
- Every tool call is shown to the user and must be approved by them. If a call is declined, adapt:
|
|
58
|
+
choose another approach or ask what they'd prefer. Never try to bypass approval.
|
|
59
|
+
- Tool results may be truncated; request more specifically if you need it.
|
|
60
|
+
|
|
61
|
+
SAFETY
|
|
62
|
+
- run_shell is powerful: prefer read-only/idempotent commands, keep them short, explain why.
|
|
63
|
+
- Never print secrets or the contents of env files / tokens. Never exfiltrate data.
|
|
64
|
+
- Do not make sweeping changes the user didn't ask for.
|
|
65
|
+
|
|
66
|
+
COMPLETION
|
|
67
|
+
- When the task is fully done, stop calling tools and give a short summary of what you did
|
|
68
|
+
(what changed, which files, any commands run, and anything the user should check).
|
|
69
|
+
- End that final message with the token DONE on its own line so the CLI knows you're finished.
|
|
70
|
+
- If you are blocked and cannot proceed, explain why and end with DONE.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def load_agents_md(root: Path) -> str:
|
|
75
|
+
"""Return the contents of AGENTS.md at the workspace root, or a placeholder if absent."""
|
|
76
|
+
for name in ("AGENTS.md", "AGENT.md"):
|
|
77
|
+
path = root / name
|
|
78
|
+
if path.exists() and path.is_file():
|
|
79
|
+
try:
|
|
80
|
+
return path.read_text("utf-8", "replace").strip()
|
|
81
|
+
except OSError:
|
|
82
|
+
break
|
|
83
|
+
return "(none - run /init to generate AGENTS.md)"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def build_agent_system_prompt(root: Path, model: str, tool_list: str, agent_md: str = None,
|
|
87
|
+
workspace: str = "") -> str:
|
|
88
|
+
"""Fill the agent system-prompt template."""
|
|
89
|
+
if agent_md is None:
|
|
90
|
+
agent_md = load_agents_md(root)
|
|
91
|
+
return (
|
|
92
|
+
AGENT_SYSTEM_PROMPT.replace("{{workspace_root}}", str(root))
|
|
93
|
+
.replace("{{os}}", platform.system() or "unknown")
|
|
94
|
+
.replace("{{model}}", model)
|
|
95
|
+
.replace("{{tool_list}}", tool_list)
|
|
96
|
+
.replace("{{workspace}}", workspace.strip() or "(not available — use list_dir/search to explore)")
|
|
97
|
+
.replace("{{agent_md}}", agent_md)
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""LLM providers behind one protocol.
|
|
2
|
+
|
|
3
|
+
Consumers speak scoot's neutral conversation format (the OpenAI chat shape: ``system``/``user``/
|
|
4
|
+
``assistant``/``tool`` messages, ``tool_calls`` on assistant messages, ``{"type": "function", ...}`` tool
|
|
5
|
+
schemas, ``image_url`` parts with data URIs). Each wire adapter translates that to a vendor API at the
|
|
6
|
+
edge. Models are addressed as ``provider/model`` (``openai/gpt-5.3-codex``, ``ollama/llama3.2``).
|
|
7
|
+
|
|
8
|
+
Public surface:
|
|
9
|
+
* :class:`~scootcli.providers.base.ChatResult` / :class:`~scootcli.providers.base.ChatRequest`
|
|
10
|
+
* :class:`~scootcli.providers.base.ProviderSpec` (a registry row) and :func:`registry.register`
|
|
11
|
+
* :class:`~scootcli.providers.registry.ProviderPool`: what the app holds; dispatches by model prefix.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from .base import ChatRequest, ChatResult, ModelInfo, ProviderSpec, qualify, split_model_id
|
|
15
|
+
from .registry import ProviderPool, default_provider_name, fallback_model, make_provider
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"ChatRequest", "ChatResult", "ModelInfo", "ProviderSpec", "ProviderPool",
|
|
19
|
+
"default_provider_name", "fallback_model", "make_provider", "qualify", "split_model_id",
|
|
20
|
+
]
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""Provider protocol, neutral types, and the plumbing every wire adapter shares.
|
|
2
|
+
|
|
3
|
+
A wire adapter subclasses :class:`BaseProvider` and implements three things: ``_complete`` (one
|
|
4
|
+
buffered chat call), ``_stream`` (one streamed call, invoking ``on_delta``), and optionally
|
|
5
|
+
``_parse_models``. Everything else, API-key lookup, retries with capped backoff, HTTP status to typed
|
|
6
|
+
error mapping, and ``provider/model`` id handling, lives here so adapters stay small.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import random
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import Callable, Dict, FrozenSet, Iterator, List, Optional, Protocol, Tuple
|
|
17
|
+
|
|
18
|
+
from ..errors import (
|
|
19
|
+
ApiError,
|
|
20
|
+
AuthError,
|
|
21
|
+
ContextLengthError,
|
|
22
|
+
Interrupted,
|
|
23
|
+
ModelUnavailableError,
|
|
24
|
+
QuotaError,
|
|
25
|
+
RETRIABLE,
|
|
26
|
+
RateLimitError,
|
|
27
|
+
ServerError,
|
|
28
|
+
)
|
|
29
|
+
from ..transport import _STATUS_MARKER, make_transport
|
|
30
|
+
|
|
31
|
+
_RETRY_ATTEMPTS = 3
|
|
32
|
+
_RETRY_BASE = 0.5
|
|
33
|
+
_RETRY_CAP = 8.0
|
|
34
|
+
|
|
35
|
+
STATUS_LINE_PREFIX = _STATUS_MARKER.strip() # "HTTP_STATUS:"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ── neutral types ──────────────────────────────────────────────────────────────
|
|
39
|
+
@dataclass
|
|
40
|
+
class ChatResult:
|
|
41
|
+
"""Parsed result of one model call (provider-independent)."""
|
|
42
|
+
|
|
43
|
+
content: str
|
|
44
|
+
model: str
|
|
45
|
+
tool_calls: List[dict] = field(default_factory=list)
|
|
46
|
+
finish_reason: str = ""
|
|
47
|
+
usage: dict = field(default_factory=dict)
|
|
48
|
+
raw_message: dict = field(default_factory=dict) # the assistant message to append to history
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class ChatRequest:
|
|
53
|
+
"""One model call. ``hints`` is free-form metadata a router may read (task text, has_images…)."""
|
|
54
|
+
|
|
55
|
+
messages: List[dict]
|
|
56
|
+
model: str
|
|
57
|
+
tools: Optional[List[dict]] = None
|
|
58
|
+
tool_choice: str = "auto"
|
|
59
|
+
temperature: Optional[float] = None
|
|
60
|
+
max_tokens: Optional[int] = None
|
|
61
|
+
cancel_event: Optional[threading.Event] = None
|
|
62
|
+
hints: Dict = field(default_factory=dict)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class ProviderSpec:
|
|
67
|
+
"""A registry row: how to reach one provider. Pure data, so new providers are configuration."""
|
|
68
|
+
|
|
69
|
+
name: str
|
|
70
|
+
base_url: str
|
|
71
|
+
wire: str = "openai_responses" # openai_responses | openai_chat
|
|
72
|
+
key_env: Tuple[str, ...] = () # env var names that may hold the API key, in order
|
|
73
|
+
key_required: bool = True
|
|
74
|
+
preferred_models: Tuple[str, ...] = () # substrings, best first; used by ``auto`` and as fallback
|
|
75
|
+
vision_models: Tuple[str, ...] = () # substrings of models that accept image input
|
|
76
|
+
capabilities: FrozenSet[str] = frozenset({"tools", "streaming"}) # + vision reasoning temperature
|
|
77
|
+
extra_headers: Tuple[Tuple[str, str], ...] = ()
|
|
78
|
+
|
|
79
|
+
def has(self, capability: str) -> bool:
|
|
80
|
+
return capability in self.capabilities
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class ModelInfo:
|
|
85
|
+
"""One model as listed by a provider; ``id`` is the qualified ``provider/name``."""
|
|
86
|
+
|
|
87
|
+
id: str
|
|
88
|
+
provider: str
|
|
89
|
+
name: str
|
|
90
|
+
raw: dict = field(default_factory=dict)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def split_model_id(model: Optional[str]) -> Tuple[Optional[str], str]:
|
|
94
|
+
"""``"openai/gpt-5.3-codex"`` → ``("openai", "gpt-5.3-codex")``; a bare name → ``(None, name)``."""
|
|
95
|
+
if model and "/" in model:
|
|
96
|
+
head, _, tail = model.partition("/")
|
|
97
|
+
if head and tail and " " not in head and "." not in head:
|
|
98
|
+
return head, tail
|
|
99
|
+
return None, model or ""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def qualify(provider: str, model: str) -> str:
|
|
103
|
+
"""Prefix ``model`` with ``provider/`` unless it already carries a provider."""
|
|
104
|
+
return model if split_model_id(model)[0] else f"{provider}/{model}"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class Provider(Protocol):
|
|
108
|
+
"""What consumers depend on. :class:`~scootcli.providers.registry.ProviderPool` implements it too."""
|
|
109
|
+
|
|
110
|
+
def chat(self, messages: List[dict], model: Optional[str] = None, tools=None, tool_choice="auto",
|
|
111
|
+
temperature=None, max_tokens=None, cancel_event=None, hints=None) -> ChatResult: ...
|
|
112
|
+
|
|
113
|
+
def chat_stream(self, messages: List[dict], model: Optional[str] = None, tools=None,
|
|
114
|
+
tool_choice="auto", temperature=None, max_tokens=None, cancel_event=None,
|
|
115
|
+
on_delta: Optional[Callable[[str], None]] = None, hints=None) -> ChatResult: ...
|
|
116
|
+
|
|
117
|
+
def list_models(self, cancel_event=None) -> List[ModelInfo]: ...
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ── error mapping ──────────────────────────────────────────────────────────────
|
|
121
|
+
def _error_fields(body: str) -> Tuple[str, str]:
|
|
122
|
+
"""Pull ``(message, code)`` out of an OpenAI/Anthropic-style ``{"error": ...}`` body."""
|
|
123
|
+
if not body:
|
|
124
|
+
return "", ""
|
|
125
|
+
try:
|
|
126
|
+
data = json.loads(body)
|
|
127
|
+
except json.JSONDecodeError:
|
|
128
|
+
return body[:200], ""
|
|
129
|
+
if not isinstance(data, dict):
|
|
130
|
+
return "", ""
|
|
131
|
+
err = data.get("error")
|
|
132
|
+
if isinstance(err, dict):
|
|
133
|
+
return (err.get("message") or ""), (err.get("code") or err.get("type") or "")
|
|
134
|
+
if isinstance(err, str):
|
|
135
|
+
return err, ""
|
|
136
|
+
return "", ""
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def raise_for_status(status: int, body: str, provider: str = "") -> None:
|
|
140
|
+
"""Classify an HTTP status + error body into a typed error; return silently on success."""
|
|
141
|
+
msg, code = _error_fields(body)
|
|
142
|
+
who = provider or "the provider"
|
|
143
|
+
if status == 401:
|
|
144
|
+
raise AuthError(msg or "unauthorized (401)",
|
|
145
|
+
hint=f"API key for {who} is missing or invalid; run `scoot auth`")
|
|
146
|
+
if status in (402, 403):
|
|
147
|
+
raise QuotaError(msg or f"access denied ({status})", status=status,
|
|
148
|
+
hint=f"check billing, quota, and permissions for {who}")
|
|
149
|
+
if status == 429:
|
|
150
|
+
raise RateLimitError(msg or "rate limited (429)", status=status,
|
|
151
|
+
hint="too many requests; backing off and retrying")
|
|
152
|
+
if 500 <= status <= 599:
|
|
153
|
+
raise ServerError(msg or f"server error ({status})", status=status,
|
|
154
|
+
hint=f"{who} issue; backing off and retrying")
|
|
155
|
+
if status >= 400 or msg:
|
|
156
|
+
low = (code + " " + msg).lower()
|
|
157
|
+
if "context" in low and any(w in low for w in ("length", "exceed", "maximum", "token")):
|
|
158
|
+
raise ContextLengthError(msg or "context length exceeded", status=status, code=code,
|
|
159
|
+
hint="run /compact to shrink context, then retry")
|
|
160
|
+
if "model" in low and any(w in low for w in
|
|
161
|
+
("not supported", "not accessible", "not found", "unsupported",
|
|
162
|
+
"does not exist", "not exist")):
|
|
163
|
+
raise ModelUnavailableError(msg or "model unavailable", status=status, code=code,
|
|
164
|
+
hint="switching to a supported model")
|
|
165
|
+
if msg:
|
|
166
|
+
raise ApiError(msg, status=status, code=code)
|
|
167
|
+
raise ApiError(f"request failed (HTTP {status})", status=status)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def decode_json(status: int, body: str) -> dict:
|
|
171
|
+
if not body:
|
|
172
|
+
raise ApiError("empty response from API", status=status)
|
|
173
|
+
try:
|
|
174
|
+
return json.loads(body)
|
|
175
|
+
except json.JSONDecodeError as exc:
|
|
176
|
+
raise ApiError(f"non-JSON response (HTTP {status}): {body[:200]}", status=status) from exc
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def normalize_usage(usage: Optional[dict]) -> dict:
|
|
180
|
+
"""Return usage with the neutral ``prompt_tokens`` / ``completion_tokens`` / ``total_tokens`` keys."""
|
|
181
|
+
if not isinstance(usage, dict):
|
|
182
|
+
return {}
|
|
183
|
+
out = dict(usage)
|
|
184
|
+
if "prompt_tokens" not in out and "input_tokens" in out:
|
|
185
|
+
out["prompt_tokens"] = out.get("input_tokens") or 0
|
|
186
|
+
if "completion_tokens" not in out and "output_tokens" in out:
|
|
187
|
+
out["completion_tokens"] = out.get("output_tokens") or 0
|
|
188
|
+
if "total_tokens" not in out:
|
|
189
|
+
out["total_tokens"] = int(out.get("prompt_tokens") or 0) + int(out.get("completion_tokens") or 0)
|
|
190
|
+
return out
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# ── shared plumbing ────────────────────────────────────────────────────────────
|
|
194
|
+
class BaseProvider:
|
|
195
|
+
"""Auth, retries, and HTTP for one provider; wire adapters implement the (de)serialisation."""
|
|
196
|
+
|
|
197
|
+
wire = "abstract"
|
|
198
|
+
|
|
199
|
+
def __init__(self, spec: ProviderSpec, config, transport=None, api_key: Optional[str] = None):
|
|
200
|
+
self.spec = spec
|
|
201
|
+
self.config = config
|
|
202
|
+
self.transport = transport or make_transport(config)
|
|
203
|
+
self._api_key = api_key
|
|
204
|
+
|
|
205
|
+
# ── identity ────────────────────────────────────────────────────────────────
|
|
206
|
+
@property
|
|
207
|
+
def name(self) -> str:
|
|
208
|
+
return self.spec.name
|
|
209
|
+
|
|
210
|
+
def default_model(self) -> Optional[str]:
|
|
211
|
+
"""The provider's first preferred model (bare), or ``None`` when it has no opinion."""
|
|
212
|
+
return self.spec.preferred_models[0] if self.spec.preferred_models else None
|
|
213
|
+
|
|
214
|
+
def bare_model(self, model: Optional[str]) -> str:
|
|
215
|
+
"""Strip our own ``provider/`` prefix; a bare name (or another provider's) passes through."""
|
|
216
|
+
head, tail = split_model_id(model)
|
|
217
|
+
return tail if head == self.name else (model or "")
|
|
218
|
+
|
|
219
|
+
def qualified(self, model: str) -> str:
|
|
220
|
+
return qualify(self.name, self.bare_model(model))
|
|
221
|
+
|
|
222
|
+
# ── public API (the Provider protocol) ───────────────────────────────────────
|
|
223
|
+
def chat(self, messages, model=None, tools=None, tool_choice="auto", temperature=None,
|
|
224
|
+
max_tokens=None, cancel_event=None, hints=None) -> ChatResult:
|
|
225
|
+
req = self._request(messages, model, tools, tool_choice, temperature, max_tokens,
|
|
226
|
+
cancel_event, hints)
|
|
227
|
+
return self._with_retry(lambda: self._complete(req), req.cancel_event)
|
|
228
|
+
|
|
229
|
+
def chat_stream(self, messages, model=None, tools=None, tool_choice="auto", temperature=None,
|
|
230
|
+
max_tokens=None, cancel_event=None, on_delta=None, hints=None) -> ChatResult:
|
|
231
|
+
req = self._request(messages, model, tools, tool_choice, temperature, max_tokens,
|
|
232
|
+
cancel_event, hints)
|
|
233
|
+
emitted = [False]
|
|
234
|
+
sink = on_delta or (lambda _t: None)
|
|
235
|
+
|
|
236
|
+
def _on_delta(text: str) -> None:
|
|
237
|
+
emitted[0] = True
|
|
238
|
+
sink(text)
|
|
239
|
+
|
|
240
|
+
return self._with_retry(lambda: self._stream(req, _on_delta), req.cancel_event, emitted)
|
|
241
|
+
|
|
242
|
+
def list_models(self, cancel_event=None) -> List[ModelInfo]:
|
|
243
|
+
status, body = self._http("GET", "/models", None, cancel_event)
|
|
244
|
+
return self._parse_models(decode_json(status, body))
|
|
245
|
+
|
|
246
|
+
# ── adapter hooks ────────────────────────────────────────────────────────────
|
|
247
|
+
def _complete(self, req: ChatRequest) -> ChatResult:
|
|
248
|
+
raise NotImplementedError
|
|
249
|
+
|
|
250
|
+
def _stream(self, req: ChatRequest, on_delta: Callable[[str], None]) -> ChatResult:
|
|
251
|
+
raise NotImplementedError
|
|
252
|
+
|
|
253
|
+
def _parse_models(self, data) -> List[ModelInfo]:
|
|
254
|
+
"""OpenAI-style ``{"data": [{"id": ...}]}`` (also what Ollama's ``/v1/models`` returns)."""
|
|
255
|
+
rows = data.get("data", []) if isinstance(data, dict) else data
|
|
256
|
+
out = []
|
|
257
|
+
for row in rows or []:
|
|
258
|
+
if not isinstance(row, dict):
|
|
259
|
+
continue
|
|
260
|
+
mid = row.get("id") or row.get("name")
|
|
261
|
+
if mid:
|
|
262
|
+
out.append(ModelInfo(id=qualify(self.name, mid), provider=self.name, name=mid, raw=row))
|
|
263
|
+
return out
|
|
264
|
+
|
|
265
|
+
# ── request assembly ─────────────────────────────────────────────────────────
|
|
266
|
+
def _request(self, messages, model, tools, tool_choice, temperature, max_tokens, cancel_event,
|
|
267
|
+
hints) -> ChatRequest:
|
|
268
|
+
bare = self.bare_model(model) or self.default_model() or ""
|
|
269
|
+
if not bare:
|
|
270
|
+
raise ApiError(f"no model given for provider '{self.name}'",
|
|
271
|
+
hint=f"pass --model {self.name}/<name> or set SCOOT_MODEL")
|
|
272
|
+
return ChatRequest(messages=list(messages), model=bare, tools=tools, tool_choice=tool_choice,
|
|
273
|
+
temperature=temperature, max_tokens=max_tokens, cancel_event=cancel_event,
|
|
274
|
+
hints=dict(hints or {}))
|
|
275
|
+
|
|
276
|
+
# ── HTTP ─────────────────────────────────────────────────────────────────────
|
|
277
|
+
def api_key(self) -> Optional[str]:
|
|
278
|
+
if self._api_key is None:
|
|
279
|
+
from ..auth import api_key_for
|
|
280
|
+
|
|
281
|
+
self._api_key = api_key_for(self.spec) or ""
|
|
282
|
+
return self._api_key or None
|
|
283
|
+
|
|
284
|
+
def _auth(self) -> str:
|
|
285
|
+
key = self.api_key()
|
|
286
|
+
if not key and self.spec.key_required:
|
|
287
|
+
env = self.spec.key_env[0] if self.spec.key_env else f"{self.name.upper()}_API_KEY"
|
|
288
|
+
raise AuthError(f"no API key for {self.name}",
|
|
289
|
+
hint=f"set {env} or run `scoot auth set {self.name}`")
|
|
290
|
+
return key or ""
|
|
291
|
+
|
|
292
|
+
def _url(self, path: str) -> str:
|
|
293
|
+
return self.spec.base_url.rstrip("/") + path
|
|
294
|
+
|
|
295
|
+
def _extra_headers(self) -> List[str]:
|
|
296
|
+
return [f"{k}: {v}" for k, v in self.spec.extra_headers]
|
|
297
|
+
|
|
298
|
+
def _http(self, method: str, path: str, payload: Optional[dict], cancel_event) -> Tuple[int, str]:
|
|
299
|
+
"""One request, status-checked. Callers wrap it in :meth:`_with_retry` when appropriate."""
|
|
300
|
+
status, body = self.transport.request(
|
|
301
|
+
method, self._url(path), self._auth(), extra_headers=self._extra_headers(), body=payload,
|
|
302
|
+
cancel_event=cancel_event,
|
|
303
|
+
)
|
|
304
|
+
raise_for_status(status, body, self.name)
|
|
305
|
+
return status, body
|
|
306
|
+
|
|
307
|
+
def _http_stream(self, path: str, payload: dict, cancel_event) -> Iterator[str]:
|
|
308
|
+
return self.transport.stream_request(
|
|
309
|
+
"POST", self._url(path), self._auth(), extra_headers=self._extra_headers(), body=payload,
|
|
310
|
+
cancel_event=cancel_event,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
# ── retry policy ─────────────────────────────────────────────────────────────
|
|
314
|
+
def _with_retry(self, fn: Callable[[], ChatResult], cancel_event, emitted=None) -> ChatResult:
|
|
315
|
+
"""Retry transient failures with capped, jittered backoff; never after tokens were printed."""
|
|
316
|
+
attempt = 0
|
|
317
|
+
while True:
|
|
318
|
+
attempt += 1
|
|
319
|
+
try:
|
|
320
|
+
return fn()
|
|
321
|
+
except RETRIABLE:
|
|
322
|
+
if attempt >= _RETRY_ATTEMPTS or (emitted is not None and emitted[0]):
|
|
323
|
+
raise
|
|
324
|
+
self._backoff(attempt, cancel_event)
|
|
325
|
+
|
|
326
|
+
@staticmethod
|
|
327
|
+
def _backoff(attempt: int, cancel_event) -> None:
|
|
328
|
+
delay = min(_RETRY_CAP, _RETRY_BASE * (2 ** (attempt - 1)))
|
|
329
|
+
delay += random.uniform(0, delay * 0.25) # jitter
|
|
330
|
+
end = time.time() + delay
|
|
331
|
+
while time.time() < end:
|
|
332
|
+
if cancel_event is not None and cancel_event.is_set():
|
|
333
|
+
raise Interrupted("cancelled during backoff")
|
|
334
|
+
time.sleep(0.05)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def iter_sse_json(lines: Iterator[str], status_holder: dict, leftovers: List[str]) -> Iterator[dict]:
|
|
338
|
+
"""Walk raw SSE lines: yield each ``data:`` JSON object, record the status sentinel, collect
|
|
339
|
+
anything that is not SSE (an error body) into ``leftovers``."""
|
|
340
|
+
for line in lines:
|
|
341
|
+
if line.startswith(STATUS_LINE_PREFIX):
|
|
342
|
+
try:
|
|
343
|
+
status_holder["status"] = int(line.split(":", 1)[1].strip())
|
|
344
|
+
except (ValueError, IndexError):
|
|
345
|
+
pass
|
|
346
|
+
continue
|
|
347
|
+
stripped = line.strip()
|
|
348
|
+
if not stripped or stripped.startswith("event:") or stripped.startswith(":"):
|
|
349
|
+
continue
|
|
350
|
+
if stripped.startswith("data:"):
|
|
351
|
+
status_holder["saw_data"] = True
|
|
352
|
+
data = stripped[len("data:"):].strip()
|
|
353
|
+
if data == "[DONE]":
|
|
354
|
+
continue
|
|
355
|
+
try:
|
|
356
|
+
yield json.loads(data)
|
|
357
|
+
except json.JSONDecodeError:
|
|
358
|
+
continue
|
|
359
|
+
else:
|
|
360
|
+
leftovers.append(line)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def finish_stream(status_holder: dict, leftovers: List[str], provider: str) -> None:
|
|
364
|
+
"""After a stream ends: raise the typed error for a non-200 status or a plain error body."""
|
|
365
|
+
status = status_holder.get("status", 200)
|
|
366
|
+
if status != 200 or (not status_holder.get("saw_data") and leftovers):
|
|
367
|
+
body = "".join(leftovers).strip()
|
|
368
|
+
raise_for_status(status if status != 200 else 400, body, provider)
|
|
369
|
+
if body:
|
|
370
|
+
raise ApiError(body[:200], status=status)
|