kosong.chat_provider.kimi

  1import copy
  2import inspect
  3import mimetypes
  4import os
  5import uuid
  6from collections.abc import AsyncIterator, Mapping, Sequence
  7from typing import TYPE_CHECKING, Any, Literal, Self, Unpack, cast
  8
  9import httpx
 10from openai import AsyncOpenAI, AsyncStream, BaseModel, OpenAIError, omit
 11from openai._types import RequestFiles, RequestOptions
 12from openai.types.chat import (
 13    ChatCompletion,
 14    ChatCompletionChunk,
 15    ChatCompletionMessageFunctionToolCall,
 16    ChatCompletionMessageParam,
 17    ChatCompletionToolParam,
 18)
 19from openai.types.completion_usage import CompletionUsage
 20from typing_extensions import TypedDict
 21
 22from kosong.chat_provider import (
 23    ChatProvider,
 24    ChatProviderError,
 25    RetryableChatProvider,
 26    StreamedMessagePart,
 27    ThinkingEffort,
 28    TokenUsage,
 29)
 30from kosong.chat_provider.openai_common import (
 31    close_replaced_openai_client,
 32    convert_error,
 33    create_openai_client,
 34    tool_to_openai,
 35)
 36from kosong.message import (
 37    ContentPart,
 38    Message,
 39    TextPart,
 40    ThinkPart,
 41    ToolCall,
 42    ToolCallPart,
 43    VideoURLPart,
 44)
 45from kosong.tooling import Tool
 46from kosong.utils.jsonschema import JsonDict, ensure_property_types
 47
 48if TYPE_CHECKING:
 49
 50    def type_check(kimi: "Kimi"):
 51        _: ChatProvider = kimi
 52        _: RetryableChatProvider = kimi
 53
 54
 55class ThinkingConfig(TypedDict, total=False):
 56    type: Literal["enabled", "disabled"]
 57    keep: Any
 58    """Moonshot-specific ``thinking.keep`` switch for preserved thinking.
 59    Forwarded verbatim to the API; callers are responsible for choosing a value
 60    the server accepts (e.g. ``"all"``)."""
 61
 62
 63class ExtraBody(TypedDict, total=False, extra_items=Any):
 64    thinking: ThinkingConfig
 65
 66
 67class Kimi:
 68    """
 69    A chat provider that uses the Kimi API.
 70
 71    >>> chat_provider = Kimi(model="kimi-k2-turbo-preview", api_key="sk-1234567890")
 72    >>> chat_provider.name
 73    'kimi'
 74    >>> chat_provider.model_name
 75    'kimi-k2-turbo-preview'
 76    >>> chat_provider.with_generation_kwargs(temperature=0)._generation_kwargs
 77    {'temperature': 0}
 78    >>> chat_provider._generation_kwargs
 79    {}
 80    """
 81
 82    name = "kimi"
 83
 84    class GenerationKwargs(TypedDict, total=False):
 85        """
 86        See https://platform.moonshot.ai/docs/api/chat#request-body.
 87        """
 88
 89        max_completion_tokens: int | None
 90        max_tokens: int | None
 91        """Deprecated alias. Normalized to ``max_completion_tokens`` before requests."""
 92        temperature: float | None
 93        top_p: float | None
 94        n: int | None
 95        presence_penalty: float | None
 96        frequency_penalty: float | None
 97        stop: str | list[str] | None
 98        prompt_cache_key: str | None
 99        reasoning_effort: str | None
