handoffkit 1.3.0__tar.gz → 1.4.0__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.
- {handoffkit-1.3.0/handoffkit.egg-info → handoffkit-1.4.0}/PKG-INFO +51 -1
- {handoffkit-1.3.0 → handoffkit-1.4.0}/README.md +50 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/examples/mai_clinical_mimo_benchmark.py +8 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/examples/opencode_go_agent.py +21 -5
- {handoffkit-1.3.0 → handoffkit-1.4.0}/examples/opencode_zen_agent.py +21 -5
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit/__init__.py +1 -1
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit/cli.py +149 -1
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit/providers/__init__.py +20 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit/providers/opencode_provider.py +83 -16
- handoffkit-1.4.0/handoffkit/providers/registry.py +316 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0/handoffkit.egg-info}/PKG-INFO +51 -1
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit.egg-info/SOURCES.txt +2 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/pyproject.toml +1 -1
- {handoffkit-1.3.0 → handoffkit-1.4.0}/tests/test_api_stability.py +2 -2
- {handoffkit-1.3.0 → handoffkit-1.4.0}/tests/test_cli.py +40 -1
- {handoffkit-1.3.0 → handoffkit-1.4.0}/tests/test_clinical_benchmark_audit.py +23 -1
- {handoffkit-1.3.0 → handoffkit-1.4.0}/tests/test_opencode_provider_mock.py +71 -0
- handoffkit-1.4.0/tests/test_provider_registry.py +135 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/tests/test_stable_metadata.py +2 -2
- {handoffkit-1.3.0 → handoffkit-1.4.0}/tests/test_wheel_smoke.py +1 -1
- {handoffkit-1.3.0 → handoffkit-1.4.0}/LICENSE +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/MANIFEST.in +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/examples/async_demo.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/examples/coding_review.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/examples/coding_review_recipe.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/examples/coding_team.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/examples/context_handoff_demo.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/examples/contract_validation_demo.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/examples/doctor_benchmark.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/examples/doctor_orchestrator.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/examples/evaluation_demo.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/examples/extension_demo.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/examples/fake_provider_tool_call_demo.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/examples/freemodel_best_model_demo.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/examples/freemodel_coding_team.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/examples/freemodel_openai_compatible.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/examples/handoff_demo.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/examples/handoff_quality_demo.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/examples/langgraph_integration.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/examples/mai_clinical_ensemble_benchmark.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/examples/mai_clinical_mimo_rescue.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/examples/mai_clinical_targeted_rescue.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/examples/mai_style_doctor_benchmark.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/examples/medical_tools_demo.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/examples/memory_demo.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/examples/ollama_agent.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/examples/openai_agents_sdk_integration.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/examples/project_context_demo.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/examples/provider_matrix_demo.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/examples/provider_tool_adapter_demo.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/examples/provider_tool_formats_demo.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/examples/pydantic_ai_integration.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/examples/real_task_calculator.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/examples/recipe_demo.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/examples/replay_demo.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/examples/repo_inspired_protocols.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/examples/research_workflow.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/examples/simple_agent.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/examples/structured_output_demo.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/examples/structured_recipe_demo.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/examples/support_escalation.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/examples/template_demo.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/examples/tool_agent.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/examples/tool_execution_demo.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/examples/tool_schema_demo.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/examples/trace_demo.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit/agent.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit/builtins.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit/context.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit/doctor_benchmark.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit/doctor_cases_100.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit/doctor_cases_30.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit/doctor_cases_400.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit/errors.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit/evaluation.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit/extensions.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit/handoff.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit/mai_benchmark.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit/memory.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit/protocol.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit/protocols/__init__.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit/protocols/compressed.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit/protocols/hybrid_min.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit/protocols/hybrid_state.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit/protocols/natural.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit/provider_adapters.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit/providers/base.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit/providers/echo_provider.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit/providers/ollama_provider.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit/providers/openai_compatible.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit/providers/openai_provider.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit/py.typed +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit/quality.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit/recipes.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit/replay.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit/reports.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit/runner.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit/safety.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit/schemas.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit/showcases.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit/structured.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit/templates.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit/tool.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit/tool_execution.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit/tool_factory.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit/tools/__init__.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit/tools/filesystem.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit/tools/medical.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit/tools/shell.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit/tools/text.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit/tracing.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit/validation.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit.egg-info/dependency_links.txt +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit.egg-info/entry_points.txt +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit.egg-info/requires.txt +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/handoffkit.egg-info/top_level.txt +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/setup.cfg +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/tests/test_070_examples.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/tests/test_adoption_docs.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/tests/test_agent.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/tests/test_async_runtime.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/tests/test_cli_doctor.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/tests/test_clinical_ensemble_benchmark.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/tests/test_clinical_targeted_rescue.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/tests/test_coding_team.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/tests/test_contract_validation.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/tests/test_docs_090.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/tests/test_doctor_benchmark.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/tests/test_evaluation.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/tests/test_handoff.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/tests/test_imports.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/tests/test_json_schemas.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/tests/test_mai_benchmark.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/tests/test_medical_tools.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/tests/test_memory_context.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/tests/test_ollama_provider_mock.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/tests/test_openai_model_selection.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/tests/test_openai_provider_mock.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/tests/test_protocols.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/tests/test_provider_adapters.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/tests/test_provider_matrix.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/tests/test_public_api_compatibility.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/tests/test_quality.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/tests/test_real_task_demo.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/tests/test_recipes_extensions.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/tests/test_release_process_docs.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/tests/test_replay.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/tests/test_reports.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/tests/test_shell_safety.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/tests/test_showcases.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/tests/test_structured_outputs.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/tests/test_team.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/tests/test_templates.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/tests/test_tool.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/tests/test_tool_execution.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/tests/test_tool_factory.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/tests/test_tracing.py +0 -0
- {handoffkit-1.3.0 → handoffkit-1.4.0}/tests/test_validation.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: handoffkit
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.0
|
|
4
4
|
Summary: A lightweight Python framework for building multi-agent workflows with structured state transfer protocols.
|
|
5
5
|
Author: DaosPath
|
|
6
6
|
License-Expression: MIT
|
|
@@ -231,6 +231,51 @@ clinical_trials_search.run(condition="systemic sclerosis", max_results=3)
|
|
|
231
231
|
dailymed_drug_search.run(drug_name="ibuprofen", max_results=3)
|
|
232
232
|
```
|
|
233
233
|
|
|
234
|
+
## What 1.4.0 Adds
|
|
235
|
+
|
|
236
|
+
HandoffKit 1.4.0 adds experimental provider routing:
|
|
237
|
+
|
|
238
|
+
- provider registry entries for OpenCode Go, OpenCode Zen, OpenAI-compatible
|
|
239
|
+
endpoints, and Ollama,
|
|
240
|
+
- `ProviderSelector` for offline model lists and real opt-in probes,
|
|
241
|
+
- `ProviderRouter` for ordered fallback between model candidates,
|
|
242
|
+
- retry/backoff for transient OpenCode `429`, `5xx`, and transport failures,
|
|
243
|
+
- `handoffkit providers ...` CLI commands that never call real APIs unless
|
|
244
|
+
`--real` is passed.
|
|
245
|
+
|
|
246
|
+
```bash
|
|
247
|
+
handoffkit providers list
|
|
248
|
+
handoffkit providers models --provider opencode-go
|
|
249
|
+
handoffkit providers select --provider opencode-go --models mimo-v2.5,deepseek-v4-flash --json
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
Real probes require an explicit flag and a scoped provider key:
|
|
253
|
+
|
|
254
|
+
```powershell
|
|
255
|
+
$env:OPENCODE_API_KEY="..."
|
|
256
|
+
handoffkit providers probe --provider opencode-go --models mimo-v2.5,deepseek-v4-flash --real
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
## Provider Registry and Model Routing
|
|
260
|
+
|
|
261
|
+
The provider registry is experimental in 1.4.0. It is intended for diagnostics,
|
|
262
|
+
model selection, and fallback without adding heavy provider SDKs.
|
|
263
|
+
|
|
264
|
+
```python
|
|
265
|
+
from handoffkit.providers import ModelCandidate, ProviderRouter
|
|
266
|
+
|
|
267
|
+
router = ProviderRouter(
|
|
268
|
+
[
|
|
269
|
+
ModelCandidate("opencode-go", "mimo-v2.5"),
|
|
270
|
+
ModelCandidate("opencode-go", "deepseek-v4-flash"),
|
|
271
|
+
]
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
print(router.generate("Create a concise release checklist."))
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Docs: [`docs/PROVIDERS.md`](docs/PROVIDERS.md).
|
|
278
|
+
|
|
234
279
|
## What 1.1.0 Adds
|
|
235
280
|
|
|
236
281
|
HandoffKit 1.1.0 focuses on adoption:
|
|
@@ -256,6 +301,8 @@ Use it as the contract and reporting layer beside orchestration frameworks:
|
|
|
256
301
|
- [`Pydantic AI`](docs/integrations/PYDANTIC_AI.md): typed outputs become workflow handoff state,
|
|
257
302
|
- [`OpenCode Go / Zen`](docs/integrations/OPENCODE.md): provider adapters route
|
|
258
303
|
OpenCode model families to their matching API endpoint styles.
|
|
304
|
+
- [`Provider Registry`](docs/PROVIDERS.md): experimental provider listing,
|
|
305
|
+
probing, model selection, and fallback routing.
|
|
259
306
|
|
|
260
307
|
Runnable offline examples:
|
|
261
308
|
[`examples/langgraph_integration.py`](examples/langgraph_integration.py),
|
|
@@ -1085,6 +1132,9 @@ handoffkit demo-trace
|
|
|
1085
1132
|
handoffkit demo-replay
|
|
1086
1133
|
handoffkit validate-report reports/trace_demo.json
|
|
1087
1134
|
handoffkit evaluate reports/trace_demo.json
|
|
1135
|
+
handoffkit providers list
|
|
1136
|
+
handoffkit providers models --provider opencode-go
|
|
1137
|
+
handoffkit providers select --provider opencode-go --models mimo-v2.5,deepseek-v4-flash --json
|
|
1088
1138
|
handoffkit init my-agent --template basic-agent --output .
|
|
1089
1139
|
```
|
|
1090
1140
|
|
|
@@ -199,6 +199,51 @@ clinical_trials_search.run(condition="systemic sclerosis", max_results=3)
|
|
|
199
199
|
dailymed_drug_search.run(drug_name="ibuprofen", max_results=3)
|
|
200
200
|
```
|
|
201
201
|
|
|
202
|
+
## What 1.4.0 Adds
|
|
203
|
+
|
|
204
|
+
HandoffKit 1.4.0 adds experimental provider routing:
|
|
205
|
+
|
|
206
|
+
- provider registry entries for OpenCode Go, OpenCode Zen, OpenAI-compatible
|
|
207
|
+
endpoints, and Ollama,
|
|
208
|
+
- `ProviderSelector` for offline model lists and real opt-in probes,
|
|
209
|
+
- `ProviderRouter` for ordered fallback between model candidates,
|
|
210
|
+
- retry/backoff for transient OpenCode `429`, `5xx`, and transport failures,
|
|
211
|
+
- `handoffkit providers ...` CLI commands that never call real APIs unless
|
|
212
|
+
`--real` is passed.
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
handoffkit providers list
|
|
216
|
+
handoffkit providers models --provider opencode-go
|
|
217
|
+
handoffkit providers select --provider opencode-go --models mimo-v2.5,deepseek-v4-flash --json
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Real probes require an explicit flag and a scoped provider key:
|
|
221
|
+
|
|
222
|
+
```powershell
|
|
223
|
+
$env:OPENCODE_API_KEY="..."
|
|
224
|
+
handoffkit providers probe --provider opencode-go --models mimo-v2.5,deepseek-v4-flash --real
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
## Provider Registry and Model Routing
|
|
228
|
+
|
|
229
|
+
The provider registry is experimental in 1.4.0. It is intended for diagnostics,
|
|
230
|
+
model selection, and fallback without adding heavy provider SDKs.
|
|
231
|
+
|
|
232
|
+
```python
|
|
233
|
+
from handoffkit.providers import ModelCandidate, ProviderRouter
|
|
234
|
+
|
|
235
|
+
router = ProviderRouter(
|
|
236
|
+
[
|
|
237
|
+
ModelCandidate("opencode-go", "mimo-v2.5"),
|
|
238
|
+
ModelCandidate("opencode-go", "deepseek-v4-flash"),
|
|
239
|
+
]
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
print(router.generate("Create a concise release checklist."))
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Docs: [`docs/PROVIDERS.md`](docs/PROVIDERS.md).
|
|
246
|
+
|
|
202
247
|
## What 1.1.0 Adds
|
|
203
248
|
|
|
204
249
|
HandoffKit 1.1.0 focuses on adoption:
|
|
@@ -224,6 +269,8 @@ Use it as the contract and reporting layer beside orchestration frameworks:
|
|
|
224
269
|
- [`Pydantic AI`](docs/integrations/PYDANTIC_AI.md): typed outputs become workflow handoff state,
|
|
225
270
|
- [`OpenCode Go / Zen`](docs/integrations/OPENCODE.md): provider adapters route
|
|
226
271
|
OpenCode model families to their matching API endpoint styles.
|
|
272
|
+
- [`Provider Registry`](docs/PROVIDERS.md): experimental provider listing,
|
|
273
|
+
probing, model selection, and fallback routing.
|
|
227
274
|
|
|
228
275
|
Runnable offline examples:
|
|
229
276
|
[`examples/langgraph_integration.py`](examples/langgraph_integration.py),
|
|
@@ -1053,6 +1100,9 @@ handoffkit demo-trace
|
|
|
1053
1100
|
handoffkit demo-replay
|
|
1054
1101
|
handoffkit validate-report reports/trace_demo.json
|
|
1055
1102
|
handoffkit evaluate reports/trace_demo.json
|
|
1103
|
+
handoffkit providers list
|
|
1104
|
+
handoffkit providers models --provider opencode-go
|
|
1105
|
+
handoffkit providers select --provider opencode-go --models mimo-v2.5,deepseek-v4-flash --json
|
|
1056
1106
|
handoffkit init my-agent --template basic-agent --output .
|
|
1057
1107
|
```
|
|
1058
1108
|
|
|
@@ -11,6 +11,7 @@ import hashlib
|
|
|
11
11
|
import json
|
|
12
12
|
import os
|
|
13
13
|
import re
|
|
14
|
+
import sys
|
|
14
15
|
import time
|
|
15
16
|
import uuid
|
|
16
17
|
from pathlib import Path
|
|
@@ -69,6 +70,12 @@ CLINICAL_EQUIVALENCE_ALIASES = {
|
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
|
|
73
|
+
def _configure_output_encoding() -> None:
|
|
74
|
+
for stream in (sys.stdout, sys.stderr):
|
|
75
|
+
if hasattr(stream, "reconfigure"):
|
|
76
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
77
|
+
|
|
78
|
+
|
|
72
79
|
def _compact(value: str) -> str:
|
|
73
80
|
return re.sub(r"[^a-z0-9]+", "", value.lower())
|
|
74
81
|
|
|
@@ -824,6 +831,7 @@ Judge:
|
|
|
824
831
|
|
|
825
832
|
|
|
826
833
|
def main() -> int:
|
|
834
|
+
_configure_output_encoding()
|
|
827
835
|
parser = argparse.ArgumentParser()
|
|
828
836
|
parser.add_argument("--cases", type=int, default=3)
|
|
829
837
|
parser.add_argument("--model", default=os.getenv("OPENCODE_GO_MODEL", DEFAULT_MODEL))
|
|
@@ -5,6 +5,7 @@ Requires OPENCODE_API_KEY. No secrets are printed.
|
|
|
5
5
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
|
+
import argparse
|
|
8
9
|
import os
|
|
9
10
|
import sys
|
|
10
11
|
|
|
@@ -18,10 +19,25 @@ def _configure_stdout() -> None:
|
|
|
18
19
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
19
20
|
|
|
20
21
|
|
|
21
|
-
def
|
|
22
|
+
def _parser() -> argparse.ArgumentParser:
|
|
23
|
+
parser = argparse.ArgumentParser(description="Run HandoffKit against OpenCode Go.")
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
"--model",
|
|
26
|
+
default=os.getenv("OPENCODE_GO_MODEL", DEFAULT_OPENCODE_GO_MODEL),
|
|
27
|
+
help="OpenCode Go model id.",
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
"--task",
|
|
31
|
+
default="Create a concise architecture plan for a Python CLI calculator.",
|
|
32
|
+
help="Task prompt.",
|
|
33
|
+
)
|
|
34
|
+
return parser
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def main(argv: list[str] | None = None) -> None:
|
|
22
38
|
_configure_stdout()
|
|
39
|
+
args = _parser().parse_args(argv)
|
|
23
40
|
api_key = os.getenv("OPENCODE_API_KEY")
|
|
24
|
-
model = os.getenv("OPENCODE_GO_MODEL", DEFAULT_OPENCODE_GO_MODEL)
|
|
25
41
|
if not api_key:
|
|
26
42
|
print("OPENCODE_API_KEY is not set.")
|
|
27
43
|
print('Set it with: $env:OPENCODE_API_KEY="..."')
|
|
@@ -29,19 +45,19 @@ def main() -> None:
|
|
|
29
45
|
return
|
|
30
46
|
|
|
31
47
|
try:
|
|
32
|
-
provider = OpenCodeGoProvider(model=model, api_key=api_key)
|
|
48
|
+
provider = OpenCodeGoProvider(model=args.model, api_key=api_key)
|
|
33
49
|
agent = Agent(
|
|
34
50
|
name="OpenCodeGoArchitect",
|
|
35
51
|
role="Create concise coding architecture plans.",
|
|
36
52
|
provider=provider,
|
|
37
53
|
)
|
|
38
|
-
response = agent.run(
|
|
54
|
+
response = agent.run(args.task)
|
|
39
55
|
except (ProviderConfigurationError, ProviderExecutionError) as exc:
|
|
40
56
|
print(f"OpenCode Go unavailable: {exc}")
|
|
41
57
|
return
|
|
42
58
|
|
|
43
59
|
print("Provider: OpenCode Go")
|
|
44
|
-
print(f"Model: {model}")
|
|
60
|
+
print(f"Model: {args.model}")
|
|
45
61
|
print(response)
|
|
46
62
|
|
|
47
63
|
|
|
@@ -5,6 +5,7 @@ Requires OPENCODE_API_KEY. No secrets are printed.
|
|
|
5
5
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
|
+
import argparse
|
|
8
9
|
import os
|
|
9
10
|
import sys
|
|
10
11
|
|
|
@@ -18,10 +19,25 @@ def _configure_stdout() -> None:
|
|
|
18
19
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
19
20
|
|
|
20
21
|
|
|
21
|
-
def
|
|
22
|
+
def _parser() -> argparse.ArgumentParser:
|
|
23
|
+
parser = argparse.ArgumentParser(description="Run HandoffKit against OpenCode Zen.")
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
"--model",
|
|
26
|
+
default=os.getenv("OPENCODE_ZEN_MODEL", DEFAULT_OPENCODE_ZEN_MODEL),
|
|
27
|
+
help="OpenCode Zen model id.",
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
"--task",
|
|
31
|
+
default="Create a concise architecture plan for a Python CLI calculator.",
|
|
32
|
+
help="Task prompt.",
|
|
33
|
+
)
|
|
34
|
+
return parser
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def main(argv: list[str] | None = None) -> None:
|
|
22
38
|
_configure_stdout()
|
|
39
|
+
args = _parser().parse_args(argv)
|
|
23
40
|
api_key = os.getenv("OPENCODE_API_KEY")
|
|
24
|
-
model = os.getenv("OPENCODE_ZEN_MODEL", DEFAULT_OPENCODE_ZEN_MODEL)
|
|
25
41
|
if not api_key:
|
|
26
42
|
print("OPENCODE_API_KEY is not set.")
|
|
27
43
|
print('Set it with: $env:OPENCODE_API_KEY="..."')
|
|
@@ -30,19 +46,19 @@ def main() -> None:
|
|
|
30
46
|
return
|
|
31
47
|
|
|
32
48
|
try:
|
|
33
|
-
provider = OpenCodeZenProvider(model=model, api_key=api_key)
|
|
49
|
+
provider = OpenCodeZenProvider(model=args.model, api_key=api_key)
|
|
34
50
|
agent = Agent(
|
|
35
51
|
name="OpenCodeZenArchitect",
|
|
36
52
|
role="Create concise coding architecture plans.",
|
|
37
53
|
provider=provider,
|
|
38
54
|
)
|
|
39
|
-
response = agent.run(
|
|
55
|
+
response = agent.run(args.task)
|
|
40
56
|
except (ProviderConfigurationError, ProviderExecutionError) as exc:
|
|
41
57
|
print(f"OpenCode Zen unavailable: {exc}")
|
|
42
58
|
return
|
|
43
59
|
|
|
44
60
|
print("Provider: OpenCode Zen")
|
|
45
|
-
print(f"Model: {model}")
|
|
61
|
+
print(f"Model: {args.model}")
|
|
46
62
|
print(response)
|
|
47
63
|
|
|
48
64
|
|
|
@@ -20,7 +20,7 @@ from handoffkit.handoff import HandoffState
|
|
|
20
20
|
from handoffkit.mai_benchmark import run_mai_style_benchmark
|
|
21
21
|
from handoffkit.protocol import HandoffProtocol
|
|
22
22
|
from handoffkit.provider_adapters import ProviderToolAdapter
|
|
23
|
-
from handoffkit.providers import BaseProvider
|
|
23
|
+
from handoffkit.providers import BaseProvider, ProviderSelector
|
|
24
24
|
from handoffkit.quality import HandoffQualityEvaluator
|
|
25
25
|
from handoffkit.recipes import RecipeRunner
|
|
26
26
|
from handoffkit.replay import ReplayRunner
|
|
@@ -382,6 +382,90 @@ def list_showcases() -> str:
|
|
|
382
382
|
return "\n".join(lines)
|
|
383
383
|
|
|
384
384
|
|
|
385
|
+
def _split_models(value: str | None) -> list[str] | None:
|
|
386
|
+
if not value:
|
|
387
|
+
return None
|
|
388
|
+
return [item.strip() for item in value.split(",") if item.strip()]
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def list_providers(*, json_output: bool = False) -> str:
|
|
392
|
+
"""List experimental provider registry entries."""
|
|
393
|
+
selector = ProviderSelector()
|
|
394
|
+
specs = [spec.to_dict() for spec in selector.list_providers()]
|
|
395
|
+
if json_output:
|
|
396
|
+
return json.dumps({"providers": specs}, indent=2, sort_keys=True)
|
|
397
|
+
lines = ["HandoffKit providers", ""]
|
|
398
|
+
for spec in selector.list_providers():
|
|
399
|
+
lines.append(f"- {spec.name}: {spec.description}")
|
|
400
|
+
lines.append(f" default: {spec.default_model}")
|
|
401
|
+
lines.append(f" models: {', '.join(spec.suggested_models)}")
|
|
402
|
+
lines.append(f" env: {', '.join(spec.env_vars)}")
|
|
403
|
+
return "\n".join(lines)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def list_provider_models(provider: str, *, real: bool = False, json_output: bool = False) -> str:
|
|
407
|
+
"""List known provider models without network unless real=True."""
|
|
408
|
+
selector = ProviderSelector()
|
|
409
|
+
models = selector.list_models(provider, real=real)
|
|
410
|
+
payload = {"provider": provider, "real": real, "models": models}
|
|
411
|
+
if json_output:
|
|
412
|
+
return json.dumps(payload, indent=2, sort_keys=True)
|
|
413
|
+
mode = "real" if real else "offline"
|
|
414
|
+
return (
|
|
415
|
+
f"HandoffKit provider models ({mode})\n"
|
|
416
|
+
f"Provider: {provider}\n"
|
|
417
|
+
+ "\n".join(f"- {model}" for model in models)
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def probe_provider_models(
|
|
422
|
+
provider: str,
|
|
423
|
+
*,
|
|
424
|
+
models: str | None = None,
|
|
425
|
+
real: bool = False,
|
|
426
|
+
json_output: bool = False,
|
|
427
|
+
) -> str:
|
|
428
|
+
"""Probe provider model candidates."""
|
|
429
|
+
selector = ProviderSelector()
|
|
430
|
+
results = [
|
|
431
|
+
selector.probe(candidate, real=real)
|
|
432
|
+
for candidate in selector.candidates(provider, _split_models(models))
|
|
433
|
+
]
|
|
434
|
+
payload = {"provider": provider, "real": real, "results": [item.to_dict() for item in results]}
|
|
435
|
+
if json_output:
|
|
436
|
+
return json.dumps(payload, indent=2, sort_keys=True)
|
|
437
|
+
mode = "real" if real else "offline"
|
|
438
|
+
lines = [f"HandoffKit provider probe ({mode})", f"Provider: {provider}"]
|
|
439
|
+
for result in results:
|
|
440
|
+
status = "ok" if result.success else "failed"
|
|
441
|
+
suffix = f" error={result.error}" if result.error else ""
|
|
442
|
+
lines.append(f"- {result.model}: {status} {result.latency_seconds:.3f}s{suffix}")
|
|
443
|
+
return "\n".join(lines)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def select_provider_model(
|
|
447
|
+
provider: str,
|
|
448
|
+
*,
|
|
449
|
+
models: str | None = None,
|
|
450
|
+
real: bool = False,
|
|
451
|
+
json_output: bool = False,
|
|
452
|
+
) -> str:
|
|
453
|
+
"""Select the first working provider model."""
|
|
454
|
+
selector = ProviderSelector()
|
|
455
|
+
result = selector.select(provider, _split_models(models), real=real)
|
|
456
|
+
payload = result.to_dict()
|
|
457
|
+
if json_output:
|
|
458
|
+
return json.dumps(payload, indent=2, sort_keys=True)
|
|
459
|
+
mode = "real" if real else "offline"
|
|
460
|
+
return (
|
|
461
|
+
f"HandoffKit provider select ({mode})\n"
|
|
462
|
+
f"Provider: {result.provider}\n"
|
|
463
|
+
f"Model: {result.model}\n"
|
|
464
|
+
f"Success: {result.success}\n"
|
|
465
|
+
f"Latency: {result.latency_seconds:.3f}s"
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
|
|
385
469
|
def run_named_showcase(slug: str) -> str:
|
|
386
470
|
"""Run one named showcase and write runs/latest artifacts."""
|
|
387
471
|
return run_showcase(slug).to_markdown()
|
|
@@ -471,6 +555,42 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
471
555
|
subparsers.add_parser("demo-support", help="Run the support escalation showcase.")
|
|
472
556
|
subparsers.add_parser("demo-research", help="Run the research workflow showcase.")
|
|
473
557
|
subparsers.add_parser("demo-doctor", help="Run the educational doctor-orchestrator showcase.")
|
|
558
|
+
providers_parser = subparsers.add_parser("providers", help="Inspect provider registry.")
|
|
559
|
+
providers_subparsers = providers_parser.add_subparsers(dest="providers_command")
|
|
560
|
+
providers_list_parser = providers_subparsers.add_parser("list", help="List providers.")
|
|
561
|
+
providers_list_parser.add_argument("--json", action="store_true", help="Print JSON.")
|
|
562
|
+
providers_models_parser = providers_subparsers.add_parser(
|
|
563
|
+
"models",
|
|
564
|
+
help="List provider models.",
|
|
565
|
+
)
|
|
566
|
+
providers_models_parser.add_argument("--provider", required=True, help="Provider name.")
|
|
567
|
+
providers_models_parser.add_argument(
|
|
568
|
+
"--real",
|
|
569
|
+
action="store_true",
|
|
570
|
+
help="Call provider endpoint.",
|
|
571
|
+
)
|
|
572
|
+
providers_models_parser.add_argument("--json", action="store_true", help="Print JSON.")
|
|
573
|
+
providers_probe_parser = providers_subparsers.add_parser("probe", help="Probe provider models.")
|
|
574
|
+
providers_probe_parser.add_argument("--provider", required=True, help="Provider name.")
|
|
575
|
+
providers_probe_parser.add_argument("--models", default=None, help="Comma-separated models.")
|
|
576
|
+
providers_probe_parser.add_argument(
|
|
577
|
+
"--real",
|
|
578
|
+
action="store_true",
|
|
579
|
+
help="Call provider endpoint.",
|
|
580
|
+
)
|
|
581
|
+
providers_probe_parser.add_argument("--json", action="store_true", help="Print JSON.")
|
|
582
|
+
providers_select_parser = providers_subparsers.add_parser(
|
|
583
|
+
"select",
|
|
584
|
+
help="Select a provider model.",
|
|
585
|
+
)
|
|
586
|
+
providers_select_parser.add_argument("--provider", required=True, help="Provider name.")
|
|
587
|
+
providers_select_parser.add_argument("--models", default=None, help="Comma-separated models.")
|
|
588
|
+
providers_select_parser.add_argument(
|
|
589
|
+
"--real",
|
|
590
|
+
action="store_true",
|
|
591
|
+
help="Call provider endpoint.",
|
|
592
|
+
)
|
|
593
|
+
providers_select_parser.add_argument("--json", action="store_true", help="Print JSON.")
|
|
474
594
|
benchmark_doctor_parser = subparsers.add_parser(
|
|
475
595
|
"benchmark-doctor",
|
|
476
596
|
help="Run the real-case doctor benchmark harness.",
|
|
@@ -556,6 +676,34 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
556
676
|
if args.command == "demo-doctor":
|
|
557
677
|
print(run_doctor_orchestrator_demo())
|
|
558
678
|
return 0
|
|
679
|
+
if args.command == "providers":
|
|
680
|
+
if args.providers_command == "list":
|
|
681
|
+
print(list_providers(json_output=args.json))
|
|
682
|
+
return 0
|
|
683
|
+
if args.providers_command == "models":
|
|
684
|
+
print(list_provider_models(args.provider, real=args.real, json_output=args.json))
|
|
685
|
+
return 0
|
|
686
|
+
if args.providers_command == "probe":
|
|
687
|
+
print(
|
|
688
|
+
probe_provider_models(
|
|
689
|
+
args.provider,
|
|
690
|
+
models=args.models,
|
|
691
|
+
real=args.real,
|
|
692
|
+
json_output=args.json,
|
|
693
|
+
)
|
|
694
|
+
)
|
|
695
|
+
return 0
|
|
696
|
+
if args.providers_command == "select":
|
|
697
|
+
print(
|
|
698
|
+
select_provider_model(
|
|
699
|
+
args.provider,
|
|
700
|
+
models=args.models,
|
|
701
|
+
real=args.real,
|
|
702
|
+
json_output=args.json,
|
|
703
|
+
)
|
|
704
|
+
)
|
|
705
|
+
return 0
|
|
706
|
+
parser.error("providers requires a subcommand")
|
|
559
707
|
if args.command == "benchmark-doctor":
|
|
560
708
|
print(run_doctor_benchmark_demo(args.cases))
|
|
561
709
|
return 0
|
|
@@ -14,12 +14,23 @@ from handoffkit.providers.opencode_provider import (
|
|
|
14
14
|
DEFAULT_OPENCODE_GO_MODEL,
|
|
15
15
|
DEFAULT_OPENCODE_ZEN_BASE_URL,
|
|
16
16
|
DEFAULT_OPENCODE_ZEN_MODEL,
|
|
17
|
+
OPENCODE_GO_MODELS,
|
|
18
|
+
OPENCODE_ZEN_MODELS,
|
|
17
19
|
OpenCodeGoProvider,
|
|
18
20
|
OpenCodeProvider,
|
|
19
21
|
OpenCodeZenProvider,
|
|
22
|
+
RetryPolicy,
|
|
20
23
|
infer_opencode_api_style,
|
|
21
24
|
list_opencode_models,
|
|
22
25
|
)
|
|
26
|
+
from handoffkit.providers.registry import (
|
|
27
|
+
ModelCandidate,
|
|
28
|
+
ProviderProbeResult,
|
|
29
|
+
ProviderRouter,
|
|
30
|
+
ProviderSelector,
|
|
31
|
+
ProviderSpec,
|
|
32
|
+
default_provider_specs,
|
|
33
|
+
)
|
|
23
34
|
|
|
24
35
|
__all__ = [
|
|
25
36
|
"BaseProvider",
|
|
@@ -29,12 +40,21 @@ __all__ = [
|
|
|
29
40
|
"DEFAULT_OPENCODE_ZEN_BASE_URL",
|
|
30
41
|
"DEFAULT_OPENCODE_ZEN_MODEL",
|
|
31
42
|
"ModelSelectionResult",
|
|
43
|
+
"ModelCandidate",
|
|
44
|
+
"OPENCODE_GO_MODELS",
|
|
45
|
+
"OPENCODE_ZEN_MODELS",
|
|
32
46
|
"OllamaProvider",
|
|
33
47
|
"OpenCodeGoProvider",
|
|
34
48
|
"OpenCodeProvider",
|
|
35
49
|
"OpenCodeZenProvider",
|
|
36
50
|
"OpenAIProvider",
|
|
51
|
+
"ProviderProbeResult",
|
|
52
|
+
"ProviderRouter",
|
|
53
|
+
"ProviderSelector",
|
|
54
|
+
"ProviderSpec",
|
|
55
|
+
"RetryPolicy",
|
|
37
56
|
"choose_openai_compatible_model",
|
|
57
|
+
"default_provider_specs",
|
|
38
58
|
"infer_opencode_api_style",
|
|
39
59
|
"list_opencode_models",
|
|
40
60
|
"list_openai_compatible_models",
|
|
@@ -4,8 +4,10 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
import os
|
|
7
|
+
import time
|
|
7
8
|
import urllib.error
|
|
8
9
|
import urllib.request
|
|
10
|
+
from dataclasses import dataclass
|
|
9
11
|
from typing import Any, Literal
|
|
10
12
|
|
|
11
13
|
from handoffkit.errors import ProviderConfigurationError, ProviderExecutionError
|
|
@@ -18,6 +20,53 @@ DEFAULT_OPENCODE_ZEN_BASE_URL = "https://opencode.ai/zen/v1"
|
|
|
18
20
|
DEFAULT_OPENCODE_GO_BASE_URL = "https://opencode.ai/zen/go/v1"
|
|
19
21
|
DEFAULT_OPENCODE_ZEN_MODEL = "gpt-5.4"
|
|
20
22
|
DEFAULT_OPENCODE_GO_MODEL = "deepseek-v4-flash"
|
|
23
|
+
OPENCODE_USER_AGENT = "handoffkit/1.4 OpenCodeProvider"
|
|
24
|
+
|
|
25
|
+
OPENCODE_GO_MODELS = (
|
|
26
|
+
"mimo-v2.5",
|
|
27
|
+
"deepseek-v4-flash",
|
|
28
|
+
"deepseek-v4-pro",
|
|
29
|
+
"glm-5.2",
|
|
30
|
+
"qwen3.7-max",
|
|
31
|
+
"qwen3.7-plus",
|
|
32
|
+
"kimi-k2.7-code",
|
|
33
|
+
"minimax-m3",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
OPENCODE_ZEN_MODELS = (
|
|
37
|
+
"gpt-5.4",
|
|
38
|
+
"claude-sonnet-5",
|
|
39
|
+
"qwen3.7-max",
|
|
40
|
+
"qwen3.7-plus",
|
|
41
|
+
"deepseek-v4-flash",
|
|
42
|
+
"deepseek-v4-pro",
|
|
43
|
+
"glm-5.2",
|
|
44
|
+
"kimi-k2.7-code",
|
|
45
|
+
"minimax-m3",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class RetryPolicy:
|
|
51
|
+
"""Retry policy for transient provider failures."""
|
|
52
|
+
|
|
53
|
+
max_attempts: int = 3
|
|
54
|
+
backoff_seconds: float = 0.5
|
|
55
|
+
retry_status_codes: tuple[int, ...] = (429, 500, 502, 503, 504)
|
|
56
|
+
|
|
57
|
+
def should_retry_status(self, status_code: int, attempt: int) -> bool:
|
|
58
|
+
"""Return whether this HTTP status should be retried."""
|
|
59
|
+
return attempt < self.max_attempts and status_code in self.retry_status_codes
|
|
60
|
+
|
|
61
|
+
def should_retry_url_error(self, attempt: int) -> bool:
|
|
62
|
+
"""Return whether this URL-level error should be retried."""
|
|
63
|
+
return attempt < self.max_attempts
|
|
64
|
+
|
|
65
|
+
def sleep(self, attempt: int) -> None:
|
|
66
|
+
"""Sleep before the next retry attempt."""
|
|
67
|
+
delay = self.backoff_seconds * max(0, attempt - 1)
|
|
68
|
+
if delay > 0:
|
|
69
|
+
time.sleep(delay)
|
|
21
70
|
|
|
22
71
|
_GO_ANTHROPIC_MODELS = {
|
|
23
72
|
"minimax-m3",
|
|
@@ -92,7 +141,7 @@ def list_opencode_models(
|
|
|
92
141
|
"Authorization": f"Bearer {resolved_key}",
|
|
93
142
|
"Accept": "application/json",
|
|
94
143
|
"Content-Type": "application/json",
|
|
95
|
-
"User-Agent":
|
|
144
|
+
"User-Agent": OPENCODE_USER_AGENT,
|
|
96
145
|
},
|
|
97
146
|
method="GET",
|
|
98
147
|
)
|
|
@@ -110,7 +159,13 @@ def list_opencode_models(
|
|
|
110
159
|
f"OpenCode {resolved_catalog} models request failed for "
|
|
111
160
|
f"{resolved_base_url}: {exc.reason}"
|
|
112
161
|
) from exc
|
|
113
|
-
data =
|
|
162
|
+
data = (
|
|
163
|
+
body.get("data", [])
|
|
164
|
+
if isinstance(body, dict)
|
|
165
|
+
else body
|
|
166
|
+
if isinstance(body, list)
|
|
167
|
+
else []
|
|
168
|
+
)
|
|
114
169
|
models: list[str] = []
|
|
115
170
|
if isinstance(data, list):
|
|
116
171
|
for item in data:
|
|
@@ -133,6 +188,7 @@ class OpenCodeProvider(BaseProvider):
|
|
|
133
188
|
base_url: str | None = None,
|
|
134
189
|
api_style: OpenCodeAPIStyle = "auto",
|
|
135
190
|
timeout: float = 60.0,
|
|
191
|
+
retry_policy: RetryPolicy | None = None,
|
|
136
192
|
) -> None:
|
|
137
193
|
self.catalog = _normalize_catalog(catalog)
|
|
138
194
|
self.model = _strip_opencode_prefix(
|
|
@@ -143,6 +199,7 @@ class OpenCodeProvider(BaseProvider):
|
|
|
143
199
|
self.base_url = _resolve_base_url(self.catalog, base_url)
|
|
144
200
|
self.api_style = infer_opencode_api_style(self.model, self.catalog, api_style)
|
|
145
201
|
self.timeout = timeout
|
|
202
|
+
self.retry_policy = retry_policy or RetryPolicy()
|
|
146
203
|
if not self.api_key:
|
|
147
204
|
raise ProviderConfigurationError(
|
|
148
205
|
"OPENCODE_API_KEY is required for OpenCodeProvider. "
|
|
@@ -227,7 +284,7 @@ class OpenCodeProvider(BaseProvider):
|
|
|
227
284
|
"Authorization": f"Bearer {self.api_key}",
|
|
228
285
|
"Accept": "application/json",
|
|
229
286
|
"Content-Type": "application/json",
|
|
230
|
-
"User-Agent":
|
|
287
|
+
"User-Agent": OPENCODE_USER_AGENT,
|
|
231
288
|
}
|
|
232
289
|
if extra_headers:
|
|
233
290
|
headers.update(extra_headers)
|
|
@@ -237,19 +294,29 @@ class OpenCodeProvider(BaseProvider):
|
|
|
237
294
|
headers=headers,
|
|
238
295
|
method="POST",
|
|
239
296
|
)
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
297
|
+
for attempt in range(1, self.retry_policy.max_attempts + 1):
|
|
298
|
+
try:
|
|
299
|
+
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
|
300
|
+
body = json.loads(response.read().decode("utf-8"))
|
|
301
|
+
break
|
|
302
|
+
except urllib.error.HTTPError as exc:
|
|
303
|
+
detail = exc.read().decode("utf-8", errors="replace")
|
|
304
|
+
if self.retry_policy.should_retry_status(exc.code, attempt):
|
|
305
|
+
self.retry_policy.sleep(attempt)
|
|
306
|
+
continue
|
|
307
|
+
raise ProviderExecutionError(
|
|
308
|
+
f"OpenCode {self.catalog} request failed with HTTP {exc.code}: "
|
|
309
|
+
f"{_sanitize_error_detail(detail, self.api_key)}"
|
|
310
|
+
) from exc
|
|
311
|
+
except urllib.error.URLError as exc:
|
|
312
|
+
if self.retry_policy.should_retry_url_error(attempt):
|
|
313
|
+
self.retry_policy.sleep(attempt)
|
|
314
|
+
continue
|
|
315
|
+
raise ProviderExecutionError(
|
|
316
|
+
f"OpenCode {self.catalog} request failed for {self.base_url}: {exc.reason}"
|
|
317
|
+
) from exc
|
|
318
|
+
else: # pragma: no cover - defensive fallback.
|
|
319
|
+
raise ProviderExecutionError(f"OpenCode {self.catalog} request failed.")
|
|
253
320
|
if not isinstance(body, dict):
|
|
254
321
|
raise ProviderExecutionError(
|
|
255
322
|
f"OpenCode {self.catalog} response was not a JSON object."
|