Skip to content

Commit dc858ef

Browse files
xxchanstdrc
andauthored
feat(kosong,kimi): add video upload support for Kimi provider (#656)
Signed-off-by: Richard Chien <stdrc@outlook.com> Co-authored-by: Richard Chien <stdrc@outlook.com>
1 parent 3108621 commit dc858ef

4 files changed

Lines changed: 72 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Only write entries that are worth mentioning to users.
1111

1212
## Unreleased
1313

14+
- Tool: Upload videos to Kimi files API when using Kimi provider, replacing inline data URLs with `ms://` references
1415
- Config: Add `reserved_context_size` setting to customize auto-compaction trigger threshold (default: 50000 tokens)
1516

1617
## 0.81 (2026-01-21)

packages/kosong/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
## Unreleased
44

55
- Control thinking mode via `extra_body` parameter instead of legacy `reasoning_effort`
6+
- Add `files` property to `Kimi` provider that returns a `KimiFiles` object
7+
- Add `KimiFiles.upload_video()` method for uploading videos to Kimi files API, returning `VideoURLPart`
68

79
## 0.38.0 (2026-01-15)
810

packages/kosong/src/kosong/chat_provider/kimi.py

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import copy
2+
import mimetypes
23
import os
34
import uuid
45
from collections.abc import AsyncIterator, Sequence
56
from typing import TYPE_CHECKING, Any, Literal, Self, Unpack, cast
67

78
import httpx
8-
from openai import AsyncOpenAI, AsyncStream, OpenAIError, omit
9+
from openai import AsyncOpenAI, AsyncStream, BaseModel, OpenAIError, omit
10+
from openai._types import RequestFiles, RequestOptions
911
from openai.types.chat import (
1012
ChatCompletion,
1113
ChatCompletionChunk,
@@ -24,7 +26,15 @@
2426
TokenUsage,
2527
)
2628
from kosong.chat_provider.openai_common import convert_error, tool_to_openai
27-
from kosong.message import ContentPart, Message, TextPart, ThinkPart, ToolCall, ToolCallPart
29+
from kosong.message import (
30+
ContentPart,
31+
Message,
32+
TextPart,
33+
ThinkPart,
34+
ToolCall,
35+
ToolCallPart,
36+
VideoURLPart,
37+
)
2838
from kosong.tooling import Tool
2939

3040
if TYPE_CHECKING:
@@ -216,6 +226,50 @@ def model_parameters(self) -> dict[str, Any]:
216226
model_parameters.update(self._generation_kwargs)
217227
return model_parameters
218228

229+
@property
230+
def files(self) -> "KimiFiles":
231+
return KimiFiles(self.client)
232+
233+
234+
class KimiFiles:
235+
def __init__(self, client: AsyncOpenAI) -> None:
236+
self._client = client
237+
238+
async def upload_video(self, *, data: bytes, mime_type: str) -> VideoURLPart:
239+
"""Upload a video to Kimi files API and return a video URL content part."""
240+
if not mime_type.startswith("video/"):
241+
raise ChatProviderError(f"Expected a video mime type, got {mime_type}")
242+
url = await self._upload_file(data=data, mime_type=mime_type, purpose="video")
243+
return VideoURLPart(video_url=VideoURLPart.VideoURL(url=url))
244+
245+
async def _upload_file(self, *, data: bytes, mime_type: str, purpose: "KimiFilePurpose") -> str:
246+
filename = _guess_filename(mime_type)
247+
files: RequestFiles = {"file": (filename, data, mime_type)}
248+
options: RequestOptions = {"headers": {"Content-Type": "multipart/form-data"}}
249+
try:
250+
response: KimiFileObject = await self._client.post(
251+
"/files",
252+
cast_to=KimiFileObject,
253+
body={"purpose": purpose},
254+
files=files,
255+
options=options,
256+
)
257+
except (OpenAIError, httpx.HTTPError) as e:
258+
raise convert_error(e) from e
259+
return f"ms://{response.id}"
260+
261+
262+
class KimiFileObject(BaseModel):
263+
id: str
264+
265+
266+
type KimiFilePurpose = Literal["video", "image"]
267+
268+
269+
def _guess_filename(mime_type: str) -> str:
270+
extension = mimetypes.guess_extension(mime_type) or ".bin"
271+
return f"upload{extension}"
272+
219273

220274
def _convert_message(message: Message) -> ChatCompletionMessageParam:
221275
message = message.model_copy(deep=True)

src/kimi_cli/tools/file/read.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from typing import override
44

55
from kaos.path import KaosPath
6+
from kosong.chat_provider.kimi import Kimi
67
from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnValue
78
from pydantic import BaseModel, Field
89

@@ -67,6 +68,7 @@ def __init__(self, runtime: Runtime) -> None:
6768
},
6869
)
6970
super().__init__(description=description)
71+
self._runtime = runtime
7072
self._work_dir = runtime.builtin_args.KIMI_WORK_DIR
7173

7274
async def _validate_path(self, path: KaosPath) -> ToolError | None:
@@ -105,13 +107,21 @@ async def _read_media(self, path: KaosPath, file_type: FileType) -> ToolReturnVa
105107
brief="File too large",
106108
)
107109

108-
data = await path.read_bytes()
109-
data_url = _to_data_url(file_type.mime_type, data)
110110
match file_type.kind:
111111
case "image":
112+
data = await path.read_bytes()
113+
data_url = _to_data_url(file_type.mime_type, data)
112114
part = ImageURLPart(image_url=ImageURLPart.ImageURL(url=data_url))
113115
case "video":
114-
part = VideoURLPart(video_url=VideoURLPart.VideoURL(url=data_url))
116+
data = await path.read_bytes()
117+
if (llm := self._runtime.llm) and isinstance(llm.chat_provider, Kimi):
118+
part = await llm.chat_provider.files.upload_video(
119+
data=data,
120+
mime_type=file_type.mime_type,
121+
)
122+
else:
123+
data_url = _to_data_url(file_type.mime_type, data)
124+
part = VideoURLPart(video_url=VideoURLPart.VideoURL(url=data_url))
115125
return ToolOk(
116126
output=part,
117127
message=(

0 commit comments

Comments
 (0)