100        """Legacy explicit passthrough. `with_thinking` uses `extra_body.thinking` instead."""
101        extra_body: ExtraBody | None
102
103    def __init__(
104        self,
105        *,
106        model: str,
107        api_key: str | None = None,
108        base_url: str | None = None,
109        stream: bool = True,
110        **client_kwargs: Any,
111    ):
112        if api_key is None:
113            api_key = os.getenv("KIMI_API_KEY")
114        if api_key is None:
115            raise ChatProviderError(
116                "The api_key client option or the KIMI_API_KEY environment variable is not set"
117            )
118        if base_url is None:
119            base_url = os.getenv("KIMI_BASE_URL", "https://api.moonshot.ai/v1")
120
121        self.model: str = model
122        """The name of the model to use."""
123        self.stream: bool = stream
124        """Whether to generate responses as a stream."""
125        self._api_key: str | None = api_key
126        self._base_url: str | None = base_url
127        self._client_kwargs: dict[str, Any] = dict(client_kwargs)
128        self.client: AsyncOpenAI = create_openai_client(
129            api_key=self._api_key,
130            base_url=self._base_url,
131            client_kwargs=self._client_kwargs,
132        )
133        """The underlying `AsyncOpenAI` client."""
134        self._generation_kwargs: Kimi.GenerationKwargs = {}
135        self._thinking_effort: ThinkingEffort | None = None
136        """Thinking state kept separately from parameters serialized onto the wire."""
137
138    @property
139    def model_name(self) -> str:
140        return self.model
141
142    @property
143    def thinking_effort(self) -> ThinkingEffort | None:
144        return self._thinking_effort
145
146    async def generate(
147        self,
148        system_prompt: str,
149        tools: Sequence[Tool],
150        history: Sequence[Message],
151        *,
152        generation_overrides: Mapping[str, Any] | None = None,
153    ) -> "KimiStreamedMessage":
154        messages: list[ChatCompletionMessageParam] = []
155        if system_prompt:
156            messages.append({"role": "system", "content": system_prompt})
157        messages.extend(_convert_message(message) for message in history)
158
159        generation_kwargs: dict[str, Any] = dict(self._generation_kwargs)
160        if generation_overrides:
161            generation_kwargs.update(
162                _normalize_generation_kwargs(
163                    cast(Kimi.GenerationKwargs, dict(generation_overrides))
164                )
165            )
166        if generation_kwargs.get("max_completion_tokens") is None:
167            generation_kwargs.pop("max_completion_tokens", None)
168
169        try:
170            if self.stream:
171                # ``with_raw_response`` eagerly reads the response body in the
172                # OpenAI SDK. Use the normal streaming path so callers receive
173                # the AsyncStream as soon as response headers arrive.
174                parsed_response = await cast(Any, self.client.chat.completions.create)(
175                    model=self.model,
176                    messages=messages,
177                    tools=(_convert_tool(tool) for tool in tools),
178                    stream=True,
179                    stream_options={"include_usage": True},
180                    **generation_kwargs,
181                )
182                trace_id = parsed_response.response.headers.get("x-trace-id")
183            else:
184                raw_response = await self.client.chat.completions.with_raw_response.create(
185                    model=self.model,
186                    messages=messages,
187                    tools=(_convert_tool(tool) for tool in tools),
188                    stream=False,
189                    stream_options=omit,
190                    **generation_kwargs,
191                )
192                trace_id = raw_response.headers.get("x-trace-id")
193                parsed_response = raw_response.parse()
194                if inspect.isawaitable(parsed_response):
195                    parsed_response = await parsed_response
196            return KimiStreamedMessage(parsed_response, trace_id=trace_id)
197        except (OpenAIError, httpx.HTTPError) as e:
198            raise convert_error(e) from e
199
200    def on_retryable_error(self, error: BaseException) -> bool:
201        old_client = self.client
202        # Read api_key from the live client (not self._api_key) so that
203        # OAuth token refreshes applied via client.api_key are preserved.
204        current_api_key = old_client.api_key
205        self.client = create_openai_client(
206            api_key=current_api_key,
207            base_url=self._base_url,
208            client_kwargs=self._client_kwargs,
209        )
210        self._api_key = current_api_key
211        close_replaced_openai_client(old_client, client_kwargs=self._client_kwargs)
212        return True
213
214    def with_thinking(self, effort: ThinkingEffort) -> Self:
215        new_self = self.with_extra_body(
216            {
217                "thinking": {
218                    "type": "enabled" if effort != "off" else "disabled",
219                }
220            }
221        )
222        new_self._thinking_effort = effort
223        return new_self
224
225    def with_generation_kwargs(self, **kwargs: Unpack[GenerationKwargs]) -> Self:
226        """
227        Copy the chat provider, updating the generation kwargs with the given values.
228
229        Returns:
230            Self: A new instance of the chat provider with updated generation kwargs.
231        """
232        new_self = copy.copy(self)
233        new_self._generation_kwargs = copy.deepcopy(self._generation_kwargs)
234        new_self._generation_kwargs.update(_normalize_generation_kwargs(kwargs))
235        return new_self
236
237    def with_extra_body(self, extra_body: ExtraBody) -> Self:
238        """
239        Copy the chat provider, updating the extra_body in generation kwargs.
240
241        Top-level keys follow last-writer-wins semantics, except for the
242        ``thinking`` key: its sub-dict is merged field-by-field so that a
243        later call adding ``thinking.keep`` does not erase a ``thinking.type``
244        installed by an earlier ``with_thinking`` call.
245
246        Returns:
247            Self: A new instance of the chat provider with updated extra_body.
248        """
249        new_self = copy.copy(self)
250        new_self._generation_kwargs = copy.deepcopy(self._generation_kwargs)
251        old_extra_body = new_self._generation_kwargs.get("extra_body") or {}
252        new_extra_body: ExtraBody = {**old_extra_body, **extra_body}
253        old_thinking = old_extra_body.get("thinking")
254        new_thinking = extra_body.get("thinking")
255        if old_thinking is not None and new_thinking is not None:
256            new_extra_body["thinking"] = {**old_thinking, **new_thinking}
257        new_self._generation_kwargs["extra_body"] = new_extra_body
258        return new_self
259
260    @property
261    def model_parameters(self) -> dict[str, Any]:
262        """
263        The parameters of the model to use.
264
265        For tracing/logging purposes.
266        """
267
268        model_parameters: dict[str, Any] = {"base_url": str(self.client.base_url)}
269        model_parameters.update(self._generation_kwargs)
270        return model_parameters
271
272    @property
273    def files(self) -> "KimiFiles":
274        return KimiFiles(self.client)
275
276
277class KimiFiles:
278    def __init__(self, client: AsyncOpenAI) -> None:
279        self._client = client
280
281    async def upload_video(self, *, data: bytes, mime_type: str) -> VideoURLPart:
282        """Upload a video to Kimi files API and return a video URL content part."""
283        if not mime_type.startswith("video/"):
284            raise ChatProviderError(f"Expected a video mime type, got {mime_type}")
285        url = await self._upload_file(data=data, mime_type=mime_type, purpose="video")
286        return VideoURLPart(video_url=VideoURLPart.VideoURL(url=url))
287
288    async def _upload_file(self, *, data: bytes, mime_type: str, purpose: "KimiFilePurpose") -> str:
289        filename = _guess_filename(mime_type)
290        files: RequestFiles = {"file": (filename, data, mime_type)}
291        options: RequestOptions = {"headers": {"Content-Type": "multipart/form-data"}}
292        try:
293            response: KimiFileObject = await self._client.post(
294                "/files",
295                cast_to=KimiFileObject,
296                body={"purpose": purpose},
297                files=files,
298                options=options,
299            )
300        except (OpenAIError, httpx.HTTPError) as e:
301            raise convert_error(e) from e
302        return f"ms://{response.id}"
303
304
305class KimiFileObject(BaseModel):
306    id: str
307
308
309type KimiFilePurpose = Literal["video", "image"]
310
311
312def _guess_filename(mime_type: str) -> str:
313    extension = mimetypes.guess_extension(mime_type) or ".bin"
314    return f"upload{extension}"
315
316
317def _normalize_generation_kwargs(kwargs: Kimi.GenerationKwargs) -> Kimi.GenerationKwargs:
318    normalized: dict[str, Any] = dict(kwargs)
319    if "max_tokens" in normalized:
320        max_tokens = normalized.pop("max_tokens")
321        if "max_completion_tokens" not in normalized:
322            normalized["max_completion_tokens"] = max_tokens
323    return cast(Kimi.GenerationKwargs, normalized)
324
325
326def _convert_message(message: Message) -> ChatCompletionMessageParam:
327    message = message.model_copy(deep=True)
328    reasoning_content: str = ""
329    content: list[ContentPart] = []
330    has_reasoning = False
331    for part in message.content:
332        if isinstance(part, ThinkPart):
333            has_reasoning = True
334            reasoning_content += part.think
335        else:
336            content.append(part)
337    message.content = content
338    dumped_message = message.model_dump(exclude_none=True)
339    if (
340        message.role == "assistant"
341        and message.tool_calls
342        and _is_effectively_empty_content_parts(content)
343    ):
344        # OpenAI-compatible APIs allow assistant tool-call messages to omit
345        # `content`, but the Kimi-for-Coding compat layer rejects a content
346        # list that contains an empty text part (observed: `content:
347        # [{"type": "text", "text": ""}]` -> 400 "text content is empty").
348        # Dropping `content` entirely is always accepted, so do that whenever
349        # the visible content is effectively empty alongside a tool call.
350        dumped_message.pop("content", None)
351    if has_reasoning:
352        dumped_message["reasoning_content"] = reasoning_content
353    return cast(ChatCompletionMessageParam, dumped_message)
354
355
356def _is_effectively_empty_content_parts(content: Sequence[ContentPart]) -> bool:
357    for part in content:
358        if not isinstance(part, TextPart):
359            return False
360        if part.text.strip():
361            return False
362    return True
363
364
365def _convert_tool(tool: Tool) -> ChatCompletionToolParam:
366    if tool.name.startswith("$"):
367        # Kimi builtin functions start with `$`
368        return cast(
369            ChatCompletionToolParam,
370            {
371                "type": "builtin_function",
372                "function": {
373                    "name": tool.name,
374                    # no need to set description and parameters
375                },
376            },
377        )
378    converted = tool_to_openai(tool)
379    # Moonshot's API rejects parameter schemas whose nested properties omit
380    # `type` (e.g. enum-only properties exposed by some MCP servers). Patch
381    # the schema locally so such tools keep working against Kimi without
382    # requiring every MCP server author to tighten their schemas.
383    function = converted["function"]
384    parameters = function.get("parameters")
385    if isinstance(parameters, dict):
386        normalized = ensure_property_types(cast(JsonDict, parameters))
387        function["parameters"] = cast(dict[str, object], normalized)
388    return converted
389
390
391class KimiStreamedMessage:
392    """The streamed message of the Kimi chat provider."""
393
394    def __init__(
395        self,
396        response: ChatCompletion | AsyncStream[ChatCompletionChunk],
397        *,
398        trace_id: str | None = None,
399    ):
400        if isinstance(response, ChatCompletion):
401            self._iter = self._convert_non_stream_response(response)
402        else:
403            self._iter = self._convert_stream_response(response)
404        self._id: str | None = None
405        self._usage: CompletionUsage | None = None
406        self._trace_id = trace_id
407
408    def __aiter__(self) -> AsyncIterator[StreamedMessagePart]:
409        return self
410
411    async def __anext__(self) -> StreamedMessagePart:
412        return await self._iter.__anext__()
413
414    @property
415    def id(self) -> str | None:
416        return self._id
417
418    @property
419    def trace_id(self) -> str | None:
420        return self._trace_id
421
422    @property
423    def usage(self) -> TokenUsage | None:
424        if self._usage:
425            cached = 0
426            other_input = self._usage.prompt_tokens
427            if hasattr(self._usage, "cached_tokens"):
428                # https://platform.moonshot.cn/docs/api/chat#%E8%BF%94%E5%9B%9E%E5%86%85%E5%AE%B9
429                # TODO: delete this when Moonshot API becomes compatible with OpenAI API
430                cached = getattr(self._usage, "cached_tokens") or 0  # noqa: B009
431                other_input -= cached
432            elif (
433                self._usage.prompt_tokens_details
434                and self._usage.prompt_tokens_details.cached_tokens
435            ):
436                cached = self._usage.prompt_tokens_details.cached_tokens
437                other_input -= cached
438            return TokenUsage(
439                input_other=other_input,
440                output=self._usage.completion_tokens,
441                input_cache_read=cached,
442            )
443        return None
444
445    async def _convert_non_stream_response(
446        self,
447        response: ChatCompletion,
448    ) -> AsyncIterator[StreamedMessagePart]:
449        self._id = response.id
450        self._usage = response.usage
451        message = response.choices[0].message
452        reasoning_content = getattr(message, "reasoning_content", None)
453        if reasoning_content is not None:
454            assert isinstance(reasoning_content, str)
455            yield ThinkPart(think=reasoning_content)
456        if message.content:
457            yield TextPart(text=message.content)
458        if message.tool_calls:
459            for tool_call in message.tool_calls:
460                if isinstance(tool_call, ChatCompletionMessageFunctionToolCall):
461                    yield ToolCall(
462                        id=tool_call.id or str(uuid.uuid4()),
463                        function=ToolCall.FunctionBody(
464                            name=tool_call.function.name,
465                            arguments=tool_call.function.arguments,
466                        ),
467                    )
468
469    async def _convert_stream_response(
470        self,
471        response: AsyncIterator[ChatCompletionChunk],
472    ) -> AsyncIterator[StreamedMessagePart]:
473        try:
474            async for chunk in response:
475                if chunk.id:
476                    self._id = chunk.id
477                if usage := extract_usage_from_chunk(chunk):
478                    self._usage = usage
479
480                if not chunk.choices:
481                    continue
482
483                delta = chunk.choices[0].delta
484
485                # convert thinking content — an empty string means "reasoned
486                # but empty", not "no reasoning": keep it as a ThinkPart so
487                # the distinction round-trips to the server (preserved-thinking
488                # backends require reasoning_content on every assistant turn)
489                reasoning_content = getattr(delta, "reasoning_content", None)
490                if reasoning_content is not None:
491                    assert isinstance(reasoning_content, str)
492                    yield ThinkPart(think=reasoning_content)
493
494                # convert text content
495                if delta.content:
496                    yield TextPart(text=delta.content)
497
498                # convert tool calls
499                for tool_call in delta.tool_calls or []:
500                    if not tool_call.function:
501                        continue
502
503                    if tool_call.function.name:
504                        yield ToolCall(
505                            id=tool_call.id or str(uuid.uuid4()),
506                            function=ToolCall.FunctionBody(
507                                name=tool_call.function.name,
508                                arguments=tool_call.function.arguments,
509                            ),
510                        )
511                    elif tool_call.function.arguments:
512                        yield ToolCallPart(
513                            arguments_part=tool_call.function.arguments,
514                        )
515                    else:
516                        # skip empty tool calls
517                        pass
518        except (OpenAIError, httpx.HTTPError) as e:
519            raise convert_error(e) from e
520
521
522def extract_usage_from_chunk(chunk: ChatCompletionChunk) -> CompletionUsage | None:
523    if chunk.usage:
524        return chunk.usage
525    if not chunk.choices:
526        return None
527    choice_dump: dict[str, object] = chunk.choices[0].model_dump()
528    raw_usage = choice_dump.get("usage")
529    if isinstance(raw_usage, CompletionUsage):
530        return raw_usage
531    if isinstance(raw_usage, dict):
532        return CompletionUsage.model_validate(raw_usage)
533    return None
534
535
536if __name__ == "__main__":
537
538    async def _dev_main():
539        chat = Kimi(model="kimi-k2-turbo-preview", stream=False)
540        system_prompt = ""
541        history = [
542            Message(role="user", content="Hello, who is Confucius?"),
543        ]
544        stream = await chat.with_generation_kwargs(
545            temperature=0,
546            max_completion_tokens=1000,
547        ).generate(system_prompt, [], history)
548        async for part in stream:
549            print(part.model_dump(exclude_none=True))
550        print("id:", stream.id)
551        print("usage:", stream.usage)
552
553    import asyncio
554
555    from dotenv import load_dotenv
556
557    load_dotenv()
558    asyncio.run(_dev_main())
class ThinkingConfig(typing_extensions.TypedDict):
56class ThinkingConfig(TypedDict, total=False):
57    type: Literal["enabled", "disabled"]
58    keep: Any
59    """Moonshot-specific ``thinking.keep`` switch for preserved thinking.
60    Forwarded verbatim to the API; callers are responsible for choosing a value
61    the server accepts (e.g. ``"all"``)."""
type: Literal['enabled', 'disabled']
keep: Any

