tether-sdk 0.2.0__tar.gz → 0.2.1__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.
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/AGENTS.md +1 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/PKG-INFO +23 -1
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/README.md +22 -0
- tether_sdk-0.2.1/docs/reference/providers.md +99 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/docs/release-notes.md +8 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/docs/tutorial/agent-interface.md +22 -0
- tether_sdk-0.2.1/tests/providers/test_declarative.py +302 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/test_tether.py +1 -1
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/__init__.py +1 -1
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/providers/__init__.py +4 -0
- tether_sdk-0.2.1/tether/providers/declarative.py +230 -0
- tether_sdk-0.2.0/docs/reference/providers.md +0 -42
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/.github/workflows/ci.yml +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/.github/workflows/delivery.yml +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/.github/workflows/docs.yml +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/.gitignore +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/LICENSE +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/docs/architecture.md +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/docs/benchmarks.md +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/docs/extending.md +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/docs/index.md +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/docs/javascripts/mathjax.js +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/docs/reference/agent-interface.md +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/docs/reference/context-and-store.md +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/docs/reference/harness.md +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/docs/reference/index.md +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/docs/reference/multiplexer-and-decorator.md +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/docs/reference/sandbox-and-skills.md +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/docs/reference/tools.md +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/docs/releasing.md +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/docs/troubleshooting.md +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/docs/tutorial/built-in-tools.md +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/docs/tutorial/cli-and-headless.md +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/docs/tutorial/index.md +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/docs/tutorial/multi-agent.md +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/docs/tutorial/sdk-and-custom-tools.md +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/docs/tutorial/security-and-sandbox.md +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/docs/tutorial/skills.md +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/docs/tutorial/workflows-and-telemetry.md +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/examples/course/README.md +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/examples/course/checks.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/examples/course/course.yaml +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/examples/course/ruff.toml +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/examples/course/skills/planner/SKILL.md +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/examples/course/skills/searcher/SKILL.md +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/examples/course/skills/searcher/tools.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/examples/course/skills/writer/SKILL.md +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/examples/course/skills/writer/tools.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/mkdocs.yml +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/pyproject.toml +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/skills/researcher/SKILL.md +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/__init__.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/conftest.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/providers/__init__.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/providers/test_anthropic.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/providers/test_base.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/providers/test_ollama.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/providers/test_openai.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/test_agent_interface.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/test_cli.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/test_context.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/test_decorator.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/test_harness.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/test_integration.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/test_multiplexer.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/test_pipeline.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/test_release_hardening.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/test_retro.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/test_sandbox.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/test_security_hardening.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/test_skills.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/test_store.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/test_structured_history.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/tools/__init__.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/tools/test_agent.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/tools/test_bash.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/tools/test_done.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/tools/test_edit.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/tools/test_mem.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/tools/test_web.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/api.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/cli.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/context.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/decorator.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/harness.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/mcp.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/multiplexer.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/pipeline.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/providers/anthropic.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/providers/base.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/providers/ollama.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/providers/openai.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/retro.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/sandbox.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/schema.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/skills.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/store.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/tools/__init__.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/tools/agent.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/tools/bash.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/tools/done.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/tools/edit.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/tools/mem.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/tools/web.py +0 -0
- {tether_sdk-0.2.0 → tether_sdk-0.2.1}/uv.lock +0 -0
|
@@ -148,6 +148,7 @@ tether/
|
|
|
148
148
|
- Model identifier format: `<provider>/<model_name>` (e.g., `ollama/qwen3.5:9b`, `openai/gpt-4o-mini`, `anthropic/claude-3-7-sonnet-latest`).
|
|
149
149
|
- If no provider prefix is given, defaults to `ollama/<model>`.
|
|
150
150
|
- Custom SDK transports (e.g., internal corporate gateways, custom local runners, or test harnesses) can be injected via `Harness(provider=...)` or registered under a custom `<prefix>/<model>` via `register_provider(prefix, factory)`.
|
|
151
|
+
- Declarative providers: `~/.tether/providers.yaml` (override with `TETHER_PROVIDERS`, never read from the workspace) maps a model-id prefix to a `kind` (`openai`, `ollama`, `anthropic` or `command`); `claude-cli/sonnet` uses the entry `claude-cli`. The `command` kind runs `argv` directly (never through a shell) with `{model}`, `{system}` and `{conversation}` placeholders, and reads the reply and usage from JSON paths. Built-in prefixes (`ollama`, `openai`, `anthropic`, `claude`) cannot be shadowed, keys are env var names (never values), and an invalid file raises `ValueError: providers.yaml: ...`. Implementation in `tether/providers/declarative.py`; docs in `docs/reference/providers.md`.
|
|
151
152
|
- Environment variables:
|
|
152
153
|
- `OLLAMA_HOST` (default `http://localhost:11434`); `TETHER_OLLAMA_FALLBACK=1` opts in to substituting an installed model when the requested one 404s (default: fail loudly with the installed list)
|
|
153
154
|
- `OPENAI_API_KEY`, `OPENAI_BASE_URL` (default `https://api.openai.com/v1` — works with OpenRouter, vLLM, Groq, etc.)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: tether-sdk
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: General-purpose, headless-first LLM-Native micro-harness for small language models
|
|
5
5
|
Project-URL: Homepage, https://github.com/igormcsouza/tether
|
|
6
6
|
Project-URL: Repository, https://github.com/igormcsouza/tether
|
|
@@ -282,6 +282,28 @@ codex mcp add tether -- tether mcp --workspace "$PWD" # Codex CLI
|
|
|
282
282
|
|
|
283
283
|
Cursor, Gemini CLI, VS Code and Claude Desktop configs, a "let your harness install it" prompt and a delegation rule for `CLAUDE.md`/`AGENTS.md` are in [Use Tether Inside Your Harness](docs/tutorial/agent-interface.md).
|
|
284
284
|
|
|
285
|
+
### Any model, no code: providers.yaml
|
|
286
|
+
|
|
287
|
+
Declare extra providers in `~/.tether/providers.yaml` (override the path with `TETHER_PROVIDERS`). The entry key is the model-id prefix, so `claude-cli/sonnet` uses the entry `claude-cli`. It works in `tether run`, `tether pipeline`, the SDK and `tether mcp`, and the file is never read from the workspace.
|
|
288
|
+
|
|
289
|
+
Kinds: `openai`, `ollama`, `anthropic` and `command`. The `command` kind wraps any program that takes a prompt and prints JSON (a `claude -p`, `codex exec` or `gemini -p` style command), and runs `argv` directly, never through a shell. Keys are given as env var names, never values.
|
|
290
|
+
|
|
291
|
+
```yaml
|
|
292
|
+
claude-cli:
|
|
293
|
+
kind: command
|
|
294
|
+
argv: [claude, -p, --model, "{model}", --tools, "", --system-prompt, "{system}", --output-format, json, --no-session-persistence, --setting-sources, "", --disable-slash-commands, --strict-mcp-config]
|
|
295
|
+
stdin: "{conversation}"
|
|
296
|
+
reply: result
|
|
297
|
+
usage: {prompt: [usage.input_tokens, usage.cache_creation_input_tokens, usage.cache_read_input_tokens], completion: usage.output_tokens}
|
|
298
|
+
timeout: 180
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
```bash
|
|
302
|
+
tether run --model claude-cli/sonnet "fix the failing test"
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
On one small bug-fix task (v0.2.0 test), this backend used about 15x fewer tokens than plain `claude -p` (21k vs 330k). That is a single small task, not a benchmark. See [Declarative providers](docs/reference/providers.md#declarative-providers).
|
|
306
|
+
|
|
285
307
|
### Python SDK Usage
|
|
286
308
|
|
|
287
309
|
```python
|
|
@@ -261,6 +261,28 @@ codex mcp add tether -- tether mcp --workspace "$PWD" # Codex CLI
|
|
|
261
261
|
|
|
262
262
|
Cursor, Gemini CLI, VS Code and Claude Desktop configs, a "let your harness install it" prompt and a delegation rule for `CLAUDE.md`/`AGENTS.md` are in [Use Tether Inside Your Harness](docs/tutorial/agent-interface.md).
|
|
263
263
|
|
|
264
|
+
### Any model, no code: providers.yaml
|
|
265
|
+
|
|
266
|
+
Declare extra providers in `~/.tether/providers.yaml` (override the path with `TETHER_PROVIDERS`). The entry key is the model-id prefix, so `claude-cli/sonnet` uses the entry `claude-cli`. It works in `tether run`, `tether pipeline`, the SDK and `tether mcp`, and the file is never read from the workspace.
|
|
267
|
+
|
|
268
|
+
Kinds: `openai`, `ollama`, `anthropic` and `command`. The `command` kind wraps any program that takes a prompt and prints JSON (a `claude -p`, `codex exec` or `gemini -p` style command), and runs `argv` directly, never through a shell. Keys are given as env var names, never values.
|
|
269
|
+
|
|
270
|
+
```yaml
|
|
271
|
+
claude-cli:
|
|
272
|
+
kind: command
|
|
273
|
+
argv: [claude, -p, --model, "{model}", --tools, "", --system-prompt, "{system}", --output-format, json, --no-session-persistence, --setting-sources, "", --disable-slash-commands, --strict-mcp-config]
|
|
274
|
+
stdin: "{conversation}"
|
|
275
|
+
reply: result
|
|
276
|
+
usage: {prompt: [usage.input_tokens, usage.cache_creation_input_tokens, usage.cache_read_input_tokens], completion: usage.output_tokens}
|
|
277
|
+
timeout: 180
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
```bash
|
|
281
|
+
tether run --model claude-cli/sonnet "fix the failing test"
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
On one small bug-fix task (v0.2.0 test), this backend used about 15x fewer tokens than plain `claude -p` (21k vs 330k). That is a single small task, not a benchmark. See [Declarative providers](docs/reference/providers.md#declarative-providers).
|
|
285
|
+
|
|
264
286
|
### Python SDK Usage
|
|
265
287
|
|
|
266
288
|
```python
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Model Providers
|
|
2
|
+
|
|
3
|
+
## Structured history
|
|
4
|
+
|
|
5
|
+
The harness passes neutral messages (assistant `tool_calls` with `id` and dict `arguments`, then `{"role": "tool", "tool_call_id": ...}`) to providers whose `structured_history` attribute is true, and each adapter translates: Ollama sends them as-is (plus `tool_name`), OpenAI-compatible JSON-encodes `arguments`, Anthropic maps them to `tool_use` and `tool_result` blocks (merging consecutive same-role messages). A custom provider without the attribute gets the textual `act('...')` / `Observation:` form. Adapter payloads are unit-tested with mock transports; they have not been verified live against OpenAI or Anthropic.
|
|
6
|
+
|
|
7
|
+
## `tether.providers`
|
|
8
|
+
|
|
9
|
+
::: tether.providers
|
|
10
|
+
options:
|
|
11
|
+
show_root_heading: false
|
|
12
|
+
members_order: source
|
|
13
|
+
|
|
14
|
+
## `tether.providers.base`
|
|
15
|
+
|
|
16
|
+
::: tether.providers.base
|
|
17
|
+
options:
|
|
18
|
+
show_root_heading: false
|
|
19
|
+
members_order: source
|
|
20
|
+
|
|
21
|
+
Ollama: an unknown model fails with `Ollama model … not found; installed: …`. Set `TETHER_OLLAMA_FALLBACK=1` (or `OllamaProvider(fallback=True)`) to substitute an installed model instead; a warning is printed to stderr and `tether stats` records the substitute ([#21](https://github.com/igormcsouza/tether/issues/21)).
|
|
22
|
+
|
|
23
|
+
## Declarative providers
|
|
24
|
+
|
|
25
|
+
Add providers without writing code by declaring them in `~/.tether/providers.yaml` (override the path with the `TETHER_PROVIDERS` environment variable). The file is read only from the home directory (or `TETHER_PROVIDERS`), never from the workspace, so a cloned repo cannot plant a provider.
|
|
26
|
+
|
|
27
|
+
The entry key is the model-id prefix: `claude-cli/sonnet` uses the entry named `claude-cli`, and `sonnet` is passed on as the model name. Declared providers work in `tether run`, `tether pipeline`, the Python SDK and `tether mcp`.
|
|
28
|
+
|
|
29
|
+
```yaml
|
|
30
|
+
claude-cli:
|
|
31
|
+
kind: command
|
|
32
|
+
argv: [claude, -p, --model, "{model}", --tools, "", --system-prompt, "{system}", --output-format, json, --no-session-persistence, --setting-sources, "", --disable-slash-commands, --strict-mcp-config]
|
|
33
|
+
stdin: "{conversation}"
|
|
34
|
+
reply: result
|
|
35
|
+
usage: {prompt: [usage.input_tokens, usage.cache_creation_input_tokens, usage.cache_read_input_tokens], completion: usage.output_tokens}
|
|
36
|
+
timeout: 180
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
tether run --model claude-cli/sonnet "fix the failing test"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Kinds
|
|
44
|
+
|
|
45
|
+
| `kind` | Fields |
|
|
46
|
+
|---|---|
|
|
47
|
+
| `openai` | `base_url`, `api_key_env` |
|
|
48
|
+
| `ollama` | `host` |
|
|
49
|
+
| `anthropic` | `base_url`, `api_key_env` |
|
|
50
|
+
| `command` | `argv` (list), `reply` (JSON path), optional `stdin` template, optional `usage`, optional `timeout` (seconds, default 300) |
|
|
51
|
+
|
|
52
|
+
`api_key_env` is the **name** of an environment variable, never the key itself.
|
|
53
|
+
|
|
54
|
+
### The `command` kind
|
|
55
|
+
|
|
56
|
+
A `command` entry wraps any program that takes a prompt and prints JSON.
|
|
57
|
+
|
|
58
|
+
- **Placeholders**: `{model}`, `{system}` and `{conversation}` are substituted in `argv` items and in the `stdin` template. If `stdin` is omitted it defaults to `{conversation}`.
|
|
59
|
+
- **No shell**: `argv` is executed directly, never through a shell.
|
|
60
|
+
- **Reply**: `reply` is a dotted path into the JSON the command prints (`result` in the example).
|
|
61
|
+
- **Usage**: `usage.prompt` is a path or a list of paths (their values are summed); `usage.completion` is a path. Both are optional.
|
|
62
|
+
- **Protocol**: the model must answer with a single JSON `{"cmd", "stdin"}` object. The `{system}` placeholder already contains that instruction.
|
|
63
|
+
|
|
64
|
+
### Rules
|
|
65
|
+
|
|
66
|
+
- The built-in prefixes `ollama`, `openai`, `anthropic` and `claude` cannot be shadowed.
|
|
67
|
+
- An invalid file raises `ValueError: providers.yaml: ...` naming the offending entry.
|
|
68
|
+
|
|
69
|
+
### Measured example
|
|
70
|
+
|
|
71
|
+
On one small bug-fix task (v0.2.0 test), using `claude -p --tools ""` as the backend, as in the entry above, used about 15x fewer tokens than plain `claude -p` (21k vs 330k). This is a single small task, not a benchmark.
|
|
72
|
+
|
|
73
|
+
## `tether.providers.ollama`
|
|
74
|
+
|
|
75
|
+
::: tether.providers.ollama
|
|
76
|
+
options:
|
|
77
|
+
show_root_heading: false
|
|
78
|
+
members_order: source
|
|
79
|
+
|
|
80
|
+
## `tether.providers.openai`
|
|
81
|
+
|
|
82
|
+
::: tether.providers.openai
|
|
83
|
+
options:
|
|
84
|
+
show_root_heading: false
|
|
85
|
+
members_order: source
|
|
86
|
+
|
|
87
|
+
## `tether.providers.anthropic`
|
|
88
|
+
|
|
89
|
+
::: tether.providers.anthropic
|
|
90
|
+
options:
|
|
91
|
+
show_root_heading: false
|
|
92
|
+
members_order: source
|
|
93
|
+
|
|
94
|
+
## `tether.providers.declarative`
|
|
95
|
+
|
|
96
|
+
::: tether.providers.declarative
|
|
97
|
+
options:
|
|
98
|
+
show_root_heading: false
|
|
99
|
+
members_order: source
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Release Notes
|
|
2
2
|
|
|
3
|
+
## v0.2.1 (2026-10-19)
|
|
4
|
+
|
|
5
|
+
Add model backends without code.
|
|
6
|
+
|
|
7
|
+
### Features
|
|
8
|
+
|
|
9
|
+
- Declarative providers (#49): add model backends without code via `~/.tether/providers.yaml` (override with `TETHER_PROVIDERS`, never read from the workspace). Kinds `openai`, `ollama`, `anthropic` and `command` (wrap any CLI that prints JSON, run without a shell). Works in `tether run`, `tether pipeline`, the SDK and `tether mcp`. See [Declarative providers](reference/providers.md#declarative-providers).
|
|
10
|
+
|
|
3
11
|
## v0.2.0 (2026-10-19)
|
|
4
12
|
|
|
5
13
|
Tether as a subagent for the harness you already use (Claude Code, Codex, Cursor, Gemini CLI, Copilot): MCP server, JSON output and exit codes (#46). See [Use Tether Inside Your Harness](tutorial/agent-interface.md).
|
|
@@ -104,6 +104,28 @@ Use `wait: false` for long jobs, then `tether_wait` and `tether_read`.
|
|
|
104
104
|
If status is `WAITING_INPUT`, answer with `tether_send`. Keep architecture and review for yourself.
|
|
105
105
|
```
|
|
106
106
|
|
|
107
|
+
## Any model, no code: providers.yaml
|
|
108
|
+
|
|
109
|
+
Tether can route to any backend you declare in `~/.tether/providers.yaml` (override the path with `TETHER_PROVIDERS`). The entry key is the model-id prefix, so `claude-cli/sonnet` uses the entry `claude-cli`. It works the same in `tether run`, `tether pipeline`, the SDK and `tether mcp`, and it is never read from the workspace.
|
|
110
|
+
|
|
111
|
+
The `command` kind wraps any program that reads a prompt and prints JSON, for example a `claude -p`, `codex exec` or `gemini -p` style command (check each tool's own docs for its flags). Tether runs `argv` directly, never through a shell.
|
|
112
|
+
|
|
113
|
+
```yaml
|
|
114
|
+
claude-cli:
|
|
115
|
+
kind: command
|
|
116
|
+
argv: [claude, -p, --model, "{model}", --tools, "", --system-prompt, "{system}", --output-format, json, --no-session-persistence, --setting-sources, "", --disable-slash-commands, --strict-mcp-config]
|
|
117
|
+
stdin: "{conversation}"
|
|
118
|
+
reply: result
|
|
119
|
+
usage: {prompt: [usage.input_tokens, usage.cache_creation_input_tokens, usage.cache_read_input_tokens], completion: usage.output_tokens}
|
|
120
|
+
timeout: 180
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
tether run --model claude-cli/sonnet "fix the failing test"
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
On one small bug-fix task, this backend used about 15x fewer tokens than plain `claude -p` (21k vs 330k). It is a single small task, not a benchmark. Full reference: [Declarative providers](../reference/providers.md#declarative-providers).
|
|
128
|
+
|
|
107
129
|
## Patterns
|
|
108
130
|
|
|
109
131
|
**Architect / executor.** The frontier model writes the spec and the tests. `tether_run` with `verify: ["pytest -q"]` implements it with a cheap model; the gate runs in the sandbox before `done` is accepted. The parent reads back only status, steps, tokens and the digest.
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from tether.providers import (
|
|
11
|
+
AnthropicProvider,
|
|
12
|
+
OllamaProvider,
|
|
13
|
+
OpenAIProvider,
|
|
14
|
+
resolve_provider,
|
|
15
|
+
)
|
|
16
|
+
from tether.providers.base import ModelResponse
|
|
17
|
+
from tether.providers.declarative import (
|
|
18
|
+
CommandProvider,
|
|
19
|
+
build_declared,
|
|
20
|
+
load_declared,
|
|
21
|
+
providers_path,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _write(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, text: str) -> Path:
|
|
26
|
+
path = tmp_path / "p.yaml"
|
|
27
|
+
path.write_text(text, encoding="utf-8")
|
|
28
|
+
monkeypatch.setenv("TETHER_PROVIDERS", str(path))
|
|
29
|
+
return path
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_providers_path_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
33
|
+
monkeypatch.setenv("TETHER_PROVIDERS", str(tmp_path / "x.yaml"))
|
|
34
|
+
assert providers_path() == tmp_path / "x.yaml"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_providers_path_default(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
38
|
+
monkeypatch.delenv("TETHER_PROVIDERS", raising=False)
|
|
39
|
+
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
40
|
+
assert providers_path() == tmp_path / ".tether" / "providers.yaml"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_missing_file(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
44
|
+
monkeypatch.setenv("TETHER_PROVIDERS", str(tmp_path / "nope.yaml"))
|
|
45
|
+
assert load_declared() == {}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_empty_file(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
49
|
+
_write(monkeypatch, tmp_path, "")
|
|
50
|
+
assert load_declared() == {}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_top_level_not_mapping(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
54
|
+
_write(monkeypatch, tmp_path, "- a\n- b\n")
|
|
55
|
+
with pytest.raises(ValueError, match="providers.yaml"):
|
|
56
|
+
load_declared()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_entry_not_mapping(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
60
|
+
_write(monkeypatch, tmp_path, "foo: bar\n")
|
|
61
|
+
with pytest.raises(ValueError, match="providers.yaml"):
|
|
62
|
+
load_declared()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_command_kind_minimal_loads(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
66
|
+
_write(monkeypatch, tmp_path, "foo:\n kind: command\n argv: [x]\n reply: r\n")
|
|
67
|
+
assert load_declared()["foo"]["kind"] == "command"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_unknown_kind(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
71
|
+
_write(monkeypatch, tmp_path, "foo:\n kind: bogus\n")
|
|
72
|
+
with pytest.raises(ValueError, match="openai, ollama, anthropic"):
|
|
73
|
+
load_declared()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_missing_kind(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
77
|
+
_write(monkeypatch, tmp_path, "foo:\n base_url: http://x\n")
|
|
78
|
+
with pytest.raises(ValueError, match="unknown kind"):
|
|
79
|
+
load_declared()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@pytest.mark.parametrize("name", ["ollama", "openai", "anthropic"])
|
|
83
|
+
def test_cannot_shadow_builtins(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, name: str) -> None:
|
|
84
|
+
_write(monkeypatch, tmp_path, f"{name}:\n kind: openai\n")
|
|
85
|
+
with pytest.raises(ValueError, match="shadow"):
|
|
86
|
+
load_declared()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_build_openai_with_api_key_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
90
|
+
monkeypatch.setenv("MY_KEY", "secret")
|
|
91
|
+
cfg = {"kind": "openai", "base_url": "http://x/v1", "api_key_env": "MY_KEY"}
|
|
92
|
+
provider = build_declared(cfg, "m", num_ctx=1024)
|
|
93
|
+
assert isinstance(provider, OpenAIProvider)
|
|
94
|
+
assert provider.api_key == "secret"
|
|
95
|
+
assert provider.base_url.startswith("http://x/v1")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_build_ollama(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
99
|
+
cfg = {"kind": "ollama", "host": "http://h:11434"}
|
|
100
|
+
provider = build_declared(cfg, "m", num_ctx=1024)
|
|
101
|
+
assert isinstance(provider, OllamaProvider)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_build_anthropic(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
105
|
+
monkeypatch.setenv("A_KEY", "ak")
|
|
106
|
+
cfg = {"kind": "anthropic", "api_key_env": "A_KEY", "base_url": "http://a"}
|
|
107
|
+
provider = build_declared(cfg, "m", num_ctx=1024)
|
|
108
|
+
assert isinstance(provider, AnthropicProvider)
|
|
109
|
+
assert provider.api_key == "ak"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_resolve_provider_declared(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
113
|
+
_write(
|
|
114
|
+
monkeypatch,
|
|
115
|
+
tmp_path,
|
|
116
|
+
"mygroq:\n kind: openai\n base_url: http://groq.local/v1\n",
|
|
117
|
+
)
|
|
118
|
+
provider = resolve_provider("mygroq/llama")
|
|
119
|
+
assert isinstance(provider, OpenAIProvider)
|
|
120
|
+
assert provider.model_name == "llama"
|
|
121
|
+
assert provider.base_url.startswith("http://groq.local/v1")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_resolve_provider_unknown_still_raises(
|
|
125
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
126
|
+
) -> None:
|
|
127
|
+
_write(monkeypatch, tmp_path, "mygroq:\n kind: openai\n")
|
|
128
|
+
with pytest.raises(ValueError, match="Unknown model provider"):
|
|
129
|
+
resolve_provider("other/llama")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _script(code: str, *extra: str) -> list[str]:
|
|
133
|
+
return [sys.executable, "-c", code, *extra]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _run(provider: CommandProvider, messages: list[dict[str, str]] | None = None) -> ModelResponse:
|
|
137
|
+
msgs: list[dict[str, str]] = messages or [{"role": "user", "content": "hi"}]
|
|
138
|
+
return asyncio.run(provider.generate(msgs, [])) # type: ignore[arg-type]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_claude_cannot_be_shadowed(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
142
|
+
_write(monkeypatch, tmp_path, "claude:\n kind: openai\n")
|
|
143
|
+
with pytest.raises(ValueError, match="cannot shadow"):
|
|
144
|
+
load_declared()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_command_success_with_usage() -> None:
|
|
148
|
+
code = (
|
|
149
|
+
"import json;"
|
|
150
|
+
"print(json.dumps({'r': json.dumps({'cmd': 'ls'}),"
|
|
151
|
+
" 'u': {'a': 1, 'b': 2, 'c': 5, 'bad': True}}))"
|
|
152
|
+
)
|
|
153
|
+
provider = CommandProvider(
|
|
154
|
+
"m",
|
|
155
|
+
argv=_script(code),
|
|
156
|
+
reply="r",
|
|
157
|
+
usage={"prompt": ["u.a", "u.b", "u.bad", "u.none"], "completion": "u.c"},
|
|
158
|
+
)
|
|
159
|
+
assert provider.structured_history is False
|
|
160
|
+
resp = _run(provider)
|
|
161
|
+
assert resp.action is not None
|
|
162
|
+
assert resp.action.cmd == "ls"
|
|
163
|
+
assert resp.prompt_tokens == 3
|
|
164
|
+
assert resp.completion_tokens == 5
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_command_usage_prompt_string_and_no_completion() -> None:
|
|
168
|
+
code = "import json; print(json.dumps({'r': 'plain', 'n': 7}))"
|
|
169
|
+
provider = CommandProvider("m", argv=_script(code), reply="r", usage={"prompt": "n"})
|
|
170
|
+
resp = _run(provider)
|
|
171
|
+
assert resp.content == "plain"
|
|
172
|
+
assert resp.action is None
|
|
173
|
+
assert resp.prompt_tokens == 7
|
|
174
|
+
assert resp.completion_tokens == 0
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_command_xml_reply() -> None:
|
|
178
|
+
text = '<parameter name="cmd">echo hi</parameter><parameter name="stdin">body</parameter>'
|
|
179
|
+
code = f"import json; print(json.dumps({{'r': {text!r}}}))"
|
|
180
|
+
resp = _run(CommandProvider("m", argv=_script(code), reply="r"))
|
|
181
|
+
assert resp.action is not None
|
|
182
|
+
assert resp.action.cmd == "echo hi"
|
|
183
|
+
assert resp.action.stdin == "body"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_command_xml_reply_without_stdin() -> None:
|
|
187
|
+
text = '<parameter name="cmd">pwd</parameter>'
|
|
188
|
+
code = f"import json; print(json.dumps({{'r': {text!r}}}))"
|
|
189
|
+
resp = _run(CommandProvider("m", argv=_script(code), reply="r"))
|
|
190
|
+
assert resp.action is not None
|
|
191
|
+
assert resp.action.cmd == "pwd"
|
|
192
|
+
assert resp.action.stdin is None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def test_command_missing_reply_path() -> None:
|
|
196
|
+
code = "import json; print(json.dumps({'x': 1}))"
|
|
197
|
+
resp = _run(CommandProvider("m", argv=_script(code), reply="a.b"))
|
|
198
|
+
assert resp.content == ""
|
|
199
|
+
assert resp.action is None
|
|
200
|
+
assert resp.prompt_tokens == 0
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def test_command_nonzero_exit() -> None:
|
|
204
|
+
code = "import sys; sys.stderr.write('boom'); sys.exit(3)"
|
|
205
|
+
with pytest.raises(RuntimeError, match="boom"):
|
|
206
|
+
_run(CommandProvider("m", argv=_script(code), reply="r"))
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_command_invalid_json() -> None:
|
|
210
|
+
code = "print('not json')"
|
|
211
|
+
with pytest.raises(RuntimeError, match="invalid JSON: not json"):
|
|
212
|
+
_run(CommandProvider("m", argv=_script(code), reply="r"))
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def test_command_timeout() -> None:
|
|
216
|
+
code = "import time; time.sleep(5)"
|
|
217
|
+
with pytest.raises(RuntimeError, match="timed out"):
|
|
218
|
+
_run(CommandProvider("m", argv=_script(code), reply="r", timeout=0.2))
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
_ECHO = (
|
|
222
|
+
"import sys, json;"
|
|
223
|
+
"print(json.dumps({'r': json.dumps({'model': sys.argv[1], 'system': sys.argv[2],"
|
|
224
|
+
" 'stdin': sys.stdin.read()})}))"
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def test_command_placeholders_default_stdin() -> None:
|
|
229
|
+
provider = CommandProvider("mymodel", argv=_script(_ECHO, "{model}", "{system}"), reply="r")
|
|
230
|
+
resp = _run(
|
|
231
|
+
provider,
|
|
232
|
+
[
|
|
233
|
+
{"role": "system", "content": "S1"},
|
|
234
|
+
{"role": "system", "content": "S2"},
|
|
235
|
+
{"role": "user", "content": "hello {model}"},
|
|
236
|
+
{"role": "assistant", "content": "yo"},
|
|
237
|
+
],
|
|
238
|
+
)
|
|
239
|
+
data = json.loads(resp.content)
|
|
240
|
+
assert data["model"] == "mymodel"
|
|
241
|
+
assert data["system"].startswith("S1\nS2\n\nReply with ONLY one JSON object")
|
|
242
|
+
assert data["system"].endswith('"<optional multiline payload or null>"}')
|
|
243
|
+
assert data["stdin"] == "[user]\nhello {model}\n\n[assistant]\nyo"
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def test_command_placeholders_custom_stdin() -> None:
|
|
247
|
+
provider = CommandProvider(
|
|
248
|
+
"mm",
|
|
249
|
+
argv=_script(_ECHO, "x", "y"),
|
|
250
|
+
reply="r",
|
|
251
|
+
stdin="{model}|{system}|{conversation}",
|
|
252
|
+
)
|
|
253
|
+
resp = _run(provider, [{"role": "system", "content": "S"}, {"role": "user", "content": "u"}])
|
|
254
|
+
stdin = json.loads(resp.content)["stdin"]
|
|
255
|
+
assert stdin.startswith("mm|S\n\nReply with ONLY")
|
|
256
|
+
assert stdin.endswith("|[user]\nu")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def test_build_command_and_load(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
260
|
+
_write(
|
|
261
|
+
monkeypatch,
|
|
262
|
+
tmp_path,
|
|
263
|
+
"cli:\n kind: command\n argv: [echo, '{model}']\n reply: out.text\n"
|
|
264
|
+
" stdin: '{conversation}'\n timeout: 5\n"
|
|
265
|
+
" usage:\n prompt: [u.a, u.b]\n completion: u.c\n",
|
|
266
|
+
)
|
|
267
|
+
cfg = load_declared()["cli"]
|
|
268
|
+
provider = build_declared(cfg, "m", num_ctx=1024)
|
|
269
|
+
assert isinstance(provider, CommandProvider)
|
|
270
|
+
assert provider.timeout == 5
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def test_build_command_defaults() -> None:
|
|
274
|
+
provider = build_declared({"kind": "command", "argv": ["x"], "reply": "r"}, "m", num_ctx=1024)
|
|
275
|
+
assert isinstance(provider, CommandProvider)
|
|
276
|
+
assert provider.timeout == 300
|
|
277
|
+
assert provider.stdin == "{conversation}"
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@pytest.mark.parametrize(
|
|
281
|
+
("body", "message"),
|
|
282
|
+
[
|
|
283
|
+
("reply: r\n", "requires `argv`"),
|
|
284
|
+
("argv: []\n reply: r\n", "requires `argv`"),
|
|
285
|
+
("argv: [1]\n reply: r\n", "requires `argv`"),
|
|
286
|
+
("argv: [x]\n", "requires `reply`"),
|
|
287
|
+
("argv: [x]\n reply: 3\n", "requires `reply`"),
|
|
288
|
+
("argv: [x]\n reply: r\n stdin: 3\n", "invalid `stdin`"),
|
|
289
|
+
("argv: [x]\n reply: r\n timeout: abc\n", "invalid `timeout`"),
|
|
290
|
+
("argv: [x]\n reply: r\n timeout: true\n", "invalid `timeout`"),
|
|
291
|
+
("argv: [x]\n reply: r\n timeout: -1\n", "invalid `timeout`"),
|
|
292
|
+
("argv: [x]\n reply: r\n usage: 3\n", "invalid `usage`"),
|
|
293
|
+
("argv: [x]\n reply: r\n usage:\n prompt: 3\n", "invalid `usage.prompt`"),
|
|
294
|
+
("argv: [x]\n reply: r\n usage:\n completion: [a]\n", "invalid `usage.completion`"),
|
|
295
|
+
],
|
|
296
|
+
)
|
|
297
|
+
def test_command_validation_errors(
|
|
298
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, body: str, message: str
|
|
299
|
+
) -> None:
|
|
300
|
+
_write(monkeypatch, tmp_path, f"cli:\n kind: command\n {body}")
|
|
301
|
+
with pytest.raises(ValueError, match=f"providers.yaml: entry `cli`.*{message}"):
|
|
302
|
+
load_declared()
|
|
@@ -7,6 +7,6 @@ import tether
|
|
|
7
7
|
|
|
8
8
|
def test_version_and_public_exports() -> None:
|
|
9
9
|
"""Verify package __version__ and public SDK symbols."""
|
|
10
|
-
assert tether.__version__ == "0.2.
|
|
10
|
+
assert tether.__version__ == "0.2.1"
|
|
11
11
|
for symbol in tether.__all__:
|
|
12
12
|
assert hasattr(tether, symbol)
|
|
@@ -13,6 +13,7 @@ from tether.providers.base import (
|
|
|
13
13
|
extract_fallback_action,
|
|
14
14
|
parse_tool_arguments,
|
|
15
15
|
)
|
|
16
|
+
from tether.providers.declarative import build_declared, load_declared
|
|
16
17
|
from tether.providers.ollama import OllamaProvider
|
|
17
18
|
from tether.providers.openai import OpenAIProvider
|
|
18
19
|
|
|
@@ -45,6 +46,9 @@ def resolve_provider(model: str, *, num_ctx: int = 8192) -> ModelProvider:
|
|
|
45
46
|
return OpenAIProvider(model_name=name)
|
|
46
47
|
if provider in ("anthropic", "claude"):
|
|
47
48
|
return AnthropicProvider(model_name=name)
|
|
49
|
+
declared = load_declared()
|
|
50
|
+
if provider in declared:
|
|
51
|
+
return build_declared(declared[provider], name, num_ctx=num_ctx)
|
|
48
52
|
supported = ["ollama/", "openai/", "anthropic/", *[f"{k}/" for k in _CUSTOM_PROVIDERS]]
|
|
49
53
|
raise ValueError(
|
|
50
54
|
f"Unknown model provider `{prefix}` in `{model}`. "
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Declarative providers loaded from ~/.tether/providers.yaml."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
|
|
14
|
+
from tether.providers.anthropic import AnthropicProvider
|
|
15
|
+
from tether.providers.base import ActionCall, ModelProvider, ModelResponse, extract_fallback_action
|
|
16
|
+
from tether.providers.ollama import OllamaProvider
|
|
17
|
+
from tether.providers.openai import OpenAIProvider
|
|
18
|
+
|
|
19
|
+
VALID_KINDS = ("openai", "ollama", "anthropic", "command")
|
|
20
|
+
BUILTIN_PREFIXES = ("ollama", "openai", "anthropic", "claude")
|
|
21
|
+
|
|
22
|
+
_SYSTEM_SUFFIX = (
|
|
23
|
+
"\n\nReply with ONLY one JSON object, no prose, no code fence: "
|
|
24
|
+
'{"cmd": "<command>", "stdin": "<optional multiline payload or null>"}'
|
|
25
|
+
)
|
|
26
|
+
_XML_CMD_RE = re.compile(r'<parameter name="cmd">(?P<v>.*?)</parameter>', re.DOTALL)
|
|
27
|
+
_XML_STDIN_RE = re.compile(r'<parameter name="stdin">(?P<v>.*?)</parameter>', re.DOTALL)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def providers_path() -> Path:
|
|
31
|
+
"""Return the providers file path (TETHER_PROVIDERS overrides the default)."""
|
|
32
|
+
env = os.environ.get("TETHER_PROVIDERS")
|
|
33
|
+
if env:
|
|
34
|
+
return Path(env)
|
|
35
|
+
return Path.home() / ".tether" / "providers.yaml"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _bad(name: Any, problem: str) -> ValueError:
|
|
39
|
+
return ValueError(f"providers.yaml: entry `{name}` {problem}")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _is_str_list(value: Any) -> bool:
|
|
43
|
+
return isinstance(value, list) and bool(value) and all(isinstance(v, str) for v in value)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _validate_command(name: Any, cfg: dict[str, Any]) -> None:
|
|
47
|
+
if not _is_str_list(cfg.get("argv")):
|
|
48
|
+
raise _bad(name, "kind `command` requires `argv` (non-empty list of strings)")
|
|
49
|
+
reply = cfg.get("reply")
|
|
50
|
+
if not isinstance(reply, str) or not reply:
|
|
51
|
+
raise _bad(name, "kind `command` requires `reply` (dotted JSON path string)")
|
|
52
|
+
stdin = cfg.get("stdin")
|
|
53
|
+
if stdin is not None and not isinstance(stdin, str):
|
|
54
|
+
raise _bad(name, "has invalid `stdin` (must be a string template)")
|
|
55
|
+
timeout = cfg.get("timeout")
|
|
56
|
+
if timeout is not None and (
|
|
57
|
+
isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or timeout <= 0
|
|
58
|
+
):
|
|
59
|
+
raise _bad(name, "has invalid `timeout` (must be a positive number)")
|
|
60
|
+
usage = cfg.get("usage")
|
|
61
|
+
if usage is None:
|
|
62
|
+
return
|
|
63
|
+
if not isinstance(usage, dict):
|
|
64
|
+
raise _bad(name, "has invalid `usage` (must be a mapping)")
|
|
65
|
+
prompt = usage.get("prompt")
|
|
66
|
+
if prompt is not None and not (isinstance(prompt, str) or _is_str_list(prompt)):
|
|
67
|
+
raise _bad(name, "has invalid `usage.prompt` (path string or list of path strings)")
|
|
68
|
+
completion = usage.get("completion")
|
|
69
|
+
if completion is not None and not isinstance(completion, str):
|
|
70
|
+
raise _bad(name, "has invalid `usage.completion` (path string)")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def load_declared() -> dict[str, dict[str, Any]]:
|
|
74
|
+
"""Load and validate declared providers; returns {} if the file is missing."""
|
|
75
|
+
path = providers_path()
|
|
76
|
+
if not path.exists():
|
|
77
|
+
return {}
|
|
78
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
79
|
+
if data is None:
|
|
80
|
+
return {}
|
|
81
|
+
if not isinstance(data, dict):
|
|
82
|
+
raise ValueError("providers.yaml: top level must be a mapping of name -> mapping")
|
|
83
|
+
result: dict[str, dict[str, Any]] = {}
|
|
84
|
+
for name, cfg in data.items():
|
|
85
|
+
if not isinstance(cfg, dict):
|
|
86
|
+
raise ValueError(f"providers.yaml: entry `{name}` must be a mapping")
|
|
87
|
+
if str(name).lower() in BUILTIN_PREFIXES:
|
|
88
|
+
raise ValueError(f"providers.yaml: `{name}` cannot shadow a built-in provider")
|
|
89
|
+
kind = cfg.get("kind")
|
|
90
|
+
if kind not in VALID_KINDS:
|
|
91
|
+
raise ValueError(
|
|
92
|
+
f"providers.yaml: entry `{name}` has unknown kind `{kind}`. "
|
|
93
|
+
f"Valid kinds: {', '.join(VALID_KINDS)}."
|
|
94
|
+
)
|
|
95
|
+
if kind == "command":
|
|
96
|
+
_validate_command(name, cfg)
|
|
97
|
+
result[str(name)] = cfg
|
|
98
|
+
return result
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _lookup(data: Any, path: str) -> Any:
|
|
102
|
+
cur = data
|
|
103
|
+
for part in path.split("."):
|
|
104
|
+
if not isinstance(cur, dict) or part not in cur:
|
|
105
|
+
return None
|
|
106
|
+
cur = cur[part]
|
|
107
|
+
return cur
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _as_int(value: Any) -> int:
|
|
111
|
+
return value if isinstance(value, int) and not isinstance(value, bool) else 0
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class CommandProvider:
|
|
115
|
+
"""Provider that shells out to an external command (argv, never a shell)."""
|
|
116
|
+
|
|
117
|
+
structured_history = False
|
|
118
|
+
|
|
119
|
+
def __init__(
|
|
120
|
+
self,
|
|
121
|
+
model_name: str,
|
|
122
|
+
*,
|
|
123
|
+
argv: list[str],
|
|
124
|
+
reply: str,
|
|
125
|
+
stdin: str | None = None,
|
|
126
|
+
usage: dict[str, Any] | None = None,
|
|
127
|
+
timeout: float = 300,
|
|
128
|
+
) -> None:
|
|
129
|
+
self.model_name = model_name
|
|
130
|
+
self.argv = argv
|
|
131
|
+
self.reply = reply
|
|
132
|
+
self.stdin = stdin if stdin is not None else "{conversation}"
|
|
133
|
+
self.usage = usage or {}
|
|
134
|
+
self.timeout = timeout
|
|
135
|
+
|
|
136
|
+
def _render(self, template: str, system: str, conversation: str) -> str:
|
|
137
|
+
return (
|
|
138
|
+
template.replace("{model}", self.model_name)
|
|
139
|
+
.replace("{system}", system)
|
|
140
|
+
.replace("{conversation}", conversation)
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
async def generate(
|
|
144
|
+
self,
|
|
145
|
+
messages: list[dict[str, Any]],
|
|
146
|
+
tools: list[dict[str, Any]],
|
|
147
|
+
) -> ModelResponse:
|
|
148
|
+
system = (
|
|
149
|
+
"\n".join(str(m.get("content", "")) for m in messages if m.get("role") == "system")
|
|
150
|
+
+ _SYSTEM_SUFFIX
|
|
151
|
+
)
|
|
152
|
+
conversation = "\n\n".join(
|
|
153
|
+
f"[{m.get('role', '')}]\n{m.get('content', '')}"
|
|
154
|
+
for m in messages
|
|
155
|
+
if m.get("role") != "system"
|
|
156
|
+
)
|
|
157
|
+
argv = [self._render(a, system, conversation) for a in self.argv]
|
|
158
|
+
stdin = self._render(self.stdin, system, conversation)
|
|
159
|
+
proc = await asyncio.create_subprocess_exec(
|
|
160
|
+
*argv,
|
|
161
|
+
stdin=asyncio.subprocess.PIPE,
|
|
162
|
+
stdout=asyncio.subprocess.PIPE,
|
|
163
|
+
stderr=asyncio.subprocess.PIPE,
|
|
164
|
+
)
|
|
165
|
+
try:
|
|
166
|
+
out, err = await asyncio.wait_for(
|
|
167
|
+
proc.communicate(stdin.encode("utf-8")), timeout=self.timeout
|
|
168
|
+
)
|
|
169
|
+
except TimeoutError:
|
|
170
|
+
proc.kill()
|
|
171
|
+
await proc.wait()
|
|
172
|
+
raise RuntimeError(
|
|
173
|
+
f"command provider `{self.model_name}` timed out after {self.timeout}s"
|
|
174
|
+
) from None
|
|
175
|
+
out_text = out.decode("utf-8", errors="replace")
|
|
176
|
+
err_text = err.decode("utf-8", errors="replace")
|
|
177
|
+
if proc.returncode != 0:
|
|
178
|
+
raise RuntimeError(
|
|
179
|
+
f"command provider `{self.model_name}` exited with {proc.returncode}: "
|
|
180
|
+
f"{(err_text or out_text)[:300]}"
|
|
181
|
+
)
|
|
182
|
+
try:
|
|
183
|
+
payload = json.loads(out_text)
|
|
184
|
+
except json.JSONDecodeError:
|
|
185
|
+
raise RuntimeError(
|
|
186
|
+
f"command provider `{self.model_name}` returned invalid JSON: "
|
|
187
|
+
f"{(out_text or err_text)[:300]}"
|
|
188
|
+
) from None
|
|
189
|
+
value = _lookup(payload, self.reply)
|
|
190
|
+
text = value if isinstance(value, str) else ""
|
|
191
|
+
action = extract_fallback_action(text)
|
|
192
|
+
if action is None:
|
|
193
|
+
cmd_match = _XML_CMD_RE.search(text)
|
|
194
|
+
if cmd_match:
|
|
195
|
+
stdin_match = _XML_STDIN_RE.search(text)
|
|
196
|
+
action = ActionCall(
|
|
197
|
+
cmd=cmd_match.group("v").strip(),
|
|
198
|
+
stdin=stdin_match.group("v") if stdin_match else None,
|
|
199
|
+
)
|
|
200
|
+
prompt_paths = self.usage.get("prompt") or []
|
|
201
|
+
if isinstance(prompt_paths, str):
|
|
202
|
+
prompt_paths = [prompt_paths]
|
|
203
|
+
completion_path = self.usage.get("completion")
|
|
204
|
+
return ModelResponse(
|
|
205
|
+
content=text,
|
|
206
|
+
action=action,
|
|
207
|
+
prompt_tokens=sum(_as_int(_lookup(payload, p)) for p in prompt_paths),
|
|
208
|
+
completion_tokens=_as_int(_lookup(payload, completion_path)) if completion_path else 0,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def build_declared(cfg: dict[str, Any], model_name: str, *, num_ctx: int) -> ModelProvider:
|
|
213
|
+
"""Build a provider instance from a declared config entry."""
|
|
214
|
+
kind = cfg["kind"]
|
|
215
|
+
if kind == "command":
|
|
216
|
+
return CommandProvider(
|
|
217
|
+
model_name,
|
|
218
|
+
argv=cfg["argv"],
|
|
219
|
+
reply=cfg["reply"],
|
|
220
|
+
stdin=cfg.get("stdin"),
|
|
221
|
+
usage=cfg.get("usage"),
|
|
222
|
+
timeout=cfg.get("timeout", 300),
|
|
223
|
+
)
|
|
224
|
+
api_key_env = cfg.get("api_key_env")
|
|
225
|
+
api_key = os.environ.get(api_key_env) if api_key_env else None
|
|
226
|
+
if kind == "openai":
|
|
227
|
+
return OpenAIProvider(model_name, api_key=api_key, base_url=cfg.get("base_url"))
|
|
228
|
+
if kind == "ollama":
|
|
229
|
+
return OllamaProvider(model_name, host=cfg.get("host"), num_ctx=num_ctx)
|
|
230
|
+
return AnthropicProvider(model_name, api_key=api_key, base_url=cfg.get("base_url"))
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
# Model Providers
|
|
2
|
-
|
|
3
|
-
## Structured history
|
|
4
|
-
|
|
5
|
-
The harness passes neutral messages (assistant `tool_calls` with `id` and dict `arguments`, then `{"role": "tool", "tool_call_id": ...}`) to providers whose `structured_history` attribute is true, and each adapter translates: Ollama sends them as-is (plus `tool_name`), OpenAI-compatible JSON-encodes `arguments`, Anthropic maps them to `tool_use` and `tool_result` blocks (merging consecutive same-role messages). A custom provider without the attribute gets the textual `act('...')` / `Observation:` form. Adapter payloads are unit-tested with mock transports; they have not been verified live against OpenAI or Anthropic.
|
|
6
|
-
|
|
7
|
-
## `tether.providers`
|
|
8
|
-
|
|
9
|
-
::: tether.providers
|
|
10
|
-
options:
|
|
11
|
-
show_root_heading: false
|
|
12
|
-
members_order: source
|
|
13
|
-
|
|
14
|
-
## `tether.providers.base`
|
|
15
|
-
|
|
16
|
-
::: tether.providers.base
|
|
17
|
-
options:
|
|
18
|
-
show_root_heading: false
|
|
19
|
-
members_order: source
|
|
20
|
-
|
|
21
|
-
Ollama: an unknown model fails with `Ollama model … not found; installed: …`. Set `TETHER_OLLAMA_FALLBACK=1` (or `OllamaProvider(fallback=True)`) to substitute an installed model instead; a warning is printed to stderr and `tether stats` records the substitute ([#21](https://github.com/igormcsouza/tether/issues/21)).
|
|
22
|
-
|
|
23
|
-
## `tether.providers.ollama`
|
|
24
|
-
|
|
25
|
-
::: tether.providers.ollama
|
|
26
|
-
options:
|
|
27
|
-
show_root_heading: false
|
|
28
|
-
members_order: source
|
|
29
|
-
|
|
30
|
-
## `tether.providers.openai`
|
|
31
|
-
|
|
32
|
-
::: tether.providers.openai
|
|
33
|
-
options:
|
|
34
|
-
show_root_heading: false
|
|
35
|
-
members_order: source
|
|
36
|
-
|
|
37
|
-
## `tether.providers.anthropic`
|
|
38
|
-
|
|
39
|
-
::: tether.providers.anthropic
|
|
40
|
-
options:
|
|
41
|
-
show_root_heading: false
|
|
42
|
-
members_order: source
|
|
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
|