kosong.chat_provider

  1from __future__ import annotations
  2
  3from collections.abc import AsyncIterator, Sequence
  4from typing import TYPE_CHECKING, Literal, Protocol, Self, runtime_checkable
  5
  6from pydantic import BaseModel
  7
  8from kosong.message import ContentPart, Message, ToolCall, ToolCallPart
  9from kosong.tooling import Tool
 10
 11if TYPE_CHECKING:
 12    import httpx
 13
 14
 15@runtime_checkable
 16class ChatProvider(Protocol):
 17    """The interface of chat providers."""
 18
 19    name: str
 20    """
 21    The name of the chat provider.
 22    """
 23
 24    @property
 25    def model_name(self) -> str:
 26        """
 27        The name of the model to use.
 28        """
 29        ...
 30
 31    @property
 32    def thinking_effort(self) -> ThinkingEffort | None:
 33        """
 34        The current thinking effort level. Returns None if not explicitly set.
 35        """
 36        ...
 37
 38    async def generate(
 39        self,
 40        system_prompt: str,
 41        tools: Sequence[Tool],
 42        history: Sequence[Message],
 43    ) -> StreamedMessage:
 44        """
 45        Generate a new message based on the given system prompt, tools, and history.
 46
 47        Raises:
 48            APIConnectionError: If the API connection fails.
 49            APITimeoutError: If the API request times out.
 50            APIStatusError: If the API returns a status code of 4xx or 5xx.
 51            ChatProviderError: If any other recognized chat provider error occurs.
 52        """
 53        ...
 54
 55    def with_thinking(self, effort: ThinkingEffort) -> Self:
 56        """
 57        Return a copy of self configured with the given thinking effort.
 58        If the chat provider does not support thinking, simply return a copy of self.
 59        """
 60        ...
 61
 62
 63@runtime_checkable
 64class RetryableChatProvider(Protocol):
 65    """Optional interface for providers that can recover from retryable transport errors."""
 66
 67    def on_retryable_error(self, error: BaseException) -> bool:
 68        """
 69        Try to recover provider transport state after a retryable error.
 70
 71        Returns:
 72            bool: Whether recovery action was performed.
 73        """
 74        ...
 75
 76
 77type StreamedMessagePart = ContentPart | ToolCall | ToolCallPart
 78
 79
 80@runtime_checkable
 81class StreamedMessage(Protocol):
 82    """The interface of streamed messages."""
 83
 84    def __aiter__(self) -> AsyncIterator[StreamedMessagePart]:
 85        """Create an async iterator from the stream."""
 86        ...
 87
 88    @property
 89    def id(self) -> str | None:
 90        """The ID of the streamed message."""
 91        ...
 92
 93    @property
 94    def usage(self) -> TokenUsage | None:
 95        """The token usage of the streamed message."""
 96        ...
 97
 98
 99class TokenUsage(BaseModel):