Moonshot-specific thinking.keep switch for preserved thinking. Forwarded verbatim to the API; callers are responsible for choosing a value the server accepts (e.g. "all").

class ExtraBody(typing_extensions.TypedDict):
64class ExtraBody(TypedDict, total=False, extra_items=Any):
65    thinking: ThinkingConfig
thinking: ThinkingConfig
class Kimi:
 68class Kimi:
 69    """
 70    A chat provider that uses the Kimi API.
 71
 72    >>> chat_provider = Kimi(model="kimi-k2-turbo-preview", api_key="sk-1234567890")
 73    >>> chat_provider.name
 74    'kimi'
 75    >>> chat_provider.model_name
 76    'kimi-k2-turbo-preview'
 77    >>> chat_provider.with_generation_kwargs(temperature=0)._generation_kwargs
 78    {'temperature': 0}
 79    >>> chat_provider._generation_kwargs
 80    {}
 81    """
 82
 83    name = "kimi"
 84
 85    class GenerationKwargs(TypedDict, total=False):
 86        """
 87        See https://platform.moonshot.ai/docs/api/chat#request-body.
 88        """
 89
 90        max_completion_tokens: int | None
 91        max_tokens: int | None
 92        """Deprecated alias. Normalized to ``max_completion_tokens`` before requests."""
 93        temperature: float | None
 94        top_p: float | None
 95        n: int | None
 96        presence_penalty: float | None
 97        frequency_penalty: float | None
 98        stop: str | list[str] | None
 99        prompt_cache_key: str | None
