zett_agent.providers.ollama 源代码

from __future__ import annotations

import base64
import json
import ssl
from collections.abc import AsyncIterator, Mapping, Sequence
from typing import TYPE_CHECKING, Any

from .._compat import aclose_stream
from ..messages import (
    AgentMessage,
    AssistantMessage,
    ImageBytesSource,
    ImageContent,
    ImageUrlSource,
    SystemMessage,
    ToolCall,
    ToolMessage,
    UserMessage,
)
from ..model import (
    DEFAULT_RETRY_OPTIONS,
    ModelEvent,
    ModelRequest,
    ModelResponse,
    ModelUsage,
    ReasoningEffort,
    RetryOptions,
    TimeoutOptions,
    ToolCallDelta,
    ToolDefinition,
    validate_response,
    validate_retry,
    validate_timeout,
)
from .base import (
    ProviderAuthError,
    ProviderResponseError,
    RetryingProvider,
    guard_idle,
    http_error,
    httpx_timeout,
    reject_local_tool_search,
    retry_model_stream,
    validate_extra_headers,
    validate_extra_query,
)
from .http_errors import failure_message, read_failure
from .tool_images import expand_tool_images

if TYPE_CHECKING:
    import httpx


def _tools_to_ollama_payload(tools: Sequence[ToolDefinition]) -> list[dict[str, Any]]:
    reject_local_tool_search("Ollama chat", tools)
    rendered = []
    for tool in tools:
        rendered.append(
            {
                "type": "function",
                "function": {
                    "name": tool.name,
                    "description": tool.description,
                    "parameters": dict(tool.parameters),
                },
            }
        )
    return rendered


#: A local model may have to be loaded from disk before it answers, which can take minutes for a
#: large one, so the wait for the first chunk is longer than for a hosted provider.
OLLAMA_TIMEOUT_OPTIONS = TimeoutOptions(first_byte=600.0)