100    """Token usage statistics."""
101
102    input_other: int
103    """Input tokens excluding `input_cache_read` and `input_cache_creation`."""
104    output: int
105    """Total output tokens."""
106    input_cache_read: int = 0
107    """Cached input tokens."""
108    input_cache_creation: int = 0
109    """Input tokens used for cache creation. For now, only Anthropic API supports this."""
110
111    @property
112    def total(self) -> int:
113        """Total tokens used, including input and output tokens."""
114        return self.input + self.output
115
116    @property
117    def input(self) -> int:
118        """Total input tokens, including cached and uncached tokens."""
119        return self.input_other + self.input_cache_read + self.input_cache_creation
120
121
122type ThinkingEffort = Literal["off", "low", "medium", "high", "xhigh", "max"]
123"""The effort level for thinking.
124
125Support for levels above ``high`` varies by provider:
126
127- **Anthropic**: ``xhigh`` is accepted only on Claude Opus 4.7; ``max`` is
128  accepted on Mythos, Opus 4.7/4.6, and Sonnet 4.6. Unsupported levels are
129  clamped down to ``high``.
130- **OpenAI**: ``xhigh`` is accepted natively for reasoning-capable models
131  after ``gpt-5.1-codex-max`` and passes through unchanged. ``max`` is
132  Anthropic-specific and clamps to ``xhigh`` (OpenAI's ceiling).
133- **Kimi**: requests only serialize thinking as enabled or disabled; the
134  caller-provided effort remains unchanged as provider state.
135- **Gemini**: ``xhigh`` and ``max`` clamp to ``high`` (no native support).
136"""
137
138
139class ChatProviderError(Exception):
140    """The error raised by a chat provider."""
141
142    def __init__(self, message: str):
143        super().__init__(message)
144
145
146class APIConnectionError(ChatProviderError):
147    """The error raised when the API connection fails."""
148
149
150class APITimeoutError(ChatProviderError):
151    """The error raised when the API request times out."""
152
153
154class APIStatusError(ChatProviderError):
155    """The error raised when the API returns a status code of 4xx or 5xx."""
156
157    status_code: int
158    request_id: str | None
159    trace_id: str | None
160
161    def __init__(
162        self,
163        status_code: int,
164        message: str,
165        *,
166        request_id: str | None = None,
167        trace_id: str | None = None,
168    ):
169        super().__init__(message)
170        self.status_code = status_code
171        self.request_id = request_id
172        self.trace_id = trace_id
173
174
175class APIEmptyResponseError(ChatProviderError):
176    """The error raised when the API returns an empty response."""
177
178
179def convert_httpx_error(error: httpx.HTTPError) -> ChatProviderError:
180    """Convert an httpx transport error to the corresponding ChatProviderError.
181
182    This is a shared utility for all chat providers. SDK-specific exceptions
183    (e.g. AnthropicError, OpenAIError) should be handled by each provider's
184    own conversion logic; only raw httpx exceptions that leak through
185    (typically during streaming) should be routed here.
186    """
187    import httpx
188
189    if isinstance(error, httpx.TimeoutException):
190        return APITimeoutError(str(error))
191    if isinstance(error, (httpx.NetworkError, httpx.RemoteProtocolError)):
192        return APIConnectionError(str(error))
193    if isinstance(error, httpx.HTTPStatusError):
194        req_id = error.response.headers.get("x-request-id")
195        trace_id = error.response.headers.get("x-trace-id")
196        return APIStatusError(
197            error.response.status_code, str(error), request_id=req_id, trace_id=trace_id
198        )
199    return ChatProviderError(f"HTTP error: {error}")
@runtime_checkable
class ChatProvider(typing.Protocol):
16@runtime_checkable
17class ChatProvider(Protocol):
18    """The interface of chat providers."""
19
20    name: str
21    """
22    The name of the chat provider.
23    """
24
25    @property
26    def model_name(self) -> str:
27        """
28        The name of the model to use.
29        """
30        ...
31
32    @property
33    def thinking_effort(self) -> ThinkingEffort | None:
34        """
35        The current thinking effort level. Returns None if not explicitly set.
36        """
37        ...
38
39    async def generate(
40        self,
41        system_prompt: str,
42        tools: Sequence[Tool],
43        history: Sequence[Message],
44    ) -> StreamedMessage:
45        """
46        Generate a new message based on the given system prompt, tools, and history.
47
48        Raises:
49            APIConnectionError: If the API connection fails.
50            APITimeoutError: If the API request times out.
51            APIStatusError: If the API returns a status code of 4xx or 5xx.
52            ChatProviderError: If any other recognized chat provider error occurs.
53        """
54        ...
55
56    def with_thinking(self, effort: ThinkingEffort) -> Self:
57        """
58        Return a copy of self configured with the given thinking effort.
59        If the chat provider does not support thinking, simply return a copy of self.
60        """
61        ...

The interface of chat providers.