100        reasoning_effort: str | None
101        """Legacy explicit passthrough. `with_thinking` uses `extra_body.thinking` instead."""
102        extra_body: ExtraBody | None
103
104    def __init__(
105        self,
106        *,
107        model: str,
108        api_key: str | None = None,
109        base_url: str | None = None,
110        stream: bool = True,
111        **client_kwargs: Any,
112    ):
113        if api_key is None:
114            api_key = os.getenv("KIMI_API_KEY")
115        if api_key is None:
116            raise ChatProviderError(
117                "The api_key client option or the KIMI_API_KEY environment variable is not set"
118            )
119        if base_url is None:
120            base_url = os.getenv("KIMI_BASE_URL", "https://api.moonshot.ai/v1")
121
122        self.model: str = model
123        """The name of the model to use."""
124        self.stream: bool = stream
125        """Whether to generate responses as a stream."""
126        self._api_key: str | None = api_key
127        self._base_url: str | None = base_url
128        self._client_kwargs: dict[str, Any] = dict(client_kwargs)
129        self.client: AsyncOpenAI = create_openai_client(
130            api_key=self._api_key,
131            base_url=self._base_url,
132            client_kwargs=self._client_kwargs,
133        )
134        """The underlying `AsyncOpenAI` client."""
135        self._generation_kwargs: Kimi.GenerationKwargs = {}
136        self._thinking_effort: ThinkingEffort | None = None
137        """Thinking state kept separately from parameters serialized onto the wire."""
138
139    @property
140    def model_name(self) -> str:
141        return self.model
142
143    @property
144    def thinking_effort(self) -> ThinkingEffort | None:
145        return self._thinking_effort
146
147    async def generate(
148        self,
149        system_prompt: str,
150        tools: Sequence[Tool],
151        history: Sequence[Message],
152        *,
153        generation_overrides: Mapping[str, Any] | None = None,
154    ) -> "KimiStreamedMessage":
155        messages: list[ChatCompletionMessageParam] = []
156        if system_prompt:
157            messages.append({"role": "system", "content": system_prompt})
158        messages.extend(_convert_message(message) for message in history)
159
160        generation_kwargs: dict[str, Any] = dict(self._generation_kwargs)
161        if generation_overrides:
162            generation_kwargs.update(
163                _normalize_generation_kwargs(
164                    cast(Kimi.GenerationKwargs, dict(generation_overrides))
165                )
166            )
167        if generation_kwargs.get("max_completion_tokens") is None:
168            generation_kwargs.pop("max_completion_tokens", None)
169
170        try:
171            if self.stream:
172                # ``with_raw_response`` eagerly reads the response body in the
173                # OpenAI SDK. Use the normal streaming path so callers receive
174                # the AsyncStream as soon as response headers arrive.
175                parsed_response = await cast(Any, self.client.chat.completions.create)(
176                    model=self.model,
177                    messages=messages,
178                    tools=(_convert_tool(tool) for tool in tools),
179                    stream=True,
180                    stream_options={"include_usage": True},
181                    **generation_kwargs,
182                )
183                trace_id = parsed_response.response.headers.get("x-trace-id")
184            else:
185                raw_response = await self.client.chat.completions.with_raw_response.create(
186                    model=self.model,
187                    messages=messages,
188                    tools=(_convert_tool(tool) for tool in tools),
189                    stream=False,
190                    stream_options=omit,
191                    **generation_kwargs,
192                )
193                trace_id = raw_response.headers.get("x-trace-id")
194                parsed_response = raw_response.parse()
195                if inspect.isawaitable(parsed_response):
196                    parsed_response = await parsed_response
197            return KimiStreamedMessage(parsed_response, trace_id=trace_id)
198        except (OpenAIError, httpx.HTTPError) as e:
199            raise convert_error(e) from e
200
201    def on_retryable_error(self, error: BaseException) -> bool:
202        old_client = self.client
203        # Read api_key from the live client (not self._api_key) so that
204        # OAuth token refreshes applied via client.api_key are preserved.
205        current_api_key = old_client.api_key
206        self.client = create_openai_client(
207            api_key=current_api_key,
208            base_url=self._base_url,
209            client_kwargs=self._client_kwargs,
210        )
211        self._api_key = current_api_key
212        close_replaced_openai_client(old_client, client_kwargs=self._client_kwargs)
213        return True
214
215    def with_thinking(self, effort: ThinkingEffort) -> Self:
216        new_self = self.with_extra_body(
217            {
218                "thinking": {
219                    "type": "enabled" if effort != "off" else "disabled",
220                }
221            }
222        )
223        new_self._thinking_effort = effort
224        return new_self
225
226    def with_generation_kwargs(self, **kwargs: Unpack[GenerationKwargs]) -> Self:
227        """
228        Copy the chat provider, updating the generation kwargs with the given values.
229
230        Returns:
231            Self: A new instance of the chat provider with updated generation kwargs.
232        """
233        new_self = copy.copy(self)
234        new_self._generation_kwargs = copy.deepcopy(self._generation_kwargs)
235        new_self._generation_kwargs.update(_normalize_generation_kwargs(kwargs))
236        return new_self
237
238    def with_extra_body(self, extra_body: ExtraBody) -> Self:
239        """
240        Copy the chat provider, updating the extra_body in generation kwargs.
241
242        Top-level keys follow last-writer-wins semantics, except for the
243        ``thinking`` key: its sub-dict is merged field-by-field so that a
244        later call adding ``thinking.keep`` does not erase a ``thinking.type``
245        installed by an earlier ``with_thinking`` call.
246
247        Returns:
248            Self: A new instance of the chat provider with updated extra_body.
249        """
250        new_self = copy.copy(self)
251        new_self._generation_kwargs = copy.deepcopy(self._generation_kwargs)
252        old_extra_body = new_self._generation_kwargs.get("extra_body") or {}
253        new_extra_body: ExtraBody = {**old_extra_body, **extra_body}
254        old_thinking = old_extra_body.get("thinking")
255        new_thinking = extra_body.get("thinking")
256        if old_thinking is not None and new_thinking is not None:
257            new_extra_body["thinking"] = {**old_thinking, **new_thinking}
258        new_self._generation_kwargs["extra_body"] = new_extra_body
259        return new_self
260
261    @property
262    def model_parameters(self) -> dict[str, Any]:
263        """
264        The parameters of the model to use.
265
266        For tracing/logging purposes.
267        """
268
269        model_parameters: dict[str, Any] = {"base_url": str(self.client.base_url)}
270        model_parameters.update(self._generation_kwargs)
271        return model_parameters
272
273    @property
274    def files(self) -> "KimiFiles":
275        return KimiFiles(self.client)

