Skip to content
Closed
22 changes: 21 additions & 1 deletion src/tau_agent/loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,10 +231,30 @@ async def _execute_tool(
)

if result.tool_call_id != tool_call.id:
return result.model_copy(update={"tool_call_id": tool_call.id})
return _replace_tool_call_id(result, tool_call.id)
return result


def _replace_tool_call_id(result: AgentToolResult, tool_call_id: str) -> AgentToolResult:
"""Return a copy of a tool result with a provider-issued tool call id.

Tau runs on constrained Python environments that may expose Pydantic v1-style
models or local shims. Prefer Pydantic v2's ``model_copy`` when present, but
fall back to constructing a new result from the public fields.
"""
model_copy = getattr(result, "model_copy", None)
if callable(model_copy):
return model_copy(update={"tool_call_id": tool_call_id})
return AgentToolResult(
tool_call_id=tool_call_id,
name=result.name,
ok=result.ok,
content=result.content,
error=result.error,
data=result.data,
)


def _unknown_tool_result(tool_call: ToolCall) -> AgentToolResult:
message = f"Unknown tool: {tool_call.name}"
return AgentToolResult(
Expand Down
3 changes: 3 additions & 0 deletions src/tau_ai/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
ProviderToolCallEvent,
)
from tau_ai.fake import FakeProvider
from tau_ai.models import ModelInfo, list_openai_compatible_models
from tau_ai.openai_codex import (
DEFAULT_OPENAI_CODEX_BASE_URL,
OpenAICodexConfig,
Expand All @@ -42,6 +43,7 @@
"DEFAULT_OPENAI_COMPATIBLE_TIMEOUT_SECONDS",
"DEFAULT_OPENAI_CODEX_BASE_URL",
"FakeProvider",
"ModelInfo",
"ModelProvider",
"OpenAICodexConfig",
"OpenAICodexCredentials",
Expand All @@ -56,5 +58,6 @@
"ProviderThinkingDeltaEvent",
"ProviderTextDeltaEvent",
"ProviderToolCallEvent",
"list_openai_compatible_models",
"openai_compatible_config_from_env",
]
50 changes: 47 additions & 3 deletions src/tau_ai/anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

from collections.abc import AsyncIterator, Mapping
from hashlib import sha1
from json import loads
from typing import Any

Expand Down Expand Up @@ -67,12 +68,16 @@ async def iterator() -> AsyncIterator[ProviderEvent]:
tools=tools,
thinking_budget_tokens=self._config.thinking_budget_tokens,
)
_sanitize_anthropic_payload_tool_ids(payload)
headers = {
**(dict(self._config.headers or {})),
"anthropic-version": ANTHROPIC_VERSION,
"content-type": "application/json",
"x-api-key": self._config.api_key,
}
if self._config.auth_header.lower() == "authorization":
headers["Authorization"] = f"Bearer {self._config.api_key}"
else:
headers["x-api-key"] = self._config.api_key
url = f"{self._config.base_url.rstrip('/')}/messages"