ChatProvider(*args, **kwargs)
1866def _no_init_or_replace_init(self, *args, **kwargs):
1867    cls = type(self)
1868
1869    if cls._is_protocol:
1870        raise TypeError('Protocols cannot be instantiated')
1871
1872    # Already using a custom `__init__`. No need to calculate correct
1873    # `__init__` to call. This can lead to RecursionError. See bpo-45121.
1874    if cls.__init__ is not _no_init_or_replace_init:
1875        return
1876
1877    # Initially, `__init__` of a protocol subclass is set to `_no_init_or_replace_init`.
1878    # The first instantiation of the subclass will call `_no_init_or_replace_init` which
1879    # searches for a proper new `__init__` in the MRO. The new `__init__`
1880    # replaces the subclass' old `__init__` (ie `_no_init_or_replace_init`). Subsequent
1881    # instantiation of the protocol subclass will thus use the new
1882    # `__init__` and no longer call `_no_init_or_replace_init`.
1883    for base in cls.__mro__:
1884        init = base.__dict__.get('__init__', _no_init_or_replace_init)
1885        if init is not _no_init_or_replace_init:
1886            cls.__init__ = init
1887            break
1888    else:
1889        # should not happen
1890        cls.__init__ = object.__init__
1891
1892    cls.__init__(self, *args, **kwargs)
name: str

The name of the chat provider.

model_name: str
25    @property
26    def model_name(self) -> str:
27        """
28        The name of the model to use.
29        """
30        ...

The name of the model to use.

thinking_effort: ThinkingEffort | None
32    @property
33    def thinking_effort(self) -> ThinkingEffort | None:
34        """
35        The current thinking effort level. Returns None if not explicitly set.
36        """
37        ...

The current thinking effort level. Returns None if not explicitly set.

async def generate( self, system_prompt: str, tools: Sequence[kosong.tooling.Tool], history: Sequence[kosong.message.Message]) -> StreamedMessage:
39    async def generate(
40        self,
41        system_prompt: str,
42        tools: Sequence[Tool],
43        history: Sequence[Message],
44    ) -> StreamedMessage:
45        """
46        Generate a new message based on the given system prompt, tools, and history.
47
48        Raises:
49            APIConnectionError: If the API connection fails.
50            APITimeoutError: If the API request times out.
51            APIStatusError: If the API returns a status code of 4xx or 5xx.
52            ChatProviderError: If any other recognized chat provider error occurs.
53        """
54        ...

Generate a new message based on the given system prompt, tools, and history.

Raises:
  • APIConnectionError: If the API connection fails.
  • APITimeoutError: If the API request times out.
  • APIStatusError: If the API returns a status code of 4xx or 5xx.
  • ChatProviderError: If any other recognized chat provider error occurs.
def with_thinking(self, effort: ThinkingEffort) -> Self:
56    def with_thinking(self, effort: ThinkingEffort) -> Self:
57        """
58        Return a copy of self configured with the given thinking effort.
59        If the chat provider does not support thinking, simply return a copy of self.
60        """
61        ...

Return a copy of self configured with the given thinking effort. If the chat provider does not support thinking, simply return a copy of self.

@runtime_checkable
class RetryableChatProvider(typing.Protocol):
64@runtime_checkable
65class RetryableChatProvider(Protocol):
66    """Optional interface for providers that can recover from retryable transport errors."""
67
68    def on_retryable_error(self, error: BaseException) -> bool:
69        """
70        Try to recover provider transport state after a retryable error.
71
72        Returns:
73            bool: Whether recovery action was performed.
74        """
75        ...

Optional interface for providers that can recover from retryable transport errors.

RetryableChatProvider(*args, **kwargs)
1866def _no_init_or_replace_init(self, *args, **kwargs):
1867    cls = type(self)
1868
1869    if cls._is_protocol:
1870        raise TypeError('Protocols cannot be instantiated')
1871
1872    # Already using a custom `__init__`. No need to calculate correct
1873    # `__init__` to call. This can lead to RecursionError. See bpo-45121.
1874    if cls.__init__ is not _no_init_or_replace_init:
1875        return
1876
1877    # Initially, `__init__` of a protocol subclass is set to `_no_init_or_replace_init`.
1878    # The first instantiation of the subclass will call `_no_init_or_replace_init` which
1879    # searches for a proper new `__init__` in the MRO. The new `__init__`
1880    # replaces the subclass' old `__init__` (ie `_no_init_or_replace_init`). Subsequent
1881    # instantiation of the protocol subclass will thus use the new
1882    # `__init__` and no longer call `_no_init_or_replace_init`.
1883    for base in cls.__mro__:
1884        init = base.__dict__.get('__init__', _no_init_or_replace_init)
1885        if init is not _no_init_or_replace_init:
1886            cls.__init__ = init
1887            break
1888    else:
1889        # should not happen
1890        cls.__init__ = object.__init__
1891
1892    cls.__init__(self, *args, **kwargs)
def on_retryable_error(self, error: BaseException) -> bool:
68    def on_retryable_error(self, error: BaseException) -> bool:
69        """
70        Try to recover provider transport state after a retryable error.
71
72        Returns:
73            bool: Whether recovery action was performed.
74        """
75        ...