A chat provider that uses the Kimi API.

>>> chat_provider = Kimi(model="kimi-k2-turbo-preview", api_key="sk-1234567890")
>>> chat_provider.name
'kimi'
>>> chat_provider.model_name
'kimi-k2-turbo-preview'
>>> chat_provider.with_generation_kwargs(temperature=0)._generation_kwargs
{'temperature': 0}
>>> chat_provider._generation_kwargs
{}
Kimi( *, model: str, api_key: str | None = None, base_url: str | None = None, stream: bool = True, **client_kwargs: Any)
104    def __init__(
105        self,
106        *,
107        model: str,
108        api_key: str | None = None,
109        base_url: str | None = None,
110        stream: bool = True,
111        **client_kwargs: Any,
112    ):
113        if api_key is None:
114            api_key = os.getenv("KIMI_API_KEY")
115        if api_key is None:
116            raise ChatProviderError(
117                "The api_key client option or the KIMI_API_KEY environment variable is not set"
118            )
119        if base_url is None:
120            base_url = os.getenv("KIMI_BASE_URL", "https://api.moonshot.ai/v1")
121
122        self.model: str = model
123        """The name of the model to use."""
124        self.stream: bool = stream
125        """Whether to generate responses as a stream."""
126        self._api_key: str | None = api_key
127        self._base_url: str | None = base_url
128        self._client_kwargs: dict[str, Any] = dict(client_kwargs)
129        self.client: AsyncOpenAI = create_openai_client(
130            api_key=self._api_key,
131            base_url=self._base_url,
132            client_kwargs=self._client_kwargs,
133        )
134        """The underlying `AsyncOpenAI` client."""
135        self._generation_kwargs: Kimi.GenerationKwargs = {}
136        self._thinking_effort: ThinkingEffort | None = None
137        """Thinking state kept separately from parameters serialized onto the wire."""
name = 'kimi'
model: str

The name of the model to use.

stream: bool

Whether to generate responses as a stream.

client: openai.AsyncOpenAI

The underlying AsyncOpenAI client.