attempt = 0
Expand Down Expand Up @@ -293,7 +298,7 @@ def _anthropic_message(message: AgentMessage) -> dict[str, JSONValue]:
content.append(
{
"type": "tool_use",
"id": tool_call.id,
"id": _anthropic_tool_id(tool_call.id),
"name": tool_call.name,
"input": tool_call.arguments,
}
Expand All @@ -305,7 +310,7 @@ def _anthropic_message(message: AgentMessage) -> dict[str, JSONValue]:
"content": [
{
"type": "tool_result",
"tool_use_id": message.tool_call_id,
"tool_use_id": _anthropic_tool_id(message.tool_call_id),
"content": message.content,
"is_error": not message.ok,
}
Expand All @@ -314,6 +319,45 @@ def _anthropic_message(message: AgentMessage) -> dict[str, JSONValue]:
raise TypeError(f"Unsupported message type: {type(message).__name__}")


def _anthropic_tool_id(value: str) -> str:
"""Return a tool-use id accepted by Anthropic's Messages API."""
cleaned = "".join(ch if ch.isalnum() or ch in {"_", "-"} else "_" for ch in value)
cleaned = cleaned.strip("_") or "tool_call"
if len(cleaned) <= 64:
return cleaned
suffix = "_" + sha1(value.encode("utf-8")).hexdigest()[:10]
return (cleaned[: 64 - len(suffix)].rstrip("_") or "tool_call") + suffix



def _sanitize_anthropic_payload_tool_ids(payload: dict[str, JSONValue]) -> None:
"""Defensively sanitize tool IDs in the final Anthropic payload."""
messages = payload.get("messages")
if not isinstance(messages, list):
return
id_map: dict[str, str] = {}
for message in messages:
if not isinstance(message, dict):
continue
content = message.get("content")
if not isinstance(content, list):
continue
for block in content:
if not isinstance(block, dict):
continue
if block.get("type") == "tool_use":
raw = block.get("id")
if isinstance(raw, str):
clean = _anthropic_tool_id(raw)
id_map[raw] = clean
block["id"] = clean
elif block.get("type") == "tool_result":
raw = block.get("tool_use_id")
if isinstance(raw, str):
block["tool_use_id"] = id_map.get(raw, _anthropic_tool_id(raw))



def _anthropic_tool(tool: AgentTool) -> dict[str, JSONValue]:
return {
"name": tool.name,
Expand Down
5 changes: 5 additions & 0 deletions src/tau_ai/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@
from collections.abc import Mapping
from dataclasses import dataclass
from os import environ
from typing import Literal

DEFAULT_OPENAI_COMPATIBLE_BASE_URL = "https://api.openai.com/v1"
DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com/v1"
DEFAULT_OPENAI_COMPATIBLE_TIMEOUT_SECONDS = 60.0
DEFAULT_OPENAI_COMPATIBLE_MAX_RETRIES = 2
DEFAULT_OPENAI_COMPATIBLE_MAX_RETRY_DELAY_SECONDS = 1.0

AnthropicThinkingType = Literal["adaptive", "disabled"]


@dataclass(frozen=True, slots=True)
class OpenAICompatibleConfig:
Expand All @@ -38,6 +41,8 @@ class AnthropicConfig:
max_retries: int = DEFAULT_OPENAI_COMPATIBLE_MAX_RETRIES
max_retry_delay_seconds: float = DEFAULT_OPENAI_COMPATIBLE_MAX_RETRY_DELAY_SECONDS
thinking_budget_tokens: int | None = None
thinking_type: AnthropicThinkingType | None = None
auth_header: str = "x-api-key"


def openai_compatible_config_from_env(
Expand Down
93 changes: 93 additions & 0 deletions src/tau_ai/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""Model discovery for OpenAI-compatible providers.

Some OpenAI-compatible endpoints (for example Nebius Token Factory) expose a
``GET /v1/models`` listing that can be expanded with a ``verbose`` query
parameter. Tau uses this to populate a provider's model list dynamically at
build time instead of hardcoding a catalog.
"""

from __future__ import annotations

from collections.abc import Mapping
from dataclasses import dataclass
from json import loads
from typing import Any

import httpx

from tau_ai.env import OpenAICompatibleConfig


@dataclass(frozen=True, slots=True)
class ModelInfo:
"""One model advertised by an OpenAI-compatible ``/models`` endpoint."""

id: str
context_window: int | None = None


async def list_openai_compatible_models(
config: OpenAICompatibleConfig,
*,
verbose: bool = False,
client: httpx.AsyncClient | None = None,
) -> tuple[ModelInfo, ...]:
"""List models from an OpenAI-compatible ``/models`` endpoint.

When ``verbose`` is true, the ``verbose=true`` query parameter is sent so
providers that support it (such as Nebius Token Factory) return the full
model catalog with metadata. The response is parsed tolerantly: only the
``id`` of each entry in ``data`` is required, and an optional integer
context-window field is extracted when present.
"""
headers: dict[str, str] = {**(dict(config.headers or {}))}
headers.setdefault("Authorization", f"Bearer {config.api_key}")
params: dict[str, str] = {}
if verbose:
params["verbose"] = "true"
url = f"{config.base_url.rstrip('/')}/models"

owns_client = client is None
http_client = client or httpx.AsyncClient(timeout=config.timeout_seconds)
try:
response = await http_client.get(url, headers=headers, params=params)
response.raise_for_status()
payload = loads(response.content)
finally:
if owns_client:
await http_client.aclose()

data = _data_array(payload)
models: list[ModelInfo] = []
seen: set[str] = set()
for entry in data:
model_id = _model_id(entry)
if model_id is None or model_id in seen:
continue
seen.add(model_id)
models.append(ModelInfo(id=model_id, context_window=_context_window(entry)))
return tuple(models)


def _data_array(payload: object) -> list[Mapping[str, Any]]:
if not isinstance(payload, Mapping):
return []
data = payload.get("data")
if not isinstance(data, list):
return []
return [item for item in data if isinstance(item, Mapping)]


def _model_id(entry: Mapping[str, Any]) -> str | None:
model_id = entry.get("id")
if isinstance(model_id, str) and model_id.strip():
return model_id.strip()
return None


def _context_window(entry: Mapping[str, Any]) -> int | None:
for field_name in ("context_window", "context_length", "max_context_length"):
value = entry.get(field_name)
if isinstance(value, int) and not isinstance(value, bool) and value > 0:
return value
return None
29 changes: 26 additions & 3 deletions src/tau_ai/openai_codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
from dataclasses import dataclass
from hashlib import sha1
from json import JSONDecodeError, dumps, loads
from platform import machine, release, system
from typing import Any
Expand Down Expand Up @@ -318,15 +319,16 @@ def _messages_to_responses_input(messages: list[AgentMessage]) -> list[JSONValue
call_id, item_id = _split_tool_call_id(tool_call.id)
item: dict[str, JSONValue] = {
"type": "function_call",
"call_id": call_id,
"name": tool_call.name,
"call_id": _codex_call_id(call_id),
"name": tool_call.name or "tool",
"arguments": dumps(tool_call.arguments),
}
if item_id:
item["id"] = item_id
item["id"] = _codex_item_id(item_id)
items.append(item)
elif isinstance(message, ToolResultMessage):
call_id, _item_id = _split_tool_call_id(message.tool_call_id)
call_id = _codex_call_id(call_id)
items.append(
{
"type": "function_call_output",
Expand All @@ -337,6 +339,27 @@ def _messages_to_responses_input(messages: list[AgentMessage]) -> list[JSONValue
return items


def _codex_identifier(value: str, *, fallback: str) -> str:
"""Return a Codex Responses identifier safe for replayed transcript items."""
cleaned = "".join(ch if ch.isalnum() or ch in {"_", "-"} else "_" for ch in value)
cleaned = cleaned.strip("_") or fallback
if len(cleaned) <= 64:
return cleaned
suffix = "_" + sha1(value.encode("utf-8")).hexdigest()[:10]
return (cleaned[: 64 - len(suffix)].rstrip("_") or fallback) + suffix



def _codex_call_id(value: str) -> str:
return _codex_identifier(value, fallback="call")



def _codex_item_id(value: str) -> str:
return _codex_identifier(value, fallback="item")



def _tool_to_codex(tool: AgentTool) -> dict[str, JSONValue]:
return {
"type": "function",
Expand Down
27 changes: 22 additions & 5 deletions src/tau_ai/openai_compatible.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from __future__ import annotations

from collections.abc import AsyncIterator, Callable, Mapping
from hashlib import sha1
from json import JSONDecodeError, dumps, loads
from typing import Any, Protocol

Expand Down Expand Up @@ -625,22 +626,38 @@ def _messages_to_responses_input(
items.append(
{
"type": "function_call",
"call_id": tool_call.id,
"name": tool_call.name,
"call_id": _responses_call_id(tool_call.id),
"name": tool_call.name or "tool",
"arguments": dumps(tool_call.arguments),
}
)
elif isinstance(message, ToolResultMessage):
items.append(
{
"type": "function_call_output",
"call_id": message.tool_call_id,
"call_id": _responses_call_id(message.tool_call_id),
"output": message.content,
}
)
return items


def _responses_call_id(value: str) -> str:
"""Return a Responses API call_id accepted by OpenAI-compatible backends.

Provider transcripts can persist foreign tool-call IDs with separators or
long opaque suffixes. Normalize separators and add a short hash suffix when
truncating so function_call/function_call_output pairs remain stable.
"""
cleaned = "".join(ch if ch.isalnum() or ch in {"_", "-"} else "_" for ch in value)
cleaned = cleaned.strip("_") or "call"
if len(cleaned) <= 64:
return cleaned
suffix = "_" + sha1(value.encode("utf-8")).hexdigest()[:10]
return (cleaned[: 64 - len(suffix)].rstrip("_") or "call") + suffix



def _tool_to_responses(tool: AgentTool) -> dict[str, JSONValue]:
return {
"type": "function",
Expand Down Expand Up @@ -769,7 +786,7 @@ def _message_to_openai(message: AgentMessage) -> dict[str, JSONValue]:
return {
"role": "tool",
"tool_call_id": message.tool_call_id,
"name": message.name,
"name": message.name or "tool",
"content": message.content,
}

Expand All @@ -790,7 +807,7 @@ def _tool_call_to_openai(tool_call: ToolCall) -> dict[str, JSONValue]:
"id": tool_call.id,
"type": "function",
"function": {
"name": tool_call.name,
"name": tool_call.name or "tool",
"arguments": dumps(tool_call.arguments),
},
}
Expand Down
Loading