Try to recover provider transport state after a retryable error.

Returns:

bool: Whether recovery action was performed.

@runtime_checkable
class StreamedMessage(typing.Protocol):
81@runtime_checkable
82class StreamedMessage(Protocol):
83    """The interface of streamed messages."""
84
85    def __aiter__(self) -> AsyncIterator[StreamedMessagePart]:
86        """Create an async iterator from the stream."""
87        ...
88
89    @property
90    def id(self) -> str | None:
91        """The ID of the streamed message."""
92        ...
93
94    @property
95    def usage(self) -> TokenUsage | None:
96        """The token usage of the streamed message."""
97        ...

The interface of streamed messages.

StreamedMessage(*args, **kwargs)
1866def _no_init_or_replace_init(self, *args, **kwargs):
1867    cls = type(self)
1868
1869    if cls._is_protocol:
1870        raise TypeError('Protocols cannot be instantiated')
1871
1872    # Already using a custom `__init__`. No need to calculate correct
1873    # `__init__` to call. This can lead to RecursionError. See bpo-45121.
1874    if cls.__init__ is not _no_init_or_replace_init:
1875        return
1876
1877    # Initially, `__init__` of a protocol subclass is set to `_no_init_or_replace_init`.
1878    # The first instantiation of the subclass will call `_no_init_or_replace_init` which
1879    # searches for a proper new `__init__` in the MRO. The new `__init__`
1880    # replaces the subclass' old `__init__` (ie `_no_init_or_replace_init`). Subsequent
1881    # instantiation of the protocol subclass will thus use the new
1882    # `__init__` and no longer call `_no_init_or_replace_init`.
1883    for base in cls.__mro__:
1884        init = base.__dict__.get('__init__', _no_init_or_replace_init)
1885        if init is not _no_init_or_replace_init:
1886            cls.__init__ = init
1887            break
1888    else:
1889        # should not happen
1890        cls.__init__ = object.__init__
1891
1892    cls.__init__(self, *args, **kwargs)
id: str | None
89    @property
90    def id(self) -> str | None:
91        """The ID of the streamed message."""
92        ...

The ID of the streamed message.

usage: TokenUsage | None
94    @property
95    def usage(self) -> TokenUsage | None:
96        """The token usage of the streamed message."""
97        ...

The token usage of the streamed message.

class TokenUsage(pydantic.main.BaseModel):
100class TokenUsage(BaseModel):
101    """Token usage statistics."""
102
103    input_other: int
104    """Input tokens excluding `input_cache_read` and `input_cache_creation`."""
105    output: int
106    """Total output tokens."""
107    input_cache_read: int = 0
108    """Cached input tokens."""
109    input_cache_creation: int = 0
110    """Input tokens used for cache creation. For now, only Anthropic API supports this."""
111
112    @property
113    def total(self) -> int:
114        """Total tokens used, including input and output tokens."""
115        return self.input + self.output
116
117    @property
118    def input(self) -> int:
119        """Total input tokens, including cached and uncached tokens."""
120        return self.input_other + self.input_cache_read + self.input_cache_creation

Token usage statistics.

input_other: int = PydanticUndefined

Input tokens excluding input_cache_read and input_cache_creation.

output: int = PydanticUndefined

Total output tokens.

input_cache_read: int = 0

Cached input tokens.

input_cache_creation: int = 0

Input tokens used for cache creation. For now, only Anthropic API supports this.

total: int
112    @property
113    def total(self) -> int:
114        """Total tokens used, including input and output tokens."""
115        return self.input + self.output

Total tokens used, including input and output tokens.

input: int
117    @property
118    def input(self) -> int:
119        """Total input tokens, including cached and uncached tokens."""
120        return self.input_other + self.input_cache_read + self.input_cache_creation

Total input tokens, including cached and uncached tokens.

type ThinkingEffort = Literal['off', 'low', 'medium', 'high', 'xhigh', 'max']

The effort level for thinking.

Support for levels above high varies by provider:

  • Anthropic: xhigh is accepted only on Claude Opus 4.7; max is accepted on Mythos, Opus 4.7/4.6, and Sonnet 4.6. Unsupported levels are clamped down to high.
  • OpenAI: xhigh is accepted natively for reasoning-capable models after gpt-5.1-codex-max and passes through unchanged. max is Anthropic-specific and clamps to xhigh (OpenAI's ceiling).
  • Kimi: requests only serialize thinking as enabled or disabled; the caller-provided effort remains unchanged as provider state.
  • Gemini: xhigh and max clamp to high (no native support).
class ChatProviderError(builtins.Exception):
140class ChatProviderError(Exception):
141    """The error raised by a chat provider."""
142
143    def __init__(self, message: str):
144        super().__init__(message)

The error raised by a chat provider.

ChatProviderError(message: str)
143    def __init__(self, message: str):
144        super().__init__(message)
class APIConnectionError(ChatProviderError):
147class APIConnectionError(ChatProviderError):
148    """The error raised when the API connection fails."""

The error raised when the API connection fails.

class APITimeoutError(ChatProviderError):
151class APITimeoutError(ChatProviderError):
152    """The error raised when the API request times out."""

The error raised when the API request times out.

class APIStatusError(ChatProviderError):
155class APIStatusError(ChatProviderError):
156    """The error raised when the API returns a status code of 4xx or 5xx."""
157
158    status_code: int
159    request_id: str | None
160    trace_id: str | None
161
162    def __init__(
163        self,
164        status_code: int,
165        message: str,
166        *,
167        request_id: str | None = None,
168        trace_id: str | None = None,
169    ):
170        super().__init__(message)
171        self.status_code = status_code
172        self.request_id = request_id
173        self.trace_id = trace_id

The error raised when the API returns a status code of 4xx or 5xx.

APIStatusError( status_code: int, message: str, *, request_id: str | None = None, trace_id: str | None = None)
162    def __init__(
163        self,
164        status_code: int,
165        message: str,
166        *,
167        request_id: str | None = None,
168        trace_id: str | None = None,
169    ):
170        super().__init__(message)
171        self.status_code = status_code
172        self.request_id = request_id
173        self.trace_id = trace_id
status_code: int
request_id: str | None
trace_id: str | None
class APIEmptyResponseError(ChatProviderError):
176class APIEmptyResponseError(ChatProviderError):
177    """The error raised when the API returns an empty response."""

The error raised when the API returns an empty response.

def convert_httpx_error(error: httpx.HTTPError) -> ChatProviderError:
180def convert_httpx_error(error: httpx.HTTPError) -> ChatProviderError:
181    """Convert an httpx transport error to the corresponding ChatProviderError.
182
183    This is a shared utility for all chat providers. SDK-specific exceptions
184    (e.g. AnthropicError, OpenAIError) should be handled by each provider's
185    own conversion logic; only raw httpx exceptions that leak through
186    (typically during streaming) should be routed here.
187    """
188    import httpx
189
190    if isinstance(error, httpx.TimeoutException):
191        return APITimeoutError(str(error))
192    if isinstance(error, (httpx.NetworkError, httpx.RemoteProtocolError)):
193        return APIConnectionError(str(error))
194    if isinstance(error, httpx.HTTPStatusError):
195        req_id = error.response.headers.get("x-request-id")
196        trace_id = error.response.headers.get("x-trace-id")
197        return APIStatusError(
198            error.response.status_code, str(error), request_id=req_id, trace_id=trace_id
199        )
200    return ChatProviderError(f"HTTP error: {error}")

Convert an httpx transport error to the corresponding ChatProviderError.

This is a shared utility for all chat providers. SDK-specific exceptions (e.g. AnthropicError, OpenAIError) should be handled by each provider's own conversion logic; only raw httpx exceptions that leak through (typically during streaming) should be routed here.