model_name: str
139    @property
140    def model_name(self) -> str:
141        return self.model
thinking_effort: ThinkingEffort | None
143    @property
144    def thinking_effort(self) -> ThinkingEffort | None:
145        return self._thinking_effort
async def generate( self, system_prompt: str, tools: Sequence[kosong.tooling.Tool], history: Sequence[kosong.message.Message], *, generation_overrides: Mapping[str, Any] | None = None) -> KimiStreamedMessage:
147    async def generate(
148        self,
149        system_prompt: str,
150        tools: Sequence[Tool],
151        history: Sequence[Message],
152        *,
153        generation_overrides: Mapping[str, Any] | None = None,
154    ) -> "KimiStreamedMessage":
155        messages: list[ChatCompletionMessageParam] = []
156        if system_prompt:
157            messages.append({"role": "system", "content": system_prompt})
158        messages.extend(_convert_message(message) for message in history)
159
160        generation_kwargs: dict[str, Any] = dict(self._generation_kwargs)
161        if generation_overrides:
162            generation_kwargs.update(
163                _normalize_generation_kwargs(
164                    cast(Kimi.GenerationKwargs, dict(generation_overrides))
165                )
166            )
167        if generation_kwargs.get("max_completion_tokens") is None:
168            generation_kwargs.pop("max_completion_tokens", None)
169
170        try:
171            if self.stream:
172                # ``with_raw_response`` eagerly reads the response body in the
173                # OpenAI SDK. Use the normal streaming path so callers receive
174                # the AsyncStream as soon as response headers arrive.
175                parsed_response = await cast(Any, self.client.chat.completions.create)(
176                    model=self.model,
177                    messages=messages,
178                    tools=(_convert_tool(tool) for tool in tools),
179                    stream=True,
180                    stream_options={"include_usage": True},
181                    **generation_kwargs,
182                )
183                trace_id = parsed_response.response.headers.get("x-trace-id")
184            else:
185                raw_response = await self.client.chat.completions.with_raw_response.create(
186                    model=self.model,
187                    messages=messages,
188                    tools=(_convert_tool(tool) for tool in tools),
189                    stream=False,
190                    stream_options=omit,
191                    **generation_kwargs,
192                )
193                trace_id = raw_response.headers.get("x-trace-id")
194                parsed_response = raw_response.parse()
195                if inspect.isawaitable(parsed_response):
196                    parsed_response = await parsed_response
197            return KimiStreamedMessage(parsed_response, trace_id=trace_id)
198        except (OpenAIError, httpx.HTTPError) as e:
199            raise convert_error(e) from e
def on_retryable_error(self, error: BaseException) -> bool:
201    def on_retryable_error(self, error: BaseException) -> bool:
202        old_client = self.client
203        # Read api_key from the live client (not self._api_key) so that
204        # OAuth token refreshes applied via client.api_key are preserved.
205        current_api_key = old_client.api_key
206        self.client = create_openai_client(
207            api_key=current_api_key,
208            base_url=self._base_url,
209            client_kwargs=self._client_kwargs,
210        )
211        self._api_key = current_api_key
212        close_replaced_openai_client(old_client, client_kwargs=self._client_kwargs)
213        return True
def with_thinking(self, effort: ThinkingEffort) -> Self:
215    def with_thinking(self, effort: ThinkingEffort) -> Self:
216        new_self = self.with_extra_body(
217            {
218                "thinking": {
219                    "type": "enabled" if effort != "off" else "disabled",
220                }
221            }
222        )
223        new_self._thinking_effort = effort
224        return new_self
def with_generation_kwargs( self, **kwargs: Unpack[Kimi.GenerationKwargs]) -> Self:
226    def with_generation_kwargs(self, **kwargs: Unpack[GenerationKwargs]) -> Self:
227        """
228        Copy the chat provider, updating the generation kwargs with the given values.
229
230        Returns:
231            Self: A new instance of the chat provider with updated generation kwargs.
232        """
233        new_self = copy.copy(self)
234        new_self._generation_kwargs = copy.deepcopy(self._generation_kwargs)
235        new_self._generation_kwargs.update(_normalize_generation_kwargs(kwargs))
236        return new_self

Copy the chat provider, updating the generation kwargs with the given values.

Returns:

Self: A new instance of the chat provider with updated generation kwargs.

def with_extra_body(self, extra_body: ExtraBody) -> Self:
238    def with_extra_body(self, extra_body: ExtraBody) -> Self:
239        """
240        Copy the chat provider, updating the extra_body in generation kwargs.
241
242        Top-level keys follow last-writer-wins semantics, except for the
243        ``thinking`` key: its sub-dict is merged field-by-field so that a
244        later call adding ``thinking.keep`` does not erase a ``thinking.type``
245        installed by an earlier ``with_thinking`` call.
246
247        Returns:
248            Self: A new instance of the chat provider with updated extra_body.
249        """
250        new_self = copy.copy(self)
251        new_self._generation_kwargs = copy.deepcopy(self._generation_kwargs)
252        old_extra_body = new_self._generation_kwargs.get("extra_body") or {}
253        new_extra_body: ExtraBody = {**old_extra_body, **extra_body}
254        old_thinking = old_extra_body.get("thinking")
255        new_thinking = extra_body.get("thinking")
256        if old_thinking is not None and new_thinking is not None:
257            new_extra_body["thinking"] = {**old_thinking, **new_thinking}
258        new_self._generation_kwargs["extra_body"] = new_extra_body
259        return new_self

Copy the chat provider, updating the extra_body in generation kwargs.

Top-level keys follow last-writer-wins semantics, except for the thinking key: its sub-dict is merged field-by-field so that a later call adding thinking.keep does not erase a thinking.type installed by an earlier with_thinking call.

Returns:

Self: A new instance of the chat provider with updated extra_body.

model_parameters: dict[str, typing.Any]
261    @property
262    def model_parameters(self) -> dict[str, Any]:
263        """
264        The parameters of the model to use.
265
266        For tracing/logging purposes.
267        """
268
269        model_parameters: dict[str, Any] = {"base_url": str(self.client.base_url)}
270        model_parameters.update(self._generation_kwargs)
271        return model_parameters

The parameters of the model to use.

For tracing/logging purposes.

