From 8e6735d8d603a7300dc8b393fa16d4623379d462 Mon Sep 17 00:00:00 2001 From: Vagner Cavichini Date: Tue, 6 Oct 2026 21:48:26 -0300 Subject: [PATCH 1/3] fix: send OpenCode Go session affinity header --- src/tau_ai/openai_compatible.py | 3 ++ src/tau_coding/data/catalog.toml | 1 + tests/test_provider_runtime.py | 15 +++++++++ tests/test_tau_ai.py | 37 ++++++++++++++++++++++ website/content/reference/configuration.md | 2 +- 5 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/tau_ai/openai_compatible.py b/src/tau_ai/openai_compatible.py index d0a77fa8b..cd3c84ab4 100644 --- a/src/tau_ai/openai_compatible.py +++ b/src/tau_ai/openai_compatible.py @@ -433,6 +433,9 @@ def _apply_session_affinity_headers( if affinity_format == "openrouter": headers["x-session-id"] = session_id return + if affinity_format == "opencode": + headers["x-opencode-session"] = session_id + return if affinity_format == "openai": headers["session_id"] = session_id diff --git a/src/tau_coding/data/catalog.toml b/src/tau_coding/data/catalog.toml index e403ab2e1..e66e49223 100644 --- a/src/tau_coding/data/catalog.toml +++ b/src/tau_coding/data/catalog.toml @@ -6594,6 +6594,7 @@ models = ["deepseek-v4-flash", "deepseek-v4-pro", "glm-5.1", "glm-5.2", "kimi-k2 default_model = "kimi-k2.7-code" docs_url = "https://opencode.ai/docs/go" api = "openai-completions" +compat = { sendSessionAffinityHeaders = true, sessionAffinityFormat = "opencode" } thinking_levels = ["off", "minimal", "low", "medium", "high", "xhigh"] thinking_default = "medium" thinking_parameter = "reasoning_effort" diff --git a/tests/test_provider_runtime.py b/tests/test_provider_runtime.py index d932da113..4dd7f3e3f 100644 --- a/tests/test_provider_runtime.py +++ b/tests/test_provider_runtime.py @@ -66,6 +66,21 @@ def test_direct_openai_runtime_enables_responses_cache_affinity(tmp_path) -> Non assert provider._config.compat["sessionAffinityFormat"] == "openai" +def test_opencode_go_runtime_enables_session_affinity(tmp_path) -> None: + store = FileCredentialStore(tmp_path / "credentials.json") + store.set_api_key("opencode-go", "go-test") + + provider = create_model_provider( + provider_config_from_catalog_entry("opencode-go"), + credential_store=store, + model="deepseek-v4-flash", + ) + + assert isinstance(provider, OpenAICompatibleProvider) + assert provider._config.compat["sendSessionAffinityHeaders"] is True + assert provider._config.compat["sessionAffinityFormat"] == "opencode" + + def test_huggingface_runtime_pins_backing_provider_with_model_alias(tmp_path) -> None: store = FileCredentialStore(tmp_path / "credentials.json") store.set_api_key("huggingface", "hf-test") diff --git a/tests/test_tau_ai.py b/tests/test_tau_ai.py index 11e5729d5..3c5270d51 100644 --- a/tests/test_tau_ai.py +++ b/tests/test_tau_ai.py @@ -462,6 +462,43 @@ def handler(request: httpx.Request) -> httpx.Response: assert "x-client-request-id" not in requests[0].headers +@pytest.mark.anyio +async def test_openai_chat_completions_sends_opencode_session_header() -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response( + 200, + text='data: {"choices":[{"delta":{"content":"ok"},"finish_reason":"stop"}]}\n\n', + headers={"content-type": "text/event-stream"}, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + provider = OpenAICompatibleProvider( + OpenAICompatibleConfig( + api_key="test-key", + base_url="https://opencode.ai/zen/go/v1", + compat={ + "sendSessionAffinityHeaders": True, + "sessionAffinityFormat": "opencode", + }, + ), + client=client, + ) + await _collect( + provider.stream_response( + model="deepseek-v4.1-flash", + system="You are Tau.", + messages=[UserMessage(content="Say ok")], + tools=[], + session_id="stable-session-id", + ) + ) + + assert requests[0].headers["x-opencode-session"] == "stable-session-id" + + @pytest.mark.anyio async def test_openai_compatible_provider_includes_configured_reasoning_effort() -> None: requests: list[httpx.Request] = [] diff --git a/website/content/reference/configuration.md b/website/content/reference/configuration.md index 67deb2ef7..871f17525 100644 --- a/website/content/reference/configuration.md +++ b/website/content/reference/configuration.md @@ -219,7 +219,7 @@ or model: | --- | --- | | `supportsPromptCacheKey` | Sends the stable session-derived `prompt_cache_key` body field | | `sendSessionAffinityHeaders` | Sends headers using `sessionAffinityFormat` | -| `sessionAffinityFormat` | `openai` sends `session_id`; `openrouter` sends `x-session-id` | +| `sessionAffinityFormat` | `openai` sends `session_id`; `openrouter` sends `x-session-id`; `opencode` sends `x-opencode-session` | Unknown gateways retain their existing request shape by default. Enable only fields documented by the target service. Codex uses its dedicated `session-id` header From 8f7abe9817f33051fb851f42cf6b266f57cc6b40 Mon Sep 17 00:00:00 2001 From: Vagner Cavichini Date: Tue, 6 Oct 2026 22:53:13 -0300 Subject: [PATCH 2/3] feat: complete OpenCode Go model support --- src/tau_ai/anthropic.py | 30 +- src/tau_ai/env.py | 1 + src/tau_coding/data/catalog.toml | 75 ++- src/tau_coding/data/models-dev-catalog.json | 427 ++++++++++++++---- src/tau_coding/provider_config.py | 6 +- src/tau_coding/provider_runtime.py | 8 +- tests/test_provider_catalog.py | 50 +- tests/test_provider_runtime.py | 63 +++ tests/test_tau_ai.py | 128 ++++++ .../content/guides/providers-and-models.md | 33 ++ website/content/reference/configuration.md | 6 +- 11 files changed, 717 insertions(+), 110 deletions(-) diff --git a/src/tau_ai/anthropic.py b/src/tau_ai/anthropic.py index b33957bb4..f16663921 100644 --- a/src/tau_ai/anthropic.py +++ b/src/tau_ai/anthropic.py @@ -105,9 +105,13 @@ def stream_response( session_id: str | None = None, ) -> AsyncIterator[AssistantMessageEvent]: """Stream one response as Pi-compatible assistant message events.""" - del session_id raw = self._stream_provider_events( - model=model, system=system, messages=messages, tools=tools, signal=signal + model=model, + system=system, + messages=messages, + tools=tools, + signal=signal, + session_id=session_id, ) return canonicalize_provider_stream( raw, api="anthropic-messages", provider="anthropic", model=model @@ -121,6 +125,7 @@ def _stream_provider_events( messages: list[AgentMessage], tools: list[AgentTool], signal: CancellationToken | None = None, + session_id: str | None = None, ) -> AsyncIterator[ProviderEvent]: """Stream one Anthropic response as provider-neutral events.""" @@ -167,6 +172,9 @@ async def iterator() -> AsyncIterator[ProviderEvent]: headers.setdefault("Authorization", f"Bearer {api_key}") else: headers["x-api-key"] = api_key + _apply_anthropic_session_affinity_headers( + headers, session_id, self._session_affinity_format() + ) url = f"{base_url.rstrip('/')}/messages" attempt = 0 @@ -384,6 +392,13 @@ def _should_retry(self, attempt: int, *, status_code: int | None = None) -> bool return False return status_code is None or status_code in {408, 409, 425, 429} or status_code >= 500 + def _session_affinity_format(self) -> str | None: + compat = self._config.compat or {} + if compat.get("sendSessionAffinityHeaders") is not True: + return None + value = compat.get("sessionAffinityFormat") + return value if isinstance(value, str) and value else None + _TRANSIENT_ANTHROPIC_STREAM_ERROR_TYPES = frozenset( { @@ -704,6 +719,17 @@ def _anthropic_tool( return payload +def _apply_anthropic_session_affinity_headers( + headers: dict[str, str], + session_id: str | None, + affinity_format: str | None, +) -> None: + """Send stable session affinity for gateways that opt in via compat.""" + if not session_id or affinity_format != "opencode": + return + headers["x-opencode-session"] = session_id + + def _parse_sse_line(line: str) -> str | None: line = line.strip() if not line or not line.startswith("data:"): diff --git a/src/tau_ai/env.py b/src/tau_ai/env.py index 026fb9454..c677431a1 100644 --- a/src/tau_ai/env.py +++ b/src/tau_ai/env.py @@ -73,6 +73,7 @@ class AnthropicConfig: bearer_auth: bool = False base_url: str = DEFAULT_ANTHROPIC_BASE_URL headers: Mapping[str, str] | None = None + compat: Mapping[str, JSONValue] = field(default_factory=dict) timeout_seconds: float = DEFAULT_OPENAI_COMPATIBLE_TIMEOUT_SECONDS max_retries: int = DEFAULT_OPENAI_COMPATIBLE_MAX_RETRIES max_retry_delay_seconds: float = DEFAULT_OPENAI_COMPATIBLE_MAX_RETRY_DELAY_SECONDS diff --git a/src/tau_coding/data/catalog.toml b/src/tau_coding/data/catalog.toml index e66e49223..507876a1f 100644 --- a/src/tau_coding/data/catalog.toml +++ b/src/tau_coding/data/catalog.toml @@ -6590,7 +6590,7 @@ kind = "openai-compatible" base_url = "https://opencode.ai/zen/go/v1" api_key_env = "OPENCODE_API_KEY" credential_name = "opencode-go" -models = ["deepseek-v4-flash", "deepseek-v4-pro", "glm-5.1", "glm-5.2", "kimi-k2.6", "kimi-k2.7-code", "mimo-v2.5", "mimo-v2.5-pro", "minimax-m2.7", "minimax-m3", "qwen3.6-plus", "qwen3.7-max", "qwen3.7-plus"] +models = ["deepseek-v4-flash", "deepseek-v4-flash-vision-exp", "deepseek-v4-pro", "deepseek-v4.1-flash", "glm-5.2", "glm-5.3", "glm-5.3-flash", "gpt-5.6-luna", "gpt-6-luna", "grok-4.6", "grok-4.7", "hy3", "hy4-preview", "kimi-k2.6", "kimi-k2.7-code", "kimi-k3", "longcat-2.0", "longcat-2.5-preview-free", "mimo-v2.5", "mimo-v2.5-pro", "mimo-v2.6-flash", "mimo-v2.6-pro", "minimax-m2.7", "minimax-m3", "muse-spark-1.2-contributor", "muse-spark-1.3-contributor", "qwen3.7-plus", "qwen3.8-flash", "qwen3.8-max", "space-bunny"] default_model = "kimi-k2.7-code" docs_url = "https://opencode.ai/docs/go" api = "openai-completions" @@ -6602,7 +6602,6 @@ thinking_parameter = "reasoning_effort" [providers.context_windows] "deepseek-v4-flash" = 1000000 "deepseek-v4-pro" = 1000000 -"glm-5.1" = 202752 "glm-5.2" = 202752 "kimi-k2.6" = 262144 "kimi-k2.7-code" = 262144 @@ -6610,49 +6609,109 @@ thinking_parameter = "reasoning_effort" "mimo-v2.5-pro" = 262144 "minimax-m2.7" = 204800 "minimax-m3" = 204800 -"qwen3.6-plus" = 1000000 -"qwen3.7-max" = 1000000 "qwen3.7-plus" = 1000000 [providers.model_metadata."deepseek-v4-flash"] input = ["text"] +[providers.model_metadata."deepseek-v4-flash-vision-exp"] +input = ["text", "image"] + [providers.model_metadata."deepseek-v4-pro"] input = ["text"] -[providers.model_metadata."glm-5.1"] +[providers.model_metadata."deepseek-v4.1-flash"] input = ["text"] [providers.model_metadata."glm-5.2"] input = ["text"] +[providers.model_metadata."glm-5.3"] +input = ["text"] + +[providers.model_metadata."glm-5.3-flash"] +input = ["text"] + +[providers.model_metadata."gpt-5.6-luna"] +api = "openai-responses" +input = ["text", "image"] + +[providers.model_metadata."gpt-6-luna"] +api = "openai-responses" +input = ["text", "image"] + +[providers.model_metadata."grok-4.6"] +api = "openai-responses" +input = ["text", "image"] + +[providers.model_metadata."grok-4.7"] +api = "openai-responses" +input = ["text", "image"] + +[providers.model_metadata."hy3"] +input = ["text"] + +[providers.model_metadata."hy4-preview"] +input = ["text"] + [providers.model_metadata."kimi-k2.6"] input = ["text", "image"] [providers.model_metadata."kimi-k2.7-code"] input = ["text", "image"] +[providers.model_metadata."kimi-k3"] +input = ["text", "image"] + +[providers.model_metadata."longcat-2.0"] +input = ["text"] + +[providers.model_metadata."longcat-2.5-preview-free"] +input = ["text"] + [providers.model_metadata."mimo-v2.5"] input = ["text", "image"] [providers.model_metadata."mimo-v2.5-pro"] input = ["text"] +[providers.model_metadata."mimo-v2.6-flash"] +input = ["text", "image"] + +[providers.model_metadata."mimo-v2.6-pro"] +input = ["text"] + [providers.model_metadata."minimax-m2.7"] +api = "anthropic-messages" input = ["text"] [providers.model_metadata."minimax-m3"] +api = "anthropic-messages" input = ["text", "image"] -[providers.model_metadata."qwen3.6-plus"] -input = ["text", "image"] +[providers.model_metadata."muse-spark-1.2-contributor"] +api = "openai-responses" +input = ["text"] -[providers.model_metadata."qwen3.7-max"] +[providers.model_metadata."muse-spark-1.3-contributor"] +api = "openai-responses" input = ["text"] [providers.model_metadata."qwen3.7-plus"] +api = "anthropic-messages" +input = ["text", "image"] + +[providers.model_metadata."qwen3.8-flash"] +api = "anthropic-messages" +input = ["text", "image"] + +[providers.model_metadata."qwen3.8-max"] +api = "anthropic-messages" input = ["text", "image"] +[providers.model_metadata."space-bunny"] +input = ["text"] + [[providers]] name = "opencode" display_name = "OpenCode Zen" diff --git a/src/tau_coding/data/models-dev-catalog.json b/src/tau_coding/data/models-dev-catalog.json index 7d00a1a0d..36b5c61ea 100644 --- a/src/tau_coding/data/models-dev-catalog.json +++ b/src/tau_coding/data/models-dev-catalog.json @@ -7551,10 +7551,10 @@ "deepseek-v4-flash": { "context_window": 1000000, "cost": { - "cacheRead": 0.007, + "cacheRead": 0.003, "cacheWrite": 0.0, - "input": 0.22, - "output": 0.66 + "input": 0.15, + "output": 0.6 }, "input": [ "text" @@ -7577,10 +7577,10 @@ "deepseek-v4-flash-vision-exp": { "context_window": 1000000, "cost": { - "cacheRead": 0.007, + "cacheRead": 0.003, "cacheWrite": 0.0, - "input": 0.22, - "output": 0.66 + "input": 0.15, + "output": 0.6 }, "input": [ "text", @@ -7627,20 +7627,32 @@ "xhigh" ] }, - "glm-5.1": { - "context_window": 202752, + "deepseek-v4.1-flash": { + "context_window": 1000000, "cost": { - "cacheRead": 0.26, + "cacheRead": 0.003, "cacheWrite": 0.0, - "input": 1.4, - "output": 4.4 + "input": 0.15, + "output": 0.6 }, "input": [ - "text" + "text", + "image" ], - "max_tokens": 32768, - "name": "GLM-5.1", - "reasoning": true + "max_tokens": 384000, + "name": "DeepSeek V4.1 Flash", + "reasoning": true, + "thinking_level_map": { + "high": "high", + "low": "low", + "max": "max" + }, + "unsupported_thinking_levels": [ + "off", + "minimal", + "medium", + "xhigh" + ] }, "glm-5.2": { "context_window": 1000000, @@ -7694,6 +7706,33 @@ "xhigh" ] }, + "glm-5.3-flash": { + "context_window": 1000000, + "cost": { + "cacheRead": 0.03, + "cacheWrite": 0.0, + "input": 0.15, + "output": 0.5 + }, + "input": [ + "text", + "image" + ], + "max_tokens": 131072, + "name": "GLM-5.3-Flash", + "reasoning": true, + "thinking_level_map": { + "high": "high", + "low": "low", + "max": "max" + }, + "unsupported_thinking_levels": [ + "off", + "minimal", + "medium", + "xhigh" + ] + }, "gpt-5.6-luna": { "context_window": 1050000, "cost": { @@ -7736,7 +7775,49 @@ "minimal" ] }, - "grok-4.5": { + "gpt-6-luna": { + "context_window": 1050000, + "cost": { + "cacheRead": 0.01, + "cacheWrite": 0.125, + "input": 0.1, + "output": 0.5 + }, + "cost_tiers": [ + { + "cacheRead": 0.01, + "cacheWrite": 0.125, + "input": 0.1, + "max_input_tokens": 272000, + "output": 0.5 + }, + { + "cacheRead": 0.02, + "cacheWrite": 0.25, + "input": 0.2, + "output": 0.75 + } + ], + "input": [ + "text", + "image" + ], + "max_tokens": 128000, + "name": "GPT-6 Luna", + "reasoning": true, + "thinking_level_map": { + "high": "high", + "low": "low", + "max": "max", + "medium": "medium", + "off": "none", + "xhigh": "xhigh" + }, + "unsupported_thinking_levels": [ + "minimal" + ] + }, + "grok-4.6": { "context_window": 500000, "cost": { "cacheRead": 0.5, @@ -7764,33 +7845,75 @@ "image" ], "max_tokens": 500000, - "name": "Grok 4.5", + "name": "Grok 4.6", "reasoning": true, "thinking_level_map": { "high": "high", "low": "low", - "medium": "medium" + "medium": "medium", + "xhigh": "xhigh" + }, + "unsupported_thinking_levels": [ + "off", + "minimal", + "max" + ] + }, + "grok-4.7": { + "context_window": 500000, + "cost": { + "cacheRead": 0.5, + "cacheWrite": 0.0, + "input": 2.0, + "output": 6.0 + }, + "cost_tiers": [ + { + "cacheRead": 0.5, + "cacheWrite": 0.0, + "input": 2.0, + "max_input_tokens": 200000, + "output": 6.0 + }, + { + "cacheRead": 1.0, + "cacheWrite": 0.0, + "input": 4.0, + "output": 12.0 + } + ], + "input": [ + "text", + "image" + ], + "max_tokens": 500000, + "name": "Grok 4.7", + "reasoning": true, + "thinking_level_map": { + "high": "high", + "low": "low", + "medium": "medium", + "xhigh": "xhigh" }, "unsupported_thinking_levels": [ "off", "minimal", - "xhigh", "max" ] }, "hy3": { "context_window": 256000, "cost": { - "cacheRead": 0.004375, + "cacheRead": 0.035, "cacheWrite": 0.0, - "input": 0.0175, - "output": 0.0725 + "input": 0.14, + "output": 0.58 }, "input": [ "text" ], - "max_tokens": 64000, - "name": "Hy3 (8x usage)", + "max_tokens": 128000, + "name": "Hy3", "reasoning": true, "thinking_level_map": { "high": "high", @@ -7804,6 +7927,32 @@ "max" ] }, + "hy4-preview": { + "context_window": 1024000, + "cost": { + "cacheRead": 0.042, + "cacheWrite": 0.0, + "input": 0.834, + "output": 2.501 + }, + "input": [ + "text" + ], + "max_tokens": 64000, + "name": "Hy4 preview", + "reasoning": true, + "thinking_level_map": { + "high": "high", + "off": "none" + }, + "unsupported_thinking_levels": [ + "minimal", + "low", + "medium", + "xhigh", + "max" + ] + }, "kimi-k2.6": { "context_window": 262144, "cost": { @@ -7863,6 +8012,37 @@ "xhigh" ] }, + "longcat-2.0": { + "context_window": 1000000, + "cost": { + "cacheRead": 0.006, + "cacheWrite": 0.0, + "input": 0.3, + "output": 1.2 + }, + "input": [ + "text" + ], + "max_tokens": 131072, + "name": "LongCat-2.0", + "reasoning": true + }, + "longcat-2.5-preview-free": { + "context_window": 1000000, + "cost": { + "cacheRead": 0.0, + "cacheWrite": 0.0, + "input": 0.0, + "output": 0.0 + }, + "input": [ + "text", + "image" + ], + "max_tokens": 131072, + "name": "LongCat 2.5 Preview Free", + "reasoning": true + }, "mimo-v2.5": { "context_window": 1000000, "cost": { @@ -7894,11 +8074,43 @@ "name": "MiMo V2.5 Pro", "reasoning": true }, + "mimo-v2.6-flash": { + "context_window": 1048576, + "cost": { + "cacheRead": 0.0028, + "cacheWrite": 0.0, + "input": 0.14, + "output": 0.28 + }, + "input": [ + "text", + "image" + ], + "max_tokens": 131072, + "name": "MiMo-V2.6-Flash", + "reasoning": true + }, + "mimo-v2.6-pro": { + "context_window": 1048576, + "cost": { + "cacheRead": 0.003625, + "cacheWrite": 0.0, + "input": 0.435, + "output": 0.87 + }, + "input": [ + "text", + "image" + ], + "max_tokens": 131072, + "name": "MiMo-V2.6-Pro", + "reasoning": true + }, "minimax-m2.7": { "context_window": 204800, "cost": { "cacheRead": 0.06, - "cacheWrite": 0.0, + "cacheWrite": 0.375, "input": 0.3, "output": 1.2 }, @@ -7967,79 +8179,33 @@ "max" ] }, - "ox-alpha-free": { - "context_window": 1000000, + "muse-spark-1.3-contributor": { + "context_window": 1048576, "cost": { - "cacheRead": 0.0, + "cacheRead": 0.002, "cacheWrite": 0.0, - "input": 0.0, - "output": 0.0 + "input": 0.1, + "output": 0.2 }, "input": [ "text", "image" ], "max_tokens": 131072, - "name": "Ox Alpha Free (Unlimited)", + "name": "Muse Spark 1.3 Contributor", "reasoning": true, "thinking_level_map": { "high": "high", "low": "low", - "max": "max" + "medium": "medium", + "minimal": "minimal", + "xhigh": "xhigh" }, "unsupported_thinking_levels": [ "off", - "minimal", - "medium", - "xhigh" + "max" ] }, - "qwen3.6-plus": { - "context_window": 1000000, - "cost": { - "cacheRead": 0.05, - "cacheWrite": 0.625, - "input": 0.5, - "output": 3.0 - }, - "cost_tiers": [ - { - "cacheRead": 0.05, - "cacheWrite": 0.625, - "input": 0.5, - "max_input_tokens": 256000, - "output": 3.0 - }, - { - "cacheRead": 0.2, - "cacheWrite": 2.5, - "input": 2.0, - "output": 6.0 - } - ], - "input": [ - "text", - "image" - ], - "max_tokens": 65536, - "name": "Qwen3.6 Plus", - "reasoning": true - }, - "qwen3.7-max": { - "context_window": 1000000, - "cost": { - "cacheRead": 0.5, - "cacheWrite": 3.125, - "input": 2.5, - "output": 7.5 - }, - "input": [ - "text" - ], - "max_tokens": 65536, - "name": "Qwen3.7 Max", - "reasoning": true - }, "qwen3.7-plus": { "context_window": 1000000, "cost": { @@ -8071,6 +8237,33 @@ "name": "Qwen3.7 Plus", "reasoning": true }, + "qwen3.8-flash": { + "context_window": 1000000, + "cost": { + "cacheRead": 0.016, + "cacheWrite": 0.2, + "input": 0.15, + "output": 0.47 + }, + "input": [ + "text", + "image" + ], + "max_tokens": 131072, + "name": "Qwen3.8 Flash", + "reasoning": true, + "thinking_level_map": { + "low": "low", + "medium": "medium", + "xhigh": "xhigh" + }, + "unsupported_thinking_levels": [ + "off", + "minimal", + "high", + "max" + ] + }, "qwen3.8-max": { "context_window": 1000000, "cost": { @@ -8085,32 +8278,78 @@ ], "max_tokens": 131072, "name": "Qwen3.8 Max", - "reasoning": true + "reasoning": true, + "thinking_level_map": { + "low": "low", + "medium": "medium", + "xhigh": "xhigh" + }, + "unsupported_thinking_levels": [ + "off", + "minimal", + "high", + "max" + ] + }, + "space-bunny": { + "context_window": 1048576, + "cost": { + "cacheRead": 0.03, + "cacheWrite": 0.0, + "input": 0.15, + "output": 0.6 + }, + "input": [ + "text", + "image" + ], + "max_tokens": 524288, + "name": "Space Bunny", + "reasoning": true, + "thinking_level_map": { + "high": "high", + "low": "low", + "max": "max", + "medium": "medium", + "xhigh": "xhigh" + }, + "unsupported_thinking_levels": [ + "off", + "minimal" + ] } }, "models": [ "deepseek-v4-flash", + "deepseek-v4-flash-vision-exp", "deepseek-v4-pro", - "glm-5.1", + "deepseek-v4.1-flash", "glm-5.2", + "glm-5.3", + "glm-5.3-flash", + "gpt-5.6-luna", + "gpt-6-luna", + "grok-4.6", + "grok-4.7", + "hy3", + "hy4-preview", "kimi-k2.6", "kimi-k2.7-code", + "kimi-k3", + "longcat-2.0", + "longcat-2.5-preview-free", "mimo-v2.5", "mimo-v2.5-pro", + "mimo-v2.6-flash", + "mimo-v2.6-pro", "minimax-m2.7", "minimax-m3", - "qwen3.6-plus", - "qwen3.7-max", - "qwen3.7-plus", - "deepseek-v4-flash-vision-exp", - "glm-5.3", - "gpt-5.6-luna", - "grok-4.5", - "hy3", - "kimi-k3", "muse-spark-1.2-contributor", - "ox-alpha-free", - "qwen3.8-max" + "muse-spark-1.3-contributor", + "qwen3.7-plus", + "qwen3.8-flash", + "qwen3.8-max", + "space-bunny" ] }, "openrouter": { diff --git a/src/tau_coding/provider_config.py b/src/tau_coding/provider_config.py index f861241bb..ada54c6d7 100644 --- a/src/tau_coding/provider_config.py +++ b/src/tau_coding/provider_config.py @@ -49,6 +49,7 @@ normalize_thinking_levels, reasoning_effort_for_level, ) +from tau_coding.version import current_version DEFAULT_PROVIDER_NAME = "openai" DEFAULT_MODEL = "gpt-5.4" @@ -1558,7 +1559,10 @@ def _model_base_url(provider: ProviderConfig, model: str | None = None) -> str: def _model_headers(provider: ProviderConfig, model: str | None = None) -> dict[str, str]: selected_model = model or provider.default_model metadata = _metadata_for_model(provider, selected_model) - return {**provider.headers, **(metadata.headers if metadata is not None else {})} + merged = {**provider.headers, **(metadata.headers if metadata is not None else {})} + if provider.name == "opencode-go" and not any(key.casefold() == "user-agent" for key in merged): + merged["User-Agent"] = f"tau/{current_version()}" + return merged def _model_compat(provider: ProviderConfig, model: str | None = None) -> dict[str, Any]: diff --git a/src/tau_coding/provider_runtime.py b/src/tau_coding/provider_runtime.py index 6ca92c4da..2a1b1bae3 100644 --- a/src/tau_coding/provider_runtime.py +++ b/src/tau_coding/provider_runtime.py @@ -311,12 +311,13 @@ def create_model_provider( ) selected_api = compatible_config.api if selected_api == "anthropic-messages": - if credential is None: + if credential is None and provider.name != "opencode-go": raise ProviderConfigError( "Anthropic-protocol models on openai-compatible providers require OAuth" ) + use_oauth = credential is not None gateway_retention, gateway_cache_control_on_tools = anthropic_cache_settings( - provider, model, oauth=True + provider, model, oauth=use_oauth ) anthropic_config = AnthropicConfig( api_key=compatible_config.api_key, @@ -327,9 +328,10 @@ def create_model_provider( max_retries=compatible_config.max_retries, max_retry_delay_seconds=compatible_config.max_retry_delay_seconds, max_tokens=provider_model_max_tokens(provider, model), - bearer_auth=True, + bearer_auth=use_oauth, credential_resolver=compatible_config.credential_resolver, supports_images=compatible_config.supports_images, + compat=dict(compatible_config.compat or {}), # Resolved from compat like the first-party path, so a gateway # proxying real Claude can opt back in per provider or per model. cache_retention=gateway_retention, diff --git a/tests/test_provider_catalog.py b/tests/test_provider_catalog.py index c6ef97d52..e0bd6f052 100644 --- a/tests/test_provider_catalog.py +++ b/tests/test_provider_catalog.py @@ -181,12 +181,20 @@ def test_builtin_catalog_separates_openai_api_and_codex_context_limits() -> None ( "opencode-go", { + "deepseek-v4-flash-vision-exp", + "gpt-5.6-luna", + "gpt-6-luna", + "grok-4.6", + "grok-4.7", "kimi-k2.6", "kimi-k2.7-code", + "kimi-k3", "mimo-v2.5", + "mimo-v2.6-flash", "minimax-m3", - "qwen3.6-plus", "qwen3.7-plus", + "qwen3.8-flash", + "qwen3.8-max", }, ), ( @@ -271,6 +279,46 @@ def test_builtin_catalog_oauth_and_opencode_auth_methods() -> None: assert opencode.api_key_env == "OPENCODE_API_KEY" +def test_builtin_catalog_opencode_go_routes_match_documented_endpoints() -> None: + entry = builtin_provider_entry("opencode-go") + assert entry is not None + assert entry.api == "openai-completions" + assert entry.base_url == "https://opencode.ai/zen/go/v1" + assert entry.compat.get("sendSessionAffinityHeaders") is True + assert entry.compat.get("sessionAffinityFormat") == "opencode" + assert "muse-spark-1.3-contributor" in entry.models + assert "muse-spark-1.2-contributor" in entry.models + + responses_models = { + "grok-4.7", + "grok-4.6", + "gpt-6-luna", + "gpt-5.6-luna", + "muse-spark-1.3-contributor", + "muse-spark-1.2-contributor", + } + anthropic_models = { + "minimax-m3", + "minimax-m2.7", + "qwen3.8-max", + "qwen3.8-flash", + "qwen3.7-plus", + } + assert responses_models <= set(entry.models) + assert anthropic_models <= set(entry.models) + for model in responses_models: + assert entry.model_metadata[model].api == "openai-responses" + for model in anthropic_models: + assert entry.model_metadata[model].api == "anthropic-messages" + assert entry.model_metadata["kimi-k2.7-code"].api is None + assert entry.default_model == "kimi-k2.7-code" + + zen = builtin_provider_entry("opencode") + assert zen is not None + assert zen.base_url == "https://opencode.ai/zen/v1" + assert zen.compat.get("sendSessionAffinityHeaders") is not True + + def test_builtin_catalog_copilot_claude_max_tokens() -> None: entry = builtin_provider_entry("github-copilot") assert entry is not None diff --git a/tests/test_provider_runtime.py b/tests/test_provider_runtime.py index 4dd7f3e3f..df1b6e943 100644 --- a/tests/test_provider_runtime.py +++ b/tests/test_provider_runtime.py @@ -81,6 +81,69 @@ def test_opencode_go_runtime_enables_session_affinity(tmp_path) -> None: assert provider._config.compat["sessionAffinityFormat"] == "opencode" +def test_opencode_go_responses_model_uses_responses_api(tmp_path) -> None: + store = FileCredentialStore(tmp_path / "credentials.json") + store.set_api_key("opencode-go", "go-test") + + provider = create_model_provider( + provider_config_from_catalog_entry("opencode-go"), + credential_store=store, + model="muse-spark-1.3-contributor", + ) + + assert isinstance(provider, OpenAICompatibleProvider) + assert provider._config.api == "openai-responses" + assert provider._config.compat["sessionAffinityFormat"] == "opencode" + + +def test_opencode_go_anthropic_model_uses_api_key_without_oauth(tmp_path) -> None: + store = FileCredentialStore(tmp_path / "credentials.json") + store.set_api_key("opencode-go", "go-test") + + provider = create_model_provider( + provider_config_from_catalog_entry("opencode-go"), + credential_store=store, + model="minimax-m3", + ) + + assert isinstance(provider, AnthropicProvider) + assert provider._config.api_key == "go-test" + assert provider._config.bearer_auth is False + assert provider._config.credential_resolver is None + assert provider._config.compat["sendSessionAffinityHeaders"] is True + assert provider._config.compat["sessionAffinityFormat"] == "opencode" + + +def test_opencode_go_sets_tau_user_agent_only_for_go(tmp_path) -> None: + store = FileCredentialStore(tmp_path / "credentials.json") + store.set_api_key("opencode-go", "go-test") + store.set_api_key("opencode", "zen-test") + + go_provider = create_model_provider( + provider_config_from_catalog_entry("opencode-go"), + credential_store=store, + model="kimi-k2.7-code", + ) + go_anthropic = create_model_provider( + provider_config_from_catalog_entry("opencode-go"), + credential_store=store, + model="qwen3.7-plus", + ) + zen_provider = create_model_provider( + provider_config_from_catalog_entry("opencode"), + credential_store=store, + model="kimi-k2.7-code", + ) + + assert isinstance(go_provider, OpenAICompatibleProvider) + assert isinstance(go_anthropic, AnthropicProvider) + assert isinstance(zen_provider, OpenAICompatibleProvider) + assert go_provider._config.headers["User-Agent"].startswith("tau/") + assert go_anthropic._config.headers["User-Agent"].startswith("tau/") + assert not any(key.casefold() == "user-agent" for key in zen_provider._config.headers) + assert zen_provider._config.compat.get("sendSessionAffinityHeaders") is not True + + def test_huggingface_runtime_pins_backing_provider_with_model_alias(tmp_path) -> None: store = FileCredentialStore(tmp_path / "credentials.json") store.set_api_key("huggingface", "hf-test") diff --git a/tests/test_tau_ai.py b/tests/test_tau_ai.py index 3c5270d51..3d6a13723 100644 --- a/tests/test_tau_ai.py +++ b/tests/test_tau_ai.py @@ -499,6 +499,134 @@ def handler(request: httpx.Request) -> httpx.Response: assert requests[0].headers["x-opencode-session"] == "stable-session-id" +@pytest.mark.anyio +async def test_openai_responses_sends_opencode_session_header() -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response( + 200, + text=( + 'data: {"type":"response.output_text.delta","delta":"ok"}\n\n' + 'data: {"type":"response.completed","response":{"status":"completed"}}\n\n' + ), + headers={"content-type": "text/event-stream"}, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + provider = OpenAICompatibleProvider( + OpenAICompatibleConfig( + api_key="test-key", + base_url="https://opencode.ai/zen/go/v1", + api="openai-responses", + compat={ + "sendSessionAffinityHeaders": True, + "sessionAffinityFormat": "opencode", + }, + ), + client=client, + ) + await _collect( + provider.stream_response( + model="muse-spark-1.3-contributor", + system="You are Tau.", + messages=[UserMessage(content="Say ok")], + tools=[], + session_id="stable-session-id", + ) + ) + + assert requests[0].url.path.endswith("/responses") + assert requests[0].headers["x-opencode-session"] == "stable-session-id" + + +@pytest.mark.anyio +async def test_anthropic_messages_sends_opencode_session_header_with_api_key() -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response( + 200, + text=( + 'data: {"type":"message_start","message":{"content":[]}}\n\n' + 'data: {"type":"content_block_delta","index":0,' + '"delta":{"type":"text_delta","text":"ok"}}\n\n' + 'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}\n\n' + 'data: {"type":"message_stop"}\n\n' + ), + headers={"content-type": "text/event-stream"}, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + provider = AnthropicProvider( + AnthropicConfig( + api_key="test-key", + base_url="https://opencode.ai/zen/go/v1", + headers={"User-Agent": "tau/test"}, + compat={ + "sendSessionAffinityHeaders": True, + "sessionAffinityFormat": "opencode", + }, + ), + client=client, + ) + await _collect( + provider.stream_response( + model="minimax-m3", + system="You are Tau.", + messages=[UserMessage(content="Say ok")], + tools=[], + session_id="stable-session-id", + ) + ) + + assert requests[0].url.path.endswith("/messages") + assert requests[0].headers["x-opencode-session"] == "stable-session-id" + assert requests[0].headers["x-api-key"] == "test-key" + assert "Authorization" not in requests[0].headers + + +@pytest.mark.anyio +async def test_anthropic_messages_omits_session_header_without_opt_in() -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response( + 200, + text=( + 'data: {"type":"message_start","message":{"content":[]}}\n\n' + 'data: {"type":"content_block_delta","index":0,' + '"delta":{"type":"text_delta","text":"ok"}}\n\n' + 'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}\n\n' + 'data: {"type":"message_stop"}\n\n' + ), + headers={"content-type": "text/event-stream"}, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + provider = AnthropicProvider( + AnthropicConfig( + api_key="test-key", + base_url="https://api.anthropic.com/v1", + ), + client=client, + ) + await _collect( + provider.stream_response( + model="claude-test", + system="You are Tau.", + messages=[UserMessage(content="Say ok")], + tools=[], + session_id="stable-session-id", + ) + ) + + assert "x-opencode-session" not in requests[0].headers + + @pytest.mark.anyio async def test_openai_compatible_provider_includes_configured_reasoning_effort() -> None: requests: list[httpx.Request] = [] diff --git a/website/content/guides/providers-and-models.md b/website/content/guides/providers-and-models.md index 907913942..eb3d4046d 100644 --- a/website/content/guides/providers-and-models.md +++ b/website/content/guides/providers-and-models.md @@ -165,6 +165,39 @@ separate `opencode-go` and `opencode` names, allowing different keys when needed. Available models and plan limits change over time; consult the [OpenCode Go](https://opencode.ai/docs/go) and [OpenCode Zen](https://opencode.ai/docs/zen) pages for the current list. +Tau does not change OpenCode Zen behavior. + +Go routes each model to the endpoint in its documentation table. Tau follows +the same per-model routing from its bundled catalog: + +| Go transport | Example models | +| --- | --- | +| `openai-completions` (`/chat/completions`) | GLM, Kimi, LongCat, DeepSeek, MiMo, Hy, Space Bunny | +| `openai-responses` (`/responses`) | Grok 4.6/4.7, GPT 6 Luna, GPT 5.6 Luna, Muse Spark 1.2/1.3 Contributor | +| `anthropic-messages` (`/messages`) | MiniMax M2.7/M3, Qwen 3.7 Plus, Qwen 3.8 Flash/Max | + +Anthropic-protocol Go models use the same Go API key; no OAuth login is +required. For example, `opencode-go/minimax-m3` and +`opencode-go/qwen3.7-plus` authenticate with `x-api-key`, while +`opencode-go/muse-spark-1.3-contributor` uses the Responses API. + +Tau sends a stable `x-opencode-session` header on every Go transport +(completions, responses, and Anthropic messages) and identifies Go requests +with a Tau-specific `User-Agent` (`tau/`) as the Go docs request. +Zen requests are unchanged. + +Muse Spark Contributor models are discounted in exchange for allowing prompts +and completions to be used for training future Meta models, and availability +is limited to regions permitted by Meta's Geographic Use Policy. See the +[Go privacy table](https://opencode.ai/docs/go/) before selecting +`muse-spark-1.2-contributor` or `muse-spark-1.3-contributor`. + +Tau does not display Go dollar usage, monthly/weekly/5-hour allowances, +estimated request counts, or peak/off-peak DeepSeek pricing. Those limits and +token prices change independently of Tau: check the +[Go usage tables](https://opencode.ai/docs/go/) and the OpenCode console for +the current allowance, and expect DeepSeek V4 variants to bill different peak +and off-peak rates that Tau cannot surface per request. ### Z.AI diff --git a/website/content/reference/configuration.md b/website/content/reference/configuration.md index 871f17525..18e05f4c9 100644 --- a/website/content/reference/configuration.md +++ b/website/content/reference/configuration.md @@ -223,7 +223,11 @@ or model: Unknown gateways retain their existing request shape by default. Enable only fields documented by the target service. Codex uses its dedicated `session-id` header -mapping and does not read these OpenAI-compatible settings. +mapping and does not read these OpenAI-compatible settings. The +`anthropic-messages` transport also honors `sendSessionAffinityHeaders` with +`sessionAffinityFormat = "opencode"`; other formats send no header there. +`opencode-go` additionally sends a Tau-specific `User-Agent` (`tau/`) +unless the active provider preferences already define one. ### Anthropic prompt-cache compat keys From 8eddbe545cbe2e75a1b6b984af423794819b4663 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 10 Oct 2026 01:36:52 +0000 Subject: [PATCH 3/3] refactor: use shared session affinity helper from openai_compatible Co-authored-by: vcavichini --- src/tau_ai/anthropic.py | 16 +------ src/tau_coding/catalog_loader.py | 2 + src/tau_coding/data/catalog.toml | 1 + src/tau_coding/provider_catalog.py | 1 + src/tau_coding/provider_config.py | 3 ++ src/tau_coding/provider_runtime.py | 2 +- tests/test_provider_runtime.py | 17 ++++++++ tests/test_tau_ai.py | 49 ++++++++++++++++++++++ website/content/reference/configuration.md | 4 +- 9 files changed, 78 insertions(+), 17 deletions(-) diff --git a/src/tau_ai/anthropic.py b/src/tau_ai/anthropic.py index f16663921..49517c45e 100644 --- a/src/tau_ai/anthropic.py +++ b/src/tau_ai/anthropic.py @@ -47,6 +47,7 @@ from tau_ai.events import AssistantMessageEvent from tau_ai.http import create_async_client from tau_ai.http_errors import provider_http_error_message +from tau_ai.openai_compatible import _apply_session_affinity_headers from tau_ai.provider import CancellationToken from tau_ai.retry import ( RETRYABLE_TRANSPORT_ERRORS, @@ -172,9 +173,7 @@ async def iterator() -> AsyncIterator[ProviderEvent]: headers.setdefault("Authorization", f"Bearer {api_key}") else: headers["x-api-key"] = api_key - _apply_anthropic_session_affinity_headers( - headers, session_id, self._session_affinity_format() - ) + _apply_session_affinity_headers(headers, session_id, self._session_affinity_format()) url = f"{base_url.rstrip('/')}/messages" attempt = 0 @@ -719,17 +718,6 @@ def _anthropic_tool( return payload -def _apply_anthropic_session_affinity_headers( - headers: dict[str, str], - session_id: str | None, - affinity_format: str | None, -) -> None: - """Send stable session affinity for gateways that opt in via compat.""" - if not session_id or affinity_format != "opencode": - return - headers["x-opencode-session"] = session_id - - def _parse_sse_line(line: str) -> str | None: line = line.strip() if not line or not line.startswith("data:"): diff --git a/src/tau_coding/catalog_loader.py b/src/tau_coding/catalog_loader.py index 7a7aa4dec..527b6253a 100644 --- a/src/tau_coding/catalog_loader.py +++ b/src/tau_coding/catalog_loader.py @@ -100,6 +100,7 @@ class _CatalogProvider(BaseModel): default_model: _NonEmptyString docs_url: _NonEmptyString api: ProviderApi | None = None + anthropic_auth: _NonEmptyString | None = None context_windows: dict[_NonEmptyString, _PositiveInt] | None = None headers: dict[_NonEmptyString, _NonEmptyString] = {} compat: dict[_NonEmptyString, Any] = {} @@ -470,6 +471,7 @@ def _entry_from_provider(provider: _CatalogProvider, *, source: str) -> Provider default_model=provider.default_model, docs_url=provider.docs_url, api=provider.api, + anthropic_auth=provider.anthropic_auth, context_windows=context_windows or None, headers=dict(provider.headers), compat=_json_object(provider.compat, f"{prefix}.compat"), diff --git a/src/tau_coding/data/catalog.toml b/src/tau_coding/data/catalog.toml index 507876a1f..d4dfa6597 100644 --- a/src/tau_coding/data/catalog.toml +++ b/src/tau_coding/data/catalog.toml @@ -6594,6 +6594,7 @@ models = ["deepseek-v4-flash", "deepseek-v4-flash-vision-exp", "deepseek-v4-pro" default_model = "kimi-k2.7-code" docs_url = "https://opencode.ai/docs/go" api = "openai-completions" +anthropic_auth = "api-key" compat = { sendSessionAffinityHeaders = true, sessionAffinityFormat = "opencode" } thinking_levels = ["off", "minimal", "low", "medium", "high", "xhigh"] thinking_default = "medium" diff --git a/src/tau_coding/provider_catalog.py b/src/tau_coding/provider_catalog.py index 95d3e93b1..356bfa9d1 100644 --- a/src/tau_coding/provider_catalog.py +++ b/src/tau_coding/provider_catalog.py @@ -81,6 +81,7 @@ class ProviderCatalogEntry: default_model: str docs_url: str api: ProviderApi | None = None + anthropic_auth: str | None = None context_windows: dict[str, int] | None = None headers: dict[str, str] = field(default_factory=dict) compat: dict[str, JSONValue] = field(default_factory=dict) diff --git a/src/tau_coding/provider_config.py b/src/tau_coding/provider_config.py index ada54c6d7..3b7f0bb1d 100644 --- a/src/tau_coding/provider_config.py +++ b/src/tau_coding/provider_config.py @@ -120,6 +120,7 @@ class OpenAICompatibleProviderConfig: api: ProviderApi = "openai-completions" api_key_env: str = "OPENAI_API_KEY" credential_name: str | None = None + anthropic_auth: str | None = None models: tuple[str, ...] = (DEFAULT_MODEL,) default_model: str = DEFAULT_MODEL context_windows: dict[str, int] = field(default_factory=dict) @@ -163,6 +164,7 @@ def to_json(self) -> dict[str, Any]: "api": self.api, "api_key_env": self.api_key_env, "credential_name": self.credential_name, + "anthropic_auth": self.anthropic_auth, "models": list(self.models), "default_model": self.default_model, "context_windows": dict(self.context_windows), @@ -445,6 +447,7 @@ def provider_config_from_entry(entry: ProviderCatalogEntry) -> ProviderConfig: api=entry.api or _default_api_for_kind(entry.kind), api_key_env=entry.api_key_env, credential_name=entry.credential_name, + anthropic_auth=entry.anthropic_auth, models=entry.models, default_model=entry.default_model, context_windows=context_windows, diff --git a/src/tau_coding/provider_runtime.py b/src/tau_coding/provider_runtime.py index 2a1b1bae3..22bf6a6d3 100644 --- a/src/tau_coding/provider_runtime.py +++ b/src/tau_coding/provider_runtime.py @@ -311,7 +311,7 @@ def create_model_provider( ) selected_api = compatible_config.api if selected_api == "anthropic-messages": - if credential is None and provider.name != "opencode-go": + if credential is None and provider.anthropic_auth != "api-key": raise ProviderConfigError( "Anthropic-protocol models on openai-compatible providers require OAuth" ) diff --git a/tests/test_provider_runtime.py b/tests/test_provider_runtime.py index df1b6e943..e58e9edba 100644 --- a/tests/test_provider_runtime.py +++ b/tests/test_provider_runtime.py @@ -144,6 +144,23 @@ def test_opencode_go_sets_tau_user_agent_only_for_go(tmp_path) -> None: assert zen_provider._config.compat.get("sendSessionAffinityHeaders") is not True +def test_anthropic_auth_api_key_allows_anthropic_protocol_without_oauth(tmp_path) -> None: + store = FileCredentialStore(tmp_path / "credentials.json") + store.set_api_key("test", "key") + config = OpenAICompatibleProviderConfig( + name="test", + api_key_env="TEST_KEY", + credential_name="test", + anthropic_auth="api-key", + models=("m",), + default_model="m", + model_metadata={"m": ProviderModelMetadata(api="anthropic-messages")}, + ) + + provider = create_model_provider(config, credential_store=store, model="m") + assert isinstance(provider, AnthropicProvider) + + def test_huggingface_runtime_pins_backing_provider_with_model_alias(tmp_path) -> None: store = FileCredentialStore(tmp_path / "credentials.json") store.set_api_key("huggingface", "hf-test") diff --git a/tests/test_tau_ai.py b/tests/test_tau_ai.py index 3d6a13723..71e64ef4f 100644 --- a/tests/test_tau_ai.py +++ b/tests/test_tau_ai.py @@ -627,6 +627,55 @@ def handler(request: httpx.Request) -> httpx.Response: assert "x-opencode-session" not in requests[0].headers +@pytest.mark.anyio +@pytest.mark.parametrize( + ("affinity_format", "expected_header"), + [("openrouter", "x-session-id"), ("opencode", "x-opencode-session"), ("openai", "session_id")], +) +async def test_anthropic_messages_sends_session_affinity_header( + affinity_format: str, expected_header: str +) -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response( + 200, + text=( + 'data: {"type":"message_start","message":{"content":[]}}\n\n' + 'data: {"type":"content_block_delta","index":0,' + '"delta":{"type":"text_delta","text":"ok"}}\n\n' + 'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}\n\n' + 'data: {"type":"message_stop"}\n\n' + ), + headers={"content-type": "text/event-stream"}, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + provider = AnthropicProvider( + AnthropicConfig( + api_key="test-key", + base_url="https://test.example", + compat={ + "sendSessionAffinityHeaders": True, + "sessionAffinityFormat": affinity_format, + }, + ), + client=client, + ) + await _collect( + provider.stream_response( + model="test", + system="You are Tau.", + messages=[UserMessage(content="Say ok")], + tools=[], + session_id="sid", + ) + ) + + assert requests[0].headers[expected_header] == "sid" + + @pytest.mark.anyio async def test_openai_compatible_provider_includes_configured_reasoning_effort() -> None: requests: list[httpx.Request] = [] diff --git a/website/content/reference/configuration.md b/website/content/reference/configuration.md index 18e05f4c9..863672fe4 100644 --- a/website/content/reference/configuration.md +++ b/website/content/reference/configuration.md @@ -224,8 +224,8 @@ or model: Unknown gateways retain their existing request shape by default. Enable only fields documented by the target service. Codex uses its dedicated `session-id` header mapping and does not read these OpenAI-compatible settings. The -`anthropic-messages` transport also honors `sendSessionAffinityHeaders` with -`sessionAffinityFormat = "opencode"`; other formats send no header there. +`anthropic-messages` transport honors `sendSessionAffinityHeaders` and all three +formats; `google-generative-ai` and `mistral-conversations` currently ignore them. `opencode-go` additionally sends a Tau-specific `User-Agent` (`tau/`) unless the active provider preferences already define one.