[文档] class OllamaProvider(RetryingProvider): """Stream Ollama SDK events without buffering the entire HTTP response. Args: model: Model name installed or available on the target Ollama server. transport: Optional HTTPX transport for custom routing or tests. base_url: Ollama server root; defaults to localhost port 11434. response: Must remain false; this adapter uses Ollama's chat endpoint. retry: Backoff policy for failures before the first streamed event. timeout: How long the server may stay silent before the request is given up on. See :class:`~zett_agent.model.TimeoutOptions`. The default allows ten minutes for the first chunk, because a local model may need to be loaded first. A timeout raises :class:`~zett_agent.providers.base.ProviderTimeoutError` and is not retried. extra_headers: HTTP headers sent on every request, such as the ``Authorization`` a reverse proxy in front of Ollama expects. extra_query: URL query parameters sent on every request. Note: This adapter does not install models or start the server. Reasoning, vision, and tool support depend on the selected model. Close with aclose(). Examples: Connect to a locally running Ollama service:: model = OllamaProvider(model="qwen3", base_url="http://127.0.0.1:11434") try: client = await create_agent(model) reply = await client.run("List the public classes") finally: await model.aclose() .. note:: ``base_url`` selects an existing service. Constructing this adapter does not download a model or launch the Ollama daemon. .. seealso:: :doc:`/learn/providers` covers shared adapter ownership and retries; :class:`~zett_agent.model.AgentModel` defines the normalized stream contract. """ def __init__( self, model: str, transport: httpx.AsyncBaseTransport | None = None, *, base_url: str = "http://localhost:11434", response: bool = False, retry: RetryOptions = DEFAULT_RETRY_OPTIONS, extra_headers: Mapping[str, str] | None = None, extra_query: Mapping[str, str] | None = None, timeout: TimeoutOptions = OLLAMA_TIMEOUT_OPTIONS, ) -> None: import truststore from ollama import AsyncClient validate_response(response) if response: raise ValueError("OllamaProvider does not support the Responses API") validate_retry(retry) validate_timeout(timeout) self.retry = retry self.timeout = timeout self.response = response self.model = model self.extra_headers = validate_extra_headers(extra_headers) self.extra_query = validate_extra_query(extra_query) self._client = AsyncClient( host=base_url, transport=transport, verify=truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT), trust_env=True, timeout=httpx_timeout(timeout), headers=self.extra_headers or None, params=self.extra_query or None, )
[文档] async def aclose(self) -> None: """Close the SDK-owned HTTP connection pool.""" await self._client._client.aclose()
@retry_model_stream async def stream(self, request: ModelRequest) -> AsyncIterator[ModelEvent]: if request.server_tools: requested = ", ".join(tool.type for tool in request.server_tools) raise ProviderResponseError(f"Ollama does not support provider-hosted server tools: {requested}") messages: list[dict[str, Any]] = [] for message in expand_tool_images(request.messages): match message: case AgentMessage(): messages.append({"role": "user", "content": message.content}) case SystemMessage(): messages.append({"role": "system", "content": message.content}) case UserMessage(): payload: dict[str, Any] = {"role": "user", "content": message.text} images: list[str] = [] for part in message.parts: match part: case ImageContent(source=ImageBytesSource(data=data)): images.append(base64.b64encode(data).decode("ascii")) case ImageContent(source=ImageUrlSource(url=url)) if url.startswith("data:"): images.append(url.partition(",")[2]) case ImageContent(source=ImageUrlSource()): raise ProviderResponseError("Ollama images must be bytes or base64 data URLs") if images: payload["images"] = images messages.append(payload) case AssistantMessage(): payload = {"role": "assistant", "content": message.content} if message.tool_calls: payload["tool_calls"] = [ {"function": {"name": call.name, "arguments": dict(call.arguments)}} for call in message.tool_calls ] if message.reasoning: payload["thinking"] = message.reasoning messages.append(payload) case ToolMessage(): messages.append( { "role": "tool", "content": message.content, "tool_name": message.name, } ) if request.tool_choice: messages.insert(0, {"role": "system", "content": f"Respond only by calling {request.tool_choice}."}) from ollama import ResponseError stream = await self._client.chat( model=self.model, messages=messages, tools=_tools_to_ollama_payload(request.tools) or None, stream=True, think=request.reasoning_effort != ReasoningEffort.OFF, ) text, reasoning = "", "" calls: list[ToolCall] = [] usage = ModelUsage() finish_reason = None done = False try: # The client only sends its request when the first chunk is asked for, so the first # wait covers connecting, the response headers and the first chunk, and an HTTP # failure surfaces here rather than when the stream is opened. async for chunk in guard_idle(stream, self.timeout, first=self.timeout.first_byte): if chunk.message.thinking: reasoning += chunk.message.thinking yield ModelEvent.reasoning(chunk.message.thinking) if chunk.message.content: text += chunk.message.content yield ModelEvent.text(chunk.message.content) for call in chunk.message.tool_calls or []: normalized = ToolCall(f"call_{len(calls)}", call.function.name, call.function.arguments) calls.append(normalized) yield ModelEvent.tool_call( ToolCallDelta( index=len(calls) - 1, id_delta=normalized.id, name_delta=normalized.name, arguments_delta=json.dumps(dict(normalized.arguments)), ) ) if chunk.done: done = True finish_reason = chunk.done_reason # Ollama reports prompt evaluation counts only; it never # reports prompt-cache counters, so the hit rate is unknown # rather than zero. usage = ModelUsage( input_tokens=chunk.prompt_eval_count or 0, output_tokens=chunk.eval_count or 0, cache_reported=False, ) except json.JSONDecodeError as error: # Ollama streams one JSON object per line. Anything else is not an Ollama server: a # proxy's page, a login redirect, or a different service on that port. raise ProviderResponseError( "ollama answered with something that is not a stream of JSON lines. A proxy, login page or " "another service on that address is the usual cause; check base_url." ) from error except ResponseError as error: status = int(getattr(error, "status_code", 0) or 0) raw = getattr(error, "error", None) failure = read_failure(content_type=None, text=raw if isinstance(raw, str) else json.dumps(raw), parsed=raw) description = failure_message("ollama", status, failure) if status in (401, 403): raise ProviderAuthError(f"Provider rejected API credential. {description}") from error raise http_error(status, failure, description) from error finally: await aclose_stream(stream) if not done: raise ProviderResponseError("Ollama stream ended without a terminal response") yield ModelEvent.completed( ModelResponse( AssistantMessage( content=text, reasoning=reasoning or None, tool_calls=tuple(calls), provider="ollama", model=self.model, ), finish_reason=finish_reason, usage=usage, ) )