files: KimiFiles
273    @property
274    def files(self) -> "KimiFiles":
275        return KimiFiles(self.client)
class Kimi.GenerationKwargs(typing_extensions.TypedDict):
 85    class GenerationKwargs(TypedDict, total=False):
 86        """
 87        See https://platform.moonshot.ai/docs/api/chat#request-body.
 88        """
 89
 90        max_completion_tokens: int | None
 91        max_tokens: int | None
 92        """Deprecated alias. Normalized to ``max_completion_tokens`` before requests."""
 93        temperature: float | None
 94        top_p: float | None
 95        n: int | None
 96        presence_penalty: float | None
 97        frequency_penalty: float | None
 98        stop: str | list[str] | None
 99        prompt_cache_key: str | None
100        reasoning_effort: str | None
101        """Legacy explicit passthrough. `with_thinking` uses `extra_body.thinking` instead."""
102        extra_body: ExtraBody | None
max_completion_tokens: int | None
max_tokens: int | None

Deprecated alias. Normalized to max_completion_tokens before requests.

temperature: float | None
top_p: float | None
n: int | None
presence_penalty: float | None
frequency_penalty: float | None
stop: str | list[str] | None
prompt_cache_key: str | None
reasoning_effort: str | None

Legacy explicit passthrough. with_thinking uses extra_body.thinking instead.

extra_body: ExtraBody | None
class KimiFiles:
278class KimiFiles:
279    def __init__(self, client: AsyncOpenAI) -> None:
280        self._client = client
281
282    async def upload_video(self, *, data: bytes, mime_type: str) -> VideoURLPart:
283        """Upload a video to Kimi files API and return a video URL content part."""
284        if not mime_type.startswith("video/"):
285            raise ChatProviderError(f"Expected a video mime type, got {mime_type}")
286        url = await self._upload_file(data=data, mime_type=mime_type, purpose="video")
287        return VideoURLPart(video_url=VideoURLPart.VideoURL(url=url))
288
289    async def _upload_file(self, *, data: bytes, mime_type: str, purpose: "KimiFilePurpose") -> str:
290        filename = _guess_filename(mime_type)
291        files: RequestFiles = {"file": (filename, data, mime_type)}
292        options: RequestOptions = {"headers": {"Content-Type": "multipart/form-data"}}
293        try:
294            response: KimiFileObject = await self._client.post(
295                "/files",
296                cast_to=KimiFileObject,
297                body={"purpose": purpose},
298                files=files,
299                options=options,
300            )
301        except (OpenAIError, httpx.HTTPError) as e:
302            raise convert_error(e) from e
303        return f"ms://{response.id}"
KimiFiles(client: openai.AsyncOpenAI)
279    def __init__(self, client: AsyncOpenAI) -> None:
280        self._client = client
async def upload_video(self, *, data: bytes, mime_type: str) -> kosong.message.VideoURLPart:
282    async def upload_video(self, *, data: bytes, mime_type: str) -> VideoURLPart:
283        """Upload a video to Kimi files API and return a video URL content part."""
284        if not mime_type.startswith("video/"):
285            raise ChatProviderError(f"Expected a video mime type, got {mime_type}")
286        url = await self._upload_file(data=data, mime_type=mime_type, purpose="video")
287        return VideoURLPart(video_url=VideoURLPart.VideoURL(url=url))

Upload a video to Kimi files API and return a video URL content part.

class KimiFileObject(openai.BaseModel):
306class KimiFileObject(BaseModel):
307    id: str

!!! abstract "Usage Documentation" Models

A base class for creating Pydantic models.

Attributes:
  • __class_vars__: The names of the class variables defined on the model.
  • __private_attributes__: Metadata about the private attributes of the model.
  • __signature__: The synthesized __init__ [Signature][inspect.Signature] of the model.
  • __pydantic_complete__: Whether model building is completed, or if there are still undefined fields.
  • __pydantic_core_schema__: The core schema of the model.
  • __pydantic_custom_init__: Whether the model has a custom __init__ function.
  • __pydantic_decorators__: Metadata containing the decorators defined on the model. This replaces Model.__validators__ and Model.__root_validators__ from Pydantic V1.
  • __pydantic_generic_metadata__: Metadata for generic models; contains data used for a similar purpose to __args__, __origin__, __parameters__ in typing-module generics. May eventually be replaced by these.
  • __pydantic_parent_namespace__: Parent namespace of the model, used for automatic rebuilding of models.
  • __pydantic_post_init__: The name of the post-init method for the model, if defined.
  • __pydantic_root_model__: Whether the model is a [RootModel][pydantic.root_model.RootModel].
  • __pydantic_serializer__: The pydantic-core SchemaSerializer used to dump instances of the model.
  • __pydantic_validator__: The pydantic-core SchemaValidator used to validate instances of the model.
  • __pydantic_fields__: A dictionary of field names and their corresponding [FieldInfo][pydantic.fields.FieldInfo] objects.
  • __pydantic_computed_fields__: A dictionary of computed field names and their corresponding [ComputedFieldInfo][pydantic.fields.ComputedFieldInfo] objects.
  • __pydantic_extra__: A dictionary containing extra values, if [extra][pydantic.config.ConfigDict.extra] is set to 'allow'.
  • __pydantic_fields_set__: The names of fields explicitly set during instantiation.
  • __pydantic_private__: Values of private attributes set on the model instance.
