tau-coding-agent 0.1.0__py3-none-any.whl
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.
- tau/__init__.py +0 -0
- tau/agent/__init__.py +11 -0
- tau/agent/prompt/__init__.py +10 -0
- tau/agent/prompt/builder.py +302 -0
- tau/agent/prompt/types.py +33 -0
- tau/agent/service.py +369 -0
- tau/agent/types.py +61 -0
- tau/auth/manager.py +247 -0
- tau/auth/storage.py +82 -0
- tau/auth/types.py +41 -0
- tau/builtins/__init__.py +4 -0
- tau/builtins/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/__pycache__/__init__.cpython-314.pyc +0 -0
- tau/builtins/commands/__init__.py +41 -0
- tau/builtins/commands/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/commands/__pycache__/__init__.cpython-314.pyc +0 -0
- tau/builtins/commands/__pycache__/clear.cpython-313.pyc +0 -0
- tau/builtins/commands/__pycache__/clear.cpython-314.pyc +0 -0
- tau/builtins/commands/__pycache__/compact.cpython-313.pyc +0 -0
- tau/builtins/commands/__pycache__/compact.cpython-314.pyc +0 -0
- tau/builtins/commands/__pycache__/reload.cpython-313.pyc +0 -0
- tau/builtins/commands/__pycache__/reload.cpython-314.pyc +0 -0
- tau/builtins/commands/__pycache__/session.cpython-313.pyc +0 -0
- tau/builtins/commands/__pycache__/session.cpython-314.pyc +0 -0
- tau/builtins/commands/clear.py +16 -0
- tau/builtins/commands/compact.py +28 -0
- tau/builtins/commands/reload.py +27 -0
- tau/builtins/commands/session.py +19 -0
- tau/builtins/extensions/footer/__init__.py +76 -0
- tau/builtins/extensions/footer/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/extensions/footer/__pycache__/git.cpython-313.pyc +0 -0
- tau/builtins/extensions/footer/__pycache__/model.cpython-313.pyc +0 -0
- tau/builtins/extensions/footer/__pycache__/utils.cpython-313.pyc +0 -0
- tau/builtins/extensions/footer/git.py +26 -0
- tau/builtins/extensions/footer/model.py +69 -0
- tau/builtins/extensions/footer/utils.py +44 -0
- tau/builtins/extensions/header/__init__.py +18 -0
- tau/builtins/extensions/header/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/models/__init__.py +0 -0
- tau/builtins/models/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/models/__pycache__/text.cpython-313.pyc +0 -0
- tau/builtins/models/audio.py +43 -0
- tau/builtins/models/image.py +43 -0
- tau/builtins/models/text.py +482 -0
- tau/builtins/models/video.py +40 -0
- tau/builtins/prompts/commit.md +7 -0
- tau/builtins/prompts/docs.md +7 -0
- tau/builtins/prompts/explain.md +7 -0
- tau/builtins/prompts/fix.md +7 -0
- tau/builtins/prompts/refactor.md +7 -0
- tau/builtins/prompts/review.md +7 -0
- tau/builtins/prompts/test.md +7 -0
- tau/builtins/providers/__init__.py +0 -0
- tau/builtins/providers/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/providers/__pycache__/text.cpython-313.pyc +0 -0
- tau/builtins/providers/audio.py +10 -0
- tau/builtins/providers/image.py +9 -0
- tau/builtins/providers/text.py +33 -0
- tau/builtins/providers/video.py +6 -0
- tau/builtins/skills/code-review/SKILL.md +4 -0
- tau/builtins/skills/debug/SKILL.md +4 -0
- tau/builtins/skills/git-commit/SKILL.md +4 -0
- tau/builtins/themes/dark.yaml +1 -0
- tau/builtins/themes/light.yaml +46 -0
- tau/builtins/tools/__init__.py +73 -0
- tau/builtins/tools/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/__init__.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/bash.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/bash.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/edit.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/edit.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/glob.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/glob.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/grep.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/grep.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/ls.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/ls.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/read.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/read.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/terminal.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/terminal.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/write.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/write.cpython-314.pyc +0 -0
- tau/builtins/tools/edit.py +215 -0
- tau/builtins/tools/glob.py +112 -0
- tau/builtins/tools/grep.py +146 -0
- tau/builtins/tools/ls.py +135 -0
- tau/builtins/tools/read.py +122 -0
- tau/builtins/tools/terminal.py +150 -0
- tau/builtins/tools/write.py +105 -0
- tau/commands/__init__.py +10 -0
- tau/commands/registry.py +71 -0
- tau/commands/types.py +33 -0
- tau/console/__init__.py +0 -0
- tau/console/cli.py +266 -0
- tau/console/commands/__init__.py +0 -0
- tau/console/commands/auth.py +193 -0
- tau/console/commands/packages.py +104 -0
- tau/console/commands/update.py +76 -0
- tau/core/__init__.py +0 -0
- tau/core/registry.py +102 -0
- tau/engine/__init__.py +47 -0
- tau/engine/service.py +768 -0
- tau/engine/types.py +163 -0
- tau/extensions/__init__.py +28 -0
- tau/extensions/api.py +928 -0
- tau/extensions/context.py +462 -0
- tau/extensions/events.py +70 -0
- tau/extensions/loader.py +386 -0
- tau/extensions/runtime.py +184 -0
- tau/extensions/settings.py +137 -0
- tau/hooks/__init__.py +112 -0
- tau/hooks/engine.py +237 -0
- tau/hooks/inference.py +21 -0
- tau/hooks/runtime.py +126 -0
- tau/hooks/service.py +121 -0
- tau/hooks/session.py +117 -0
- tau/hooks/tui.py +61 -0
- tau/hooks/types.py +72 -0
- tau/inference/__init__.py +180 -0
- tau/inference/api/__init__.py +0 -0
- tau/inference/api/audio/__init__.py +0 -0
- tau/inference/api/audio/base.py +29 -0
- tau/inference/api/audio/builtins.py +15 -0
- tau/inference/api/audio/elevenlabs_audio.py +183 -0
- tau/inference/api/audio/gemini_audio.py +95 -0
- tau/inference/api/audio/openai_audio.py +159 -0
- tau/inference/api/audio/registry.py +15 -0
- tau/inference/api/audio/sarvam_audio.py +163 -0
- tau/inference/api/audio/service.py +103 -0
- tau/inference/api/audio/utils.py +47 -0
- tau/inference/api/image/__init__.py +0 -0
- tau/inference/api/image/base.py +17 -0
- tau/inference/api/image/builtins.py +8 -0
- tau/inference/api/image/gemini_image.py +77 -0
- tau/inference/api/image/openai_image.py +103 -0
- tau/inference/api/image/openrouter.py +144 -0
- tau/inference/api/image/registry.py +15 -0
- tau/inference/api/image/service.py +71 -0
- tau/inference/api/registry.py +82 -0
- tau/inference/api/text/__init__.py +0 -0
- tau/inference/api/text/anthropic_claude_code.py +222 -0
- tau/inference/api/text/anthropic_messages.py +196 -0
- tau/inference/api/text/base.py +40 -0
- tau/inference/api/text/builtins.py +19 -0
- tau/inference/api/text/gemini_generate.py +234 -0
- tau/inference/api/text/github_copilot_chat.py +172 -0
- tau/inference/api/text/google_antigravity.py +522 -0
- tau/inference/api/text/mistral_chat.py +284 -0
- tau/inference/api/text/ollama_chat.py +200 -0
- tau/inference/api/text/openai_codex_responses.py +497 -0
- tau/inference/api/text/openai_completions.py +227 -0
- tau/inference/api/text/openai_responses.py +235 -0
- tau/inference/api/text/registry.py +50 -0
- tau/inference/api/text/service.py +297 -0
- tau/inference/api/text/types.py +7 -0
- tau/inference/api/text/utils.py +228 -0
- tau/inference/api/video/__init__.py +0 -0
- tau/inference/api/video/base.py +26 -0
- tau/inference/api/video/builtins.py +7 -0
- tau/inference/api/video/fal_video.py +119 -0
- tau/inference/api/video/openrouter_video.py +142 -0
- tau/inference/api/video/registry.py +15 -0
- tau/inference/api/video/service.py +72 -0
- tau/inference/model/__init__.py +0 -0
- tau/inference/model/registry.py +102 -0
- tau/inference/model/types.py +65 -0
- tau/inference/provider/__init__.py +0 -0
- tau/inference/provider/oauth/__init__.py +35 -0
- tau/inference/provider/oauth/anthropic_claude_code.py +286 -0
- tau/inference/provider/oauth/github_copilot.py +333 -0
- tau/inference/provider/oauth/google_antigravity.py +258 -0
- tau/inference/provider/oauth/openai_codex.py +309 -0
- tau/inference/provider/oauth/pkce.py +14 -0
- tau/inference/provider/oauth/types.py +46 -0
- tau/inference/provider/oauth/utils.py +154 -0
- tau/inference/provider/registry.py +141 -0
- tau/inference/provider/types.py +114 -0
- tau/inference/types.py +549 -0
- tau/inference/utils.py +219 -0
- tau/message/__init__.py +0 -0
- tau/message/types.py +482 -0
- tau/message/utils.py +178 -0
- tau/packages/__init__.py +11 -0
- tau/packages/manager.py +190 -0
- tau/packages/types.py +20 -0
- tau/packages/utils.py +67 -0
- tau/prompts/expand.py +58 -0
- tau/prompts/loader.py +69 -0
- tau/prompts/registry.py +45 -0
- tau/prompts/types.py +24 -0
- tau/rpc/__init__.py +8 -0
- tau/rpc/mode.py +783 -0
- tau/rpc/types.py +252 -0
- tau/runtime/service.py +759 -0
- tau/runtime/types.py +303 -0
- tau/session/branch_summarization.py +312 -0
- tau/session/compaction.py +646 -0
- tau/session/manager.py +652 -0
- tau/session/types.py +188 -0
- tau/session/utils.py +233 -0
- tau/settings/manager.py +1077 -0
- tau/settings/paths.py +150 -0
- tau/settings/storage.py +63 -0
- tau/settings/types.py +173 -0
- tau/settings/utils.py +25 -0
- tau/skills/loader.py +91 -0
- tau/skills/registry.py +70 -0
- tau/skills/types.py +25 -0
- tau/themes/loader.py +238 -0
- tau/themes/registry.py +108 -0
- tau/themes/types.py +19 -0
- tau/tool/__init__.py +3 -0
- tau/tool/registry.py +117 -0
- tau/tool/render.py +21 -0
- tau/tool/types.py +244 -0
- tau/trust/__init__.py +13 -0
- tau/trust/manager.py +80 -0
- tau/trust/types.py +14 -0
- tau/trust/utils.py +72 -0
- tau/tui/__init__.py +54 -0
- tau/tui/agent_hooks.py +346 -0
- tau/tui/ansi.py +330 -0
- tau/tui/app.py +540 -0
- tau/tui/autocomplete.py +33 -0
- tau/tui/capabilities.py +119 -0
- tau/tui/commands/__init__.py +3 -0
- tau/tui/commands/appearance.py +498 -0
- tau/tui/commands/auth.py +232 -0
- tau/tui/commands/context.py +38 -0
- tau/tui/commands/misc.py +82 -0
- tau/tui/commands/model.py +118 -0
- tau/tui/commands/session.py +464 -0
- tau/tui/component.py +268 -0
- tau/tui/components/__init__.py +0 -0
- tau/tui/components/autocomplete_manager.py +267 -0
- tau/tui/components/autocomplete_picker.py +143 -0
- tau/tui/components/box.py +90 -0
- tau/tui/components/command_palette.py +144 -0
- tau/tui/components/dynamic_border.py +19 -0
- tau/tui/components/file_picker.py +233 -0
- tau/tui/components/image.py +181 -0
- tau/tui/components/inline_selector.py +71 -0
- tau/tui/components/layout.py +1194 -0
- tau/tui/components/message_list.py +692 -0
- tau/tui/components/modal.py +97 -0
- tau/tui/components/model_palette.py +204 -0
- tau/tui/components/picker_overlay.py +174 -0
- tau/tui/components/prompt_overlay.py +236 -0
- tau/tui/components/resume_modal.py +372 -0
- tau/tui/components/select_list.py +222 -0
- tau/tui/components/settings_modal.py +274 -0
- tau/tui/components/settings_schema.py +203 -0
- tau/tui/components/spinner.py +119 -0
- tau/tui/components/text_input.py +396 -0
- tau/tui/components/text_prompt.py +82 -0
- tau/tui/components/tree_select_list.py +580 -0
- tau/tui/components/trust_screen.py +97 -0
- tau/tui/diff.py +114 -0
- tau/tui/fuzzy.py +99 -0
- tau/tui/input.py +496 -0
- tau/tui/input_handler.py +716 -0
- tau/tui/keybindings.py +87 -0
- tau/tui/markdown.py +286 -0
- tau/tui/message_renderers.py +31 -0
- tau/tui/overlay.py +326 -0
- tau/tui/renderer.py +378 -0
- tau/tui/terminal.py +499 -0
- tau/tui/theme.py +148 -0
- tau/tui/tui.py +544 -0
- tau/tui/ui_context.py +768 -0
- tau/tui/utils.py +20 -0
- tau/utils/__init__.py +0 -0
- tau/utils/http_proxy.py +221 -0
- tau/utils/image_processing.py +172 -0
- tau/utils/secrets.py +59 -0
- tau/utils/version_check.py +60 -0
- tau_coding_agent-0.1.0.dist-info/METADATA +177 -0
- tau_coding_agent-0.1.0.dist-info/RECORD +283 -0
- tau_coding_agent-0.1.0.dist-info/WHEEL +5 -0
- tau_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
- tau_coding_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
- tau_coding_agent-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import json
|
|
3
|
+
from tau.inference.api.text.utils import parse_tool_args, openai_user_content, openai_response_format
|
|
4
|
+
from collections.abc import AsyncGenerator, AsyncIterator
|
|
5
|
+
from typing import Any
|
|
6
|
+
from mistralai.client import Mistral
|
|
7
|
+
from mistralai.client.models import ThinkChunk, TextChunk
|
|
8
|
+
from mistralai.client.types import UNSET
|
|
9
|
+
from tau.inference.api.text.base import BaseLLMAPI as BaseAPI
|
|
10
|
+
from tau.inference.model.types import Model
|
|
11
|
+
from tau.inference.types import (
|
|
12
|
+
LLMContext, LLMEvent, LLMOptions, StopReason, ThinkingLevel,
|
|
13
|
+
StartEvent, EndEvent, ErrorEvent,
|
|
14
|
+
TextStartEvent, TextDeltaEvent, TextEndEvent,
|
|
15
|
+
ThinkingStartEvent, ThinkingDeltaEvent, ThinkingEndEvent,
|
|
16
|
+
ToolCallStartEvent, ToolCallDeltaEvent, ToolCallEndEvent,
|
|
17
|
+
)
|
|
18
|
+
from tau.message.types import (
|
|
19
|
+
SystemMessage, UserMessage, AssistantMessage, ToolMessage,
|
|
20
|
+
TextContent, ImageContent, ThinkingContent, ToolCallContent, ToolResultContent,
|
|
21
|
+
)
|
|
22
|
+
from typing import Optional, TYPE_CHECKING
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from tau.tool.types import Tool
|
|
25
|
+
|
|
26
|
+
_STOP_REASON: dict[str, StopReason] = {
|
|
27
|
+
"stop": StopReason.Stop,
|
|
28
|
+
"length": StopReason.Length,
|
|
29
|
+
"model_length": StopReason.Length,
|
|
30
|
+
"tool_calls": StopReason.ToolCalls,
|
|
31
|
+
"error": StopReason.Error,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
_MINIMAL_LEVELS = {ThinkingLevel.Low, ThinkingLevel.Minimal}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _messages_to_mistral(messages: list[LLMMessage]) -> list[dict[str, Any]]:
|
|
39
|
+
"""Convert a message list to Mistral Chat API format, preserving thinking content blocks."""
|
|
40
|
+
result: list[dict[str, Any]] = []
|
|
41
|
+
for msg in messages:
|
|
42
|
+
match msg:
|
|
43
|
+
case SystemMessage():
|
|
44
|
+
text = "\n".join(c.content for c in msg.contents if isinstance(c, TextContent))
|
|
45
|
+
result.append({"role": "system", "content": text})
|
|
46
|
+
case UserMessage():
|
|
47
|
+
if not msg.contents:
|
|
48
|
+
continue
|
|
49
|
+
result.append({"role": "user", "content": openai_user_content(msg.contents)})
|
|
50
|
+
case AssistantMessage():
|
|
51
|
+
text_parts: list[str] = []
|
|
52
|
+
tool_calls: list[dict[str, Any]] = []
|
|
53
|
+
content_chunks: list[dict[str, Any]] = []
|
|
54
|
+
has_thinking = any(isinstance(c, ThinkingContent) for c in msg.contents)
|
|
55
|
+
for item in msg.contents:
|
|
56
|
+
match item:
|
|
57
|
+
case ThinkingContent():
|
|
58
|
+
content_chunks.append({
|
|
59
|
+
"type": "thinking",
|
|
60
|
+
"thinking": [{"type": "text", "text": item.content}],
|
|
61
|
+
"signature": item.signature,
|
|
62
|
+
})
|
|
63
|
+
case TextContent():
|
|
64
|
+
if has_thinking:
|
|
65
|
+
content_chunks.append({"type": "text", "text": item.content})
|
|
66
|
+
else:
|
|
67
|
+
text_parts.append(item.content)
|
|
68
|
+
case ToolCallContent():
|
|
69
|
+
tool_calls.append({
|
|
70
|
+
"id": item.id,
|
|
71
|
+
"type": "function",
|
|
72
|
+
"function": {"name": item.name, "arguments": json.dumps(item.args)},
|
|
73
|
+
})
|
|
74
|
+
entry: dict[str, Any] = {"role": "assistant"}
|
|
75
|
+
if has_thinking:
|
|
76
|
+
# When thinking blocks are present, Mistral requires chunked content format.
|
|
77
|
+
entry["content"] = content_chunks
|
|
78
|
+
else:
|
|
79
|
+
text = "".join(text_parts) or None
|
|
80
|
+
if text is not None:
|
|
81
|
+
entry["content"] = text
|
|
82
|
+
if tool_calls:
|
|
83
|
+
entry["tool_calls"] = tool_calls
|
|
84
|
+
result.append(entry)
|
|
85
|
+
case ToolMessage():
|
|
86
|
+
for content in msg.contents:
|
|
87
|
+
if isinstance(content, ToolResultContent):
|
|
88
|
+
result.append({
|
|
89
|
+
"role": "tool",
|
|
90
|
+
"tool_call_id": content.id,
|
|
91
|
+
"content": content.content,
|
|
92
|
+
})
|
|
93
|
+
return result
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class MistralChatAPI(BaseAPI):
|
|
97
|
+
"""Streaming LLM API adapter for the Mistral Chat API."""
|
|
98
|
+
|
|
99
|
+
def __init__(self, options: LLMOptions) -> None:
|
|
100
|
+
"""Initialise the Mistral client and cache the initial api_key for change detection."""
|
|
101
|
+
super().__init__(options)
|
|
102
|
+
self._client_key = options.api_key
|
|
103
|
+
self._client = self._build_client()
|
|
104
|
+
|
|
105
|
+
def _build_client(self) -> Mistral:
|
|
106
|
+
"""Construct a fresh Mistral SDK client from the current options."""
|
|
107
|
+
return Mistral(
|
|
108
|
+
api_key=self.options.api_key,
|
|
109
|
+
server_url=self.options.base_url,
|
|
110
|
+
timeout_ms=int(self.options.timeout.total_seconds() * 1000),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def _sync_client(self) -> None:
|
|
114
|
+
"""Rebuild the Mistral client if the api_key has changed since construction."""
|
|
115
|
+
# The api_key is resolved and assigned to options *after* __init__,
|
|
116
|
+
# so rebuild the client whenever it changes.
|
|
117
|
+
if self.options.api_key != self._client_key:
|
|
118
|
+
self._client_key = self.options.api_key
|
|
119
|
+
self._client = self._build_client()
|
|
120
|
+
|
|
121
|
+
async def stream(self, context: LLMContext, model: Model) -> AsyncGenerator[LLMEvent, None]: # type: ignore[override]
|
|
122
|
+
"""Stream LLMEvents from the Mistral Chat API."""
|
|
123
|
+
self._sync_client()
|
|
124
|
+
mistral_messages = _messages_to_mistral(context.messages)
|
|
125
|
+
if context.system_prompt:
|
|
126
|
+
mistral_messages = [{"role": "system", "content": context.system_prompt}] + mistral_messages
|
|
127
|
+
|
|
128
|
+
reasoning_effort = None
|
|
129
|
+
if self.options.thinking_level is not None:
|
|
130
|
+
reasoning_effort = "none" if self.options.thinking_level in _MINIMAL_LEVELS else "high"
|
|
131
|
+
|
|
132
|
+
text_started = False
|
|
133
|
+
text_buf = ""
|
|
134
|
+
text_index = 0
|
|
135
|
+
thinking_started = False
|
|
136
|
+
thinking_buf = ""
|
|
137
|
+
thinking_index = 0
|
|
138
|
+
tool_index = 0
|
|
139
|
+
tool_started: dict[int, bool] = {}
|
|
140
|
+
tool_bufs: dict[int, str] = {}
|
|
141
|
+
tool_meta: dict[int, dict[str, str]] = {}
|
|
142
|
+
_input_tokens = 0
|
|
143
|
+
_output_tokens = 0
|
|
144
|
+
_cache_read_tokens = 0
|
|
145
|
+
|
|
146
|
+
yield StartEvent()
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
kwargs: dict[str, Any] = {
|
|
150
|
+
"model": model.id,
|
|
151
|
+
"messages": mistral_messages,
|
|
152
|
+
"temperature": self.options.temperature,
|
|
153
|
+
"max_tokens": self.options.max_tokens,
|
|
154
|
+
}
|
|
155
|
+
if reasoning_effort is not None:
|
|
156
|
+
kwargs["reasoning_effort"] = reasoning_effort
|
|
157
|
+
response_format = openai_response_format(context.response_format)
|
|
158
|
+
if response_format is not None:
|
|
159
|
+
kwargs["response_format"] = response_format
|
|
160
|
+
|
|
161
|
+
tools = context.tools or None
|
|
162
|
+
if tools:
|
|
163
|
+
kwargs["tools"] = [
|
|
164
|
+
{
|
|
165
|
+
"type": "function",
|
|
166
|
+
"function": {
|
|
167
|
+
"name": tool.name,
|
|
168
|
+
"description": tool.description,
|
|
169
|
+
"parameters": tool.schema.model_json_schema(),
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
for tool in tools
|
|
173
|
+
]
|
|
174
|
+
kwargs["tool_choice"] = "auto"
|
|
175
|
+
|
|
176
|
+
if self.options.on_payload:
|
|
177
|
+
modified = self.options.on_payload(kwargs)
|
|
178
|
+
if modified is not None:
|
|
179
|
+
kwargs = modified
|
|
180
|
+
|
|
181
|
+
async with await self._client.chat.stream_async(**kwargs) as stream:
|
|
182
|
+
async for event in stream:
|
|
183
|
+
if self._cancelled():
|
|
184
|
+
yield ErrorEvent(reason=StopReason.Abort, error="Cancelled")
|
|
185
|
+
return
|
|
186
|
+
chunk = event.data
|
|
187
|
+
usage_data = getattr(chunk, 'usage', None)
|
|
188
|
+
if usage_data and usage_data != UNSET:
|
|
189
|
+
_input_tokens = getattr(usage_data, 'prompt_tokens', 0) or 0
|
|
190
|
+
_output_tokens = getattr(usage_data, 'completion_tokens', 0) or 0
|
|
191
|
+
_details = getattr(usage_data, 'prompt_tokens_details', None)
|
|
192
|
+
_cache_read_tokens = getattr(_details, 'cached_tokens', 0) or 0
|
|
193
|
+
if not chunk.choices:
|
|
194
|
+
continue
|
|
195
|
+
choice = chunk.choices[0]
|
|
196
|
+
delta = choice.delta
|
|
197
|
+
|
|
198
|
+
content = delta.content
|
|
199
|
+
if content and content !=UNSET:
|
|
200
|
+
if isinstance(content, str):
|
|
201
|
+
if not text_started:
|
|
202
|
+
yield TextStartEvent(text=TextContent(content=""))
|
|
203
|
+
text_started = True
|
|
204
|
+
text_buf += content
|
|
205
|
+
yield TextDeltaEvent(text=TextContent(content=content))
|
|
206
|
+
elif isinstance(content, list):
|
|
207
|
+
for chunk_item in content:
|
|
208
|
+
if isinstance(chunk_item, ThinkChunk):
|
|
209
|
+
thinking_text = "".join(
|
|
210
|
+
t.text for t in chunk_item.thinking
|
|
211
|
+
if isinstance(t, TextChunk)
|
|
212
|
+
)
|
|
213
|
+
if thinking_text:
|
|
214
|
+
if not thinking_started:
|
|
215
|
+
yield ThinkingStartEvent(thinking=None)
|
|
216
|
+
thinking_started = True
|
|
217
|
+
thinking_buf += thinking_text
|
|
218
|
+
yield ThinkingDeltaEvent(thinking=ThinkingContent(content=thinking_text))
|
|
219
|
+
if chunk_item.closed:
|
|
220
|
+
if thinking_started:
|
|
221
|
+
yield ThinkingEndEvent(thinking=ThinkingContent(content=thinking_buf))
|
|
222
|
+
thinking_index += 1
|
|
223
|
+
thinking_started = False
|
|
224
|
+
thinking_buf = ""
|
|
225
|
+
elif isinstance(chunk_item, TextChunk):
|
|
226
|
+
if not text_started:
|
|
227
|
+
yield TextStartEvent(text=TextContent(content=""))
|
|
228
|
+
text_started = True
|
|
229
|
+
text_buf += chunk_item.text
|
|
230
|
+
yield TextDeltaEvent(text=TextContent(content=chunk_item.text))
|
|
231
|
+
|
|
232
|
+
tool_calls = delta.tool_calls
|
|
233
|
+
if tool_calls and tool_calls != UNSET:
|
|
234
|
+
for tc in tool_calls:
|
|
235
|
+
idx = tc.index if tc.index is not None else 0
|
|
236
|
+
fn = tc.function
|
|
237
|
+
args = fn.arguments if isinstance(fn.arguments, str) else json.dumps(fn.arguments)
|
|
238
|
+
tc_id = tc.id or ""
|
|
239
|
+
if idx not in tool_started:
|
|
240
|
+
tool_started[idx] = True
|
|
241
|
+
tool_bufs[idx] = ""
|
|
242
|
+
tool_meta[idx] = {"id": tc_id, "name": fn.name}
|
|
243
|
+
yield ToolCallStartEvent(tool_call=ToolCallContent(id=tc_id, name=fn.name)
|
|
244
|
+
)
|
|
245
|
+
if args:
|
|
246
|
+
tool_bufs[idx] += args
|
|
247
|
+
yield ToolCallDeltaEvent(tool_call=ToolCallContent(id=tc_id)
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
finish = choice.finish_reason
|
|
251
|
+
if finish and finish != UNSET:
|
|
252
|
+
if thinking_started:
|
|
253
|
+
yield ThinkingEndEvent(thinking=ThinkingContent(content=thinking_buf))
|
|
254
|
+
thinking_index += 1
|
|
255
|
+
thinking_started = False
|
|
256
|
+
thinking_buf = ""
|
|
257
|
+
|
|
258
|
+
if text_started:
|
|
259
|
+
yield TextEndEvent(text=TextContent(content=text_buf))
|
|
260
|
+
text_index += 1
|
|
261
|
+
text_started = False
|
|
262
|
+
text_buf = ""
|
|
263
|
+
|
|
264
|
+
for idx in sorted(tool_started):
|
|
265
|
+
args_str = tool_bufs[idx].strip()
|
|
266
|
+
args = parse_tool_args(args_str)
|
|
267
|
+
|
|
268
|
+
yield ToolCallEndEvent(tool_call=ToolCallContent(
|
|
269
|
+
id=tool_meta[idx]["id"],
|
|
270
|
+
name=tool_meta[idx]["name"],
|
|
271
|
+
args=args
|
|
272
|
+
)
|
|
273
|
+
)
|
|
274
|
+
if tool_started:
|
|
275
|
+
tool_index += len(tool_started)
|
|
276
|
+
tool_started.clear()
|
|
277
|
+
tool_bufs.clear()
|
|
278
|
+
tool_meta.clear()
|
|
279
|
+
|
|
280
|
+
stop_reason = _STOP_REASON.get(str(finish), StopReason.Stop)
|
|
281
|
+
yield EndEvent(reason=stop_reason, input_tokens=_input_tokens, output_tokens=_output_tokens, cache_read_tokens=_cache_read_tokens)
|
|
282
|
+
|
|
283
|
+
except Exception as e:
|
|
284
|
+
yield ErrorEvent(reason=StopReason.Error, error=str(e))
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import json
|
|
3
|
+
from tau.inference.api.text.utils import parse_tool_args
|
|
4
|
+
from collections.abc import AsyncGenerator, AsyncIterator
|
|
5
|
+
from typing import Any
|
|
6
|
+
from ollama import AsyncClient
|
|
7
|
+
from tau.inference.api.text.base import BaseLLMAPI as BaseAPI
|
|
8
|
+
from tau.inference.model.types import Model
|
|
9
|
+
from tau.inference.types import (
|
|
10
|
+
LLMContext, LLMEvent, LLMOptions, StopReason, ThinkingLevel,
|
|
11
|
+
StartEvent, EndEvent, ErrorEvent,
|
|
12
|
+
TextStartEvent, TextDeltaEvent, TextEndEvent,
|
|
13
|
+
ThinkingStartEvent, ThinkingDeltaEvent, ThinkingEndEvent,
|
|
14
|
+
ToolCallStartEvent, ToolCallEndEvent,
|
|
15
|
+
normalize_structured_response_format,
|
|
16
|
+
)
|
|
17
|
+
from tau.message.types import (
|
|
18
|
+
SystemMessage, UserMessage, AssistantMessage, ToolMessage,
|
|
19
|
+
TextContent, ImageContent, ThinkingContent, ToolCallContent, ToolResultContent,
|
|
20
|
+
)
|
|
21
|
+
from typing import Optional, TYPE_CHECKING
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from tau.tool.types import Tool
|
|
24
|
+
|
|
25
|
+
_MINIMAL_LEVELS = {ThinkingLevel.Low, ThinkingLevel.Minimal}
|
|
26
|
+
|
|
27
|
+
_STOP_REASON: dict[str, StopReason] = {
|
|
28
|
+
"stop": StopReason.Stop,
|
|
29
|
+
"length": StopReason.Length,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _messages_to_ollama(messages: list[LLMMessage]) -> list[dict[str, Any]]:
|
|
34
|
+
"""Convert a message list to Ollama Chat API format, placing images in a separate field."""
|
|
35
|
+
result: list[dict[str, Any]] = []
|
|
36
|
+
for msg in messages:
|
|
37
|
+
match msg:
|
|
38
|
+
case SystemMessage():
|
|
39
|
+
text = "\n".join(c.content for c in msg.contents if isinstance(c, TextContent))
|
|
40
|
+
result.append({"role": "system", "content": text})
|
|
41
|
+
case UserMessage():
|
|
42
|
+
text_parts: list[str] = []
|
|
43
|
+
images: list[str] = []
|
|
44
|
+
for item in msg.contents:
|
|
45
|
+
match item:
|
|
46
|
+
case TextContent():
|
|
47
|
+
text_parts.append(item.content)
|
|
48
|
+
case ImageContent():
|
|
49
|
+
images.extend(b64 for b64, _ in item.to_base64())
|
|
50
|
+
if not text_parts and not images:
|
|
51
|
+
continue
|
|
52
|
+
entry: dict[str, Any] = {"role": "user", "content": "\n".join(text_parts)}
|
|
53
|
+
if images:
|
|
54
|
+
entry["images"] = images
|
|
55
|
+
result.append(entry)
|
|
56
|
+
case AssistantMessage():
|
|
57
|
+
text_parts = []
|
|
58
|
+
thinking_parts: list[str] = []
|
|
59
|
+
tool_calls: list[dict[str, Any]] = []
|
|
60
|
+
for item in msg.contents:
|
|
61
|
+
match item:
|
|
62
|
+
case TextContent():
|
|
63
|
+
text_parts.append(item.content)
|
|
64
|
+
case ThinkingContent():
|
|
65
|
+
thinking_parts.append(item.content)
|
|
66
|
+
case ToolCallContent():
|
|
67
|
+
tool_calls.append({
|
|
68
|
+
"function": {"name": item.name, "arguments": item.args}
|
|
69
|
+
})
|
|
70
|
+
entry = {"role": "assistant", "content": "\n".join(text_parts)}
|
|
71
|
+
if thinking_parts:
|
|
72
|
+
entry["thinking"] = "\n".join(thinking_parts)
|
|
73
|
+
if tool_calls:
|
|
74
|
+
entry["tool_calls"] = tool_calls
|
|
75
|
+
result.append(entry)
|
|
76
|
+
case ToolMessage():
|
|
77
|
+
for content in msg.contents:
|
|
78
|
+
if isinstance(content, ToolResultContent):
|
|
79
|
+
result.append({"role": "tool", "content": content.content})
|
|
80
|
+
|
|
81
|
+
return result
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _format(response_format: Any | None) -> dict[str, Any] | None:
|
|
85
|
+
"""Extract the raw JSON schema dict for Ollama's format field, or None if unstructured."""
|
|
86
|
+
structured = normalize_structured_response_format(response_format)
|
|
87
|
+
return structured.schema if structured is not None else None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class OllamaChatAPI(BaseAPI):
|
|
91
|
+
"""Streaming LLM API adapter for the Ollama Chat endpoint."""
|
|
92
|
+
|
|
93
|
+
def __init__(self, options: LLMOptions) -> None:
|
|
94
|
+
"""Initialise the Ollama AsyncClient targeting the configured host."""
|
|
95
|
+
super().__init__(options)
|
|
96
|
+
self._client = AsyncClient(
|
|
97
|
+
host=options.base_url,
|
|
98
|
+
headers=options.headers or {},
|
|
99
|
+
timeout=options.timeout.total_seconds(),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def _inference_options(self) -> dict[str, Any]:
|
|
103
|
+
"""Build Ollama model-level options dict (temperature, token limit)."""
|
|
104
|
+
opts: dict[str, Any] = {"temperature": self.options.temperature}
|
|
105
|
+
if self.options.max_tokens is not None:
|
|
106
|
+
opts["num_predict"] = self.options.max_tokens
|
|
107
|
+
return opts
|
|
108
|
+
|
|
109
|
+
async def stream(self, context: LLMContext, model: Model) -> AsyncGenerator[LLMEvent, None]: # type: ignore[override]
|
|
110
|
+
"""Stream LLMEvents from the local Ollama Chat endpoint."""
|
|
111
|
+
ollama_messages = _messages_to_ollama(context.messages)
|
|
112
|
+
if context.system_prompt:
|
|
113
|
+
ollama_messages = [{"role": "system", "content": context.system_prompt}] + ollama_messages
|
|
114
|
+
|
|
115
|
+
think: bool | None = None
|
|
116
|
+
if self.options.thinking_level is not None:
|
|
117
|
+
think = self.options.thinking_level not in _MINIMAL_LEVELS
|
|
118
|
+
|
|
119
|
+
text_started = False
|
|
120
|
+
text_buf = ""
|
|
121
|
+
thinking_started = False
|
|
122
|
+
thinking_buf = ""
|
|
123
|
+
_input_tokens = 0
|
|
124
|
+
_output_tokens = 0
|
|
125
|
+
|
|
126
|
+
yield StartEvent()
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
payload: dict[str, Any] = {
|
|
130
|
+
"model": model.id,
|
|
131
|
+
"messages": ollama_messages,
|
|
132
|
+
"stream": True,
|
|
133
|
+
"think": think,
|
|
134
|
+
"options": self._inference_options(),
|
|
135
|
+
}
|
|
136
|
+
response_format = _format(context.response_format)
|
|
137
|
+
if response_format is not None:
|
|
138
|
+
payload["format"] = response_format
|
|
139
|
+
|
|
140
|
+
tools = context.tools or None
|
|
141
|
+
if tools:
|
|
142
|
+
payload["tools"] = [
|
|
143
|
+
{
|
|
144
|
+
"type": "function",
|
|
145
|
+
"function": {
|
|
146
|
+
"name": tool.name,
|
|
147
|
+
"description": tool.description,
|
|
148
|
+
"parameters": tool.schema.model_json_schema(),
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
for tool in tools
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
if self.options.on_payload:
|
|
155
|
+
modified = self.options.on_payload(payload)
|
|
156
|
+
if modified is not None:
|
|
157
|
+
payload = modified
|
|
158
|
+
|
|
159
|
+
async for chunk in await self._client.chat(**payload):
|
|
160
|
+
if self._cancelled():
|
|
161
|
+
yield ErrorEvent(reason=StopReason.Abort, error="Cancelled")
|
|
162
|
+
return
|
|
163
|
+
msg = chunk.message
|
|
164
|
+
|
|
165
|
+
if msg.thinking:
|
|
166
|
+
if not thinking_started:
|
|
167
|
+
yield ThinkingStartEvent(thinking=None)
|
|
168
|
+
thinking_started = True
|
|
169
|
+
thinking_buf += msg.thinking
|
|
170
|
+
yield ThinkingDeltaEvent(thinking=ThinkingContent(content=msg.thinking))
|
|
171
|
+
|
|
172
|
+
if msg.content:
|
|
173
|
+
if not text_started:
|
|
174
|
+
yield TextStartEvent(text=TextContent(content=""))
|
|
175
|
+
text_started = True
|
|
176
|
+
text_buf += msg.content
|
|
177
|
+
yield TextDeltaEvent(text=TextContent(content=msg.content))
|
|
178
|
+
|
|
179
|
+
# Ollama sends all tool calls in the final chunk, not incrementally.
|
|
180
|
+
if msg.tool_calls:
|
|
181
|
+
for i, tc in enumerate(msg.tool_calls):
|
|
182
|
+
fn = tc.function
|
|
183
|
+
args_raw = fn.arguments
|
|
184
|
+
args = parse_tool_args(args_raw)
|
|
185
|
+
|
|
186
|
+
yield ToolCallStartEvent(tool_call=ToolCallContent(name=fn.name))
|
|
187
|
+
yield ToolCallEndEvent(tool_call=ToolCallContent(name=fn.name, args=args))
|
|
188
|
+
|
|
189
|
+
if chunk.done:
|
|
190
|
+
_input_tokens = getattr(chunk, 'prompt_eval_count', 0) or 0
|
|
191
|
+
_output_tokens = getattr(chunk, 'eval_count', 0) or 0
|
|
192
|
+
if thinking_started:
|
|
193
|
+
yield ThinkingEndEvent(thinking=ThinkingContent(content=thinking_buf))
|
|
194
|
+
if text_started:
|
|
195
|
+
yield TextEndEvent(text=TextContent(content=text_buf))
|
|
196
|
+
stop_reason = _STOP_REASON.get(chunk.done_reason or "", StopReason.Stop)
|
|
197
|
+
yield EndEvent(reason=stop_reason, input_tokens=_input_tokens, output_tokens=_output_tokens)
|
|
198
|
+
|
|
199
|
+
except Exception as e:
|
|
200
|
+
yield ErrorEvent(reason=StopReason.Error, error=str(e))
|