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.
Files changed (105) hide show
  1. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/AGENTS.md +1 -0
  2. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/PKG-INFO +23 -1
  3. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/README.md +22 -0
  4. tether_sdk-0.2.1/docs/reference/providers.md +99 -0
  5. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/docs/release-notes.md +8 -0
  6. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/docs/tutorial/agent-interface.md +22 -0
  7. tether_sdk-0.2.1/tests/providers/test_declarative.py +302 -0
  8. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/test_tether.py +1 -1
  9. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/__init__.py +1 -1
  10. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/providers/__init__.py +4 -0
  11. tether_sdk-0.2.1/tether/providers/declarative.py +230 -0
  12. tether_sdk-0.2.0/docs/reference/providers.md +0 -42
  13. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/.github/workflows/ci.yml +0 -0
  14. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/.github/workflows/delivery.yml +0 -0
  15. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/.github/workflows/docs.yml +0 -0
  16. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/.gitignore +0 -0
  17. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/LICENSE +0 -0
  18. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/docs/architecture.md +0 -0
  19. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/docs/benchmarks.md +0 -0
  20. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/docs/extending.md +0 -0
  21. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/docs/index.md +0 -0
  22. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/docs/javascripts/mathjax.js +0 -0
  23. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/docs/reference/agent-interface.md +0 -0
  24. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/docs/reference/context-and-store.md +0 -0
  25. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/docs/reference/harness.md +0 -0
  26. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/docs/reference/index.md +0 -0
  27. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/docs/reference/multiplexer-and-decorator.md +0 -0
  28. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/docs/reference/sandbox-and-skills.md +0 -0
  29. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/docs/reference/tools.md +0 -0
  30. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/docs/releasing.md +0 -0
  31. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/docs/troubleshooting.md +0 -0
  32. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/docs/tutorial/built-in-tools.md +0 -0
  33. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/docs/tutorial/cli-and-headless.md +0 -0
  34. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/docs/tutorial/index.md +0 -0
  35. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/docs/tutorial/multi-agent.md +0 -0
  36. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/docs/tutorial/sdk-and-custom-tools.md +0 -0
  37. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/docs/tutorial/security-and-sandbox.md +0 -0
  38. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/docs/tutorial/skills.md +0 -0
  39. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/docs/tutorial/workflows-and-telemetry.md +0 -0
  40. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/examples/course/README.md +0 -0
  41. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/examples/course/checks.py +0 -0
  42. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/examples/course/course.yaml +0 -0
  43. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/examples/course/ruff.toml +0 -0
  44. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/examples/course/skills/planner/SKILL.md +0 -0
  45. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/examples/course/skills/searcher/SKILL.md +0 -0
  46. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/examples/course/skills/searcher/tools.py +0 -0
  47. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/examples/course/skills/writer/SKILL.md +0 -0
  48. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/examples/course/skills/writer/tools.py +0 -0
  49. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/mkdocs.yml +0 -0
  50. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/pyproject.toml +0 -0
  51. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/skills/researcher/SKILL.md +0 -0
  52. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/__init__.py +0 -0
  53. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/conftest.py +0 -0
  54. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/providers/__init__.py +0 -0
  55. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/providers/test_anthropic.py +0 -0
  56. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/providers/test_base.py +0 -0
  57. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/providers/test_ollama.py +0 -0
  58. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/providers/test_openai.py +0 -0
  59. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/test_agent_interface.py +0 -0
  60. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/test_cli.py +0 -0
  61. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/test_context.py +0 -0
  62. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/test_decorator.py +0 -0
  63. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/test_harness.py +0 -0
  64. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/test_integration.py +0 -0
  65. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/test_multiplexer.py +0 -0
  66. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/test_pipeline.py +0 -0
  67. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/test_release_hardening.py +0 -0
  68. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/test_retro.py +0 -0
  69. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/test_sandbox.py +0 -0
  70. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/test_security_hardening.py +0 -0
  71. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/test_skills.py +0 -0
  72. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/test_store.py +0 -0
  73. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/test_structured_history.py +0 -0
  74. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/tools/__init__.py +0 -0
  75. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/tools/test_agent.py +0 -0
  76. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/tools/test_bash.py +0 -0
  77. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/tools/test_done.py +0 -0
  78. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/tools/test_edit.py +0 -0
  79. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/tools/test_mem.py +0 -0
  80. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tests/tools/test_web.py +0 -0
  81. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/api.py +0 -0
  82. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/cli.py +0 -0
  83. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/context.py +0 -0
  84. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/decorator.py +0 -0
  85. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/harness.py +0 -0
  86. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/mcp.py +0 -0
  87. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/multiplexer.py +0 -0
  88. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/pipeline.py +0 -0
  89. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/providers/anthropic.py +0 -0
  90. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/providers/base.py +0 -0
  91. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/providers/ollama.py +0 -0
  92. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/providers/openai.py +0 -0
  93. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/retro.py +0 -0
  94. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/sandbox.py +0 -0
  95. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/schema.py +0 -0
  96. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/skills.py +0 -0
  97. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/store.py +0 -0
  98. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/tools/__init__.py +0 -0
  99. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/tools/agent.py +0 -0
  100. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/tools/bash.py +0 -0
  101. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/tools/done.py +0 -0
  102. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/tools/edit.py +0 -0
  103. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/tools/mem.py +0 -0
  104. {tether_sdk-0.2.0 → tether_sdk-0.2.1}/tether/tools/web.py +0 -0
  105. {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.0
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.0"
10
+ assert tether.__version__ == "0.2.1"
11
11
  for symbol in tether.__all__:
12
12
  assert hasattr(tether, symbol)
@@ -30,7 +30,7 @@ from tether.store import (
30
30
  ToolOffender,
31
31
  )
32
32
 
33
- __version__ = "0.2.0"
33
+ __version__ = "0.2.1"
34
34
 
35
35
  __all__ = [
36
36
  "ActionCall",
@@ -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