id: str = PydanticUndefined
type KimiFilePurpose = Literal['video', 'image']
class KimiStreamedMessage:
392class KimiStreamedMessage:
393    """The streamed message of the Kimi chat provider."""
394
395    def __init__(
396        self,
397        response: ChatCompletion | AsyncStream[ChatCompletionChunk],
398        *,
399        trace_id: str | None = None,
400    ):
401        if isinstance(response, ChatCompletion):
402            self._iter = self._convert_non_stream_response(response)
403        else:
404            self._iter = self._convert_stream_response(response)
405        self._id: str | None = None
406        self._usage: CompletionUsage | None = None
407        self._trace_id = trace_id
408
409    def __aiter__(self) -> AsyncIterator[StreamedMessagePart]:
410        return self
411
412    async def __anext__(self) -> StreamedMessagePart:
413        return await self._iter.__anext__()
414
415    @property
416    def id(self) -> str | None:
417        return self._id
418
419    @property
420    def trace_id(self) -> str | None:
421        return self._trace_id
422
423    @property
424    def usage(self) -> TokenUsage | None:
425        if self._usage:
426            cached = 0
427            other_input = self._usage.prompt_tokens
428            if hasattr(self._usage, "cached_tokens"):
429                # https://platform.moonshot.cn/docs/api/chat#%E8%BF%94%E5%9B%9E%E5%86%85%E5%AE%B9
430                # TODO: delete this when Moonshot API becomes compatible with OpenAI API
431                cached = getattr(self._usage, "cached_tokens") or 0  # noqa: B009
432                other_input -= cached
433            elif (
434                self._usage.prompt_tokens_details
435                and self._usage.prompt_tokens_details.cached_tokens
436            ):
437                cached = self._usage.prompt_tokens_details.cached_tokens
438                other_input -= cached
439            return TokenUsage(
440                input_other=other_input,
441                output=self._usage.completion_tokens,
442                input_cache_read=cached,
443            )
444        return None
445
446    async def _convert_non_stream_response(
447        self,
448        response: ChatCompletion,
449    ) -> AsyncIterator[StreamedMessagePart]:
450        self._id = response.id
451        self._usage = response.usage
452        message = response.choices[0].message
453        reasoning_content = getattr(message, "reasoning_content", None)
454        if reasoning_content is not None:
455            assert isinstance(reasoning_content, str)
456            yield ThinkPart(think=reasoning_content)
457        if message.content:
458            yield TextPart(text=message.content)
459        if message.tool_calls:
460            for tool_call in message.tool_calls:
461                if isinstance(tool_call, ChatCompletionMessageFunctionToolCall):
462                    yield ToolCall(
463                        id=tool_call.id or str(uuid.uuid4()),
464                        function=ToolCall.FunctionBody(
465                            name=tool_call.function.name,
466                            arguments=tool_call.function.arguments,
467                        ),
468                    )
469
470    async def _convert_stream_response(
471        self,
472        response: AsyncIterator[ChatCompletionChunk],
473    ) -> AsyncIterator[StreamedMessagePart]:
474        try:
475            async for chunk in response:
476                if chunk.id:
477                    self._id = chunk.id
478                if usage := extract_usage_from_chunk(chunk):
479                    self._usage = usage
480
481                if not chunk.choices:
482                    continue
483
484                delta = chunk.choices[0].delta
485
486                # convert thinking content — an empty string means "reasoned
487                # but empty", not "no reasoning": keep it as a ThinkPart so
488                # the distinction round-trips to the server (preserved-thinking
489                # backends require reasoning_content on every assistant turn)
490                reasoning_content = getattr(delta, "reasoning_content", None)
491                if reasoning_content is not None:
492                    assert isinstance(reasoning_content, str)
493                    yield ThinkPart(think=reasoning_content)
494
495                # convert text content
496                if delta.content:
497                    yield TextPart(text=delta.content)
498
499                # convert tool calls
500                for tool_call in delta.tool_calls or []:
501                    if not tool_call.function:
502                        continue
503
504                    if tool_call.function.name:
505                        yield ToolCall(
506                            id=tool_call.id or str(uuid.uuid4()),
507                            function=ToolCall.FunctionBody(
508                                name=tool_call.function.name,
509                                arguments=tool_call.function.arguments,
510                            ),
511                        )
512                    elif tool_call.function.arguments:
513                        yield ToolCallPart(
514                            arguments_part=tool_call.function.arguments,
515                        )
516                    else:
517                        # skip empty tool calls
518                        pass
519        except (OpenAIError, httpx.HTTPError) as e:
520            raise convert_error(e) from e

The streamed message of the Kimi chat provider.

KimiStreamedMessage( response: openai.types.chat.chat_completion.ChatCompletion | openai.AsyncStream[openai.types.chat.chat_completion_chunk.ChatCompletionChunk], *, trace_id: str | None = None)
395    def __init__(
396        self,
397        response: ChatCompletion | AsyncStream[ChatCompletionChunk],
398        *,
399        trace_id: str | None = None,
400    ):
401        if isinstance(response, ChatCompletion):
402            self._iter = self._convert_non_stream_response(response)
403        else:
404            self._iter = self._convert_stream_response(response)
405        self._id: str | None = None
406        self._usage: CompletionUsage | None = None
407        self._trace_id = trace_id
id: str | None
415    @property
416    def id(self) -> str | None:
417        return self._id
trace_id: str | None
419    @property
420    def trace_id(self) -> str | None:
421        return self._trace_id
usage: kosong.chat_provider.TokenUsage | None
423    @property
424    def usage(self) -> TokenUsage | None:
425        if self._usage:
426            cached = 0
427            other_input = self._usage.prompt_tokens
428            if hasattr(self._usage, "cached_tokens"):
429                # https://platform.moonshot.cn/docs/api/chat#%E8%BF%94%E5%9B%9E%E5%86%85%E5%AE%B9
430                # TODO: delete this when Moonshot API becomes compatible with OpenAI API
431                cached = getattr(self._usage, "cached_tokens") or 0  # noqa: B009
432                other_input -= cached
433            elif (
434                self._usage.prompt_tokens_details
435                and self._usage.prompt_tokens_details.cached_tokens
436            ):
437                cached = self._usage.prompt_tokens_details.cached_tokens
438                other_input -= cached
439            return TokenUsage(
440                input_other=other_input,
441                output=self._usage.completion_tokens,
442                input_cache_read=cached,
443            )
444        return None
def extract_usage_from_chunk( chunk: openai.types.chat.chat_completion_chunk.ChatCompletionChunk) -> openai.types.completion_usage.CompletionUsage | None:
523def extract_usage_from_chunk(chunk: ChatCompletionChunk) -> CompletionUsage | None:
524    if chunk.usage:
525        return chunk.usage
526    if not chunk.choices:
527        return None
528    choice_dump: dict[str, object] = chunk.choices[0].model_dump()
529    raw_usage = choice_dump.get("usage")
530    if isinstance(raw_usage, CompletionUsage):
531        return raw_usage
532    if isinstance(raw_usage, dict):
533        return CompletionUsage.model_validate(raw_usage)
534    return None