Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [UNRELEASED]

### New features

* Added video input for `ChatGoogle()`/`ChatVertex()` (Gemini is the only provider that accepts video): `content_video_file()` for small inline clips (mp4, mpeg, mov, avi, x-flv, mpg, webm, wmv, 3gpp), and `content_video_youtube()` to reference a public YouTube URL directly, with no upload and no MIME type. Passing either to a provider other than Gemini raises a clear `NotImplementedError`. For larger local video files, use `chat.files.upload()` instead.

### Improvements

* Reasoning is now visible when echoing. Previously, thinking content was wrapped in literal `<thinking>` tags that a markdown renderer treated as an HTML block and dropped, so reasoning never appeared at all — even with `echo="all"`. It now renders in a "Thinking" panel in the console, and in a `<details>` block in notebooks that stays expanded while reasoning streams in and collapses once it's done. `echo="text"` continues to show only the assistant's answer. (#361)
Expand Down
3 changes: 3 additions & 0 deletions chatlas/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from ._content_document import content_document_file, content_document_url
from ._content_image import content_image_file, content_image_plot, content_image_url
from ._content_pdf import content_pdf_file, content_pdf_url
from ._content_video import content_video_file, content_video_youtube
from ._files import FileManager
from ._interpolate import interpolate, interpolate_file
from ._parallel import parallel_chat, parallel_chat_structured, parallel_chat_text
Expand Down Expand Up @@ -87,6 +88,8 @@
"content_image_url",
"content_pdf_file",
"content_pdf_url",
"content_video_file",
"content_video_youtube",
"ContentToolRequest",
"ContentToolResult",
"FileManager",
Expand Down
95 changes: 95 additions & 0 deletions chatlas/_content.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,22 @@ def is_image_content_type(content_type: str) -> TypeIs[ImageContentTypes]:
return content_type in IMAGE_CONTENT_TYPES


VideoContentTypes = Literal[
"video/mp4",
"video/mpeg",
"video/mov",
"video/avi",
"video/x-flv",
"video/mpg",
"video/webm",
"video/wmv",
"video/3gpp",
]
"""
Allowable content types for inline video. Only Gemini accepts video input.
"""


class ToolInfo(BaseModel):
"""
Serializable tool information
Expand Down Expand Up @@ -207,6 +223,8 @@ def from_tool(cls, tool: "Tool | ToolBuiltIn") -> "ToolInfo":
"text",
"image_remote",
"image_inline",
"video_inline",
"video_url",
"tool_request",
"tool_result",
"tool_result_image",
Expand Down Expand Up @@ -358,6 +376,77 @@ def __str__(self):
return f"![](data:{self.image_content_type};base64,{self.data})"


class ContentVideo(Content):
"""
Base class for video content.

This class is not meant to be used directly. Instead, use
[](`~chatlas.content_video_file`) or [](`~chatlas.content_video_youtube`).
"""

pass


class ContentVideoInline(ContentVideo):
"""
Inline video content, for small clips.

This is the return type for [](`~chatlas.content_video_file`).
It's not meant to be used directly.

Only Gemini accepts video input, and only for requests that stay under
roughly 100 MB once base64-encoded; use `chat.files.upload()` for larger
files instead.

Parameters
----------
video_content_type
The content type of the video.
data
The base64-encoded video data.
filename
The name of the video file, if known.
"""

video_content_type: VideoContentTypes
data: str
filename: Optional[str] = None

content_type: ContentTypeEnum = "video_inline"

def __str__(self):
name = f" file={self.filename}" if self.filename else ""
return f"<video{name} mime_type={self.video_content_type}>"


class ContentVideoUrl(ContentVideo):
"""
A video referenced by URL, with no upload involved.

This is the return type for [](`~chatlas.content_video_youtube`).
It's not meant to be used directly.

Unlike [](`~chatlas.types.ContentUploaded`), this isn't a file a provider
is hosting on your behalf -- there's nothing to list, download, or
delete, and it doesn't expire. It's sent with no MIME type, since Gemini
determines the video format itself. As of this writing, Gemini only
accepts public YouTube URLs this way (up to 10 per request on Gemini
2.5+), and only Gemini accepts video URLs at all.

Parameters
----------
url
The URL of the video.
"""

url: str

content_type: ContentTypeEnum = "video_url"

def __str__(self):
return f"<video url={self.url}>"


class ContentToolRequest(Content):
"""
A request to call a tool/function
Expand Down Expand Up @@ -1129,6 +1218,8 @@ def __str__(self) -> str:
ContentText,
ContentImageRemote,
ContentImageInline,
ContentVideoInline,
ContentVideoUrl,
ContentToolRequest,
ContentToolResult,
ContentJson,
Expand Down Expand Up @@ -1226,6 +1317,10 @@ def create_content(data: dict[str, Any]) -> ContentUnion:
return ContentImageRemote.model_validate(data)
elif ct == "image_inline":
return ContentImageInline.model_validate(data)
elif ct == "video_inline":
return ContentVideoInline.model_validate(data)
elif ct == "video_url":
return ContentVideoUrl.model_validate(data)
elif ct == "tool_request":
return ContentToolRequest.model_validate(data)
elif ct == "tool_result":
Expand Down
129 changes: 129 additions & 0 deletions chatlas/_content_video.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
from __future__ import annotations

import base64
from pathlib import Path
from typing import Optional
from urllib.parse import urlparse

from ._content import ContentVideoInline, ContentVideoUrl, VideoContentTypes

__all__ = (
"content_video_file",
"content_video_youtube",
)


def content_video_file(
path: str | Path,
mime_type: Optional[VideoContentTypes] = None,
) -> ContentVideoInline:
"""
Prepare a local video clip for input to a chat.

Only Gemini accepts video input, and only inline data that keeps the
total request under roughly 100 MB. For larger files, upload the video
once with `chat.files.upload()` and reuse the returned reference across
turns instead of re-sending its bytes.

Parameters
----------
path
A path to a local video file.
mime_type
The video's MIME type. If not provided, it's guessed from `path`'s
extension.

Returns
-------
[](`~chatlas.types.Content`)
Content suitable for a [](`~chatlas.Turn`) object.

Raises
------
FileNotFoundError
If the specified file does not exist.
ValueError
If `mime_type` isn't provided and can't be guessed from the file
extension.
"""

if isinstance(path, str):
path = Path(path)

if not path.is_file():
raise FileNotFoundError(f"Video file not found: {path}")

if mime_type is None:
guessed = _VIDEO_EXTENSION_MIME_TYPES.get(path.suffix.lower())
if guessed is None:
raise ValueError(
f"Couldn't guess a video MIME type from extension {path.suffix!r}. "
f"Pass `mime_type` explicitly (one of {sorted(_VIDEO_EXTENSION_MIME_TYPES.values())})."
)
mime_type = guessed

with open(path, "rb") as f:
data = base64.b64encode(f.read()).decode("utf-8")

return ContentVideoInline(
video_content_type=mime_type,
data=data,
filename=path.name,
)


def content_video_youtube(url: str) -> ContentVideoUrl:
"""
Reference a public YouTube video for input to a chat.

Only Gemini accepts this: the URL is passed straight through with no
upload and no MIME type (Gemini fetches and determines the video format
itself). As of this writing, this is a free preview feature limited to
public (not private or unlisted) videos, up to 10 per request on Gemini
2.5+ models.

Parameters
----------
url
A `youtube.com` or `youtu.be` video URL.

Returns
-------
[](`~chatlas.types.Content`)
Content suitable for a [](`~chatlas.Turn`) object.

Raises
------
ValueError
If `url` doesn't look like a YouTube video URL.
"""

if not _is_youtube_url(url):
raise ValueError(
f"{url!r} doesn't look like a YouTube video URL "
"(expected a youtube.com or youtu.be URL)."
)

return ContentVideoUrl(url=url)


_VIDEO_EXTENSION_MIME_TYPES: dict[str, VideoContentTypes] = {
".mp4": "video/mp4",
".mpeg": "video/mpeg",
".mpg": "video/mpg",
".mov": "video/mov",
".avi": "video/avi",
".flv": "video/x-flv",
".webm": "video/webm",
".wmv": "video/wmv",
".3gpp": "video/3gpp",
".3gp": "video/3gpp",
}


def _is_youtube_url(url: str) -> bool:
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
return False
host = parsed.hostname or ""
return host in ("youtu.be", "youtube.com", "www.youtube.com", "m.youtube.com")
6 changes: 6 additions & 0 deletions chatlas/_provider_anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
ContentToolResponseSearch,
ContentToolResult,
ContentUploaded,
ContentVideo,
ProviderAnnotation,
WebSource,
check_image_content_type_supported,
Expand Down Expand Up @@ -906,6 +907,11 @@ def _as_content_block(content: Content) -> "ContentBlockParam":
"url": content.url,
},
}
elif isinstance(content, ContentVideo):
raise NotImplementedError(
"Video input isn't supported by Anthropic. Only ChatGoogle() "
"(Gemini) supports video."
)
elif isinstance(content, ContentToolRequest):
return {
"type": "tool_use",
Expand Down
20 changes: 20 additions & 0 deletions chatlas/_provider_google.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
ContentToolResponseSearch,
ContentToolResult,
ContentUploaded,
ContentVideoInline,
ContentVideoUrl,
WebSource,
)
from ._content_file import ensure_bytes
Expand Down Expand Up @@ -645,6 +647,24 @@ def _as_part_type(self, content: Content) -> "Part":
"Remote images aren't supported by Google (Gemini). "
"Consider downloading the image and using content_image_file() instead."
)
elif isinstance(content, ContentVideoInline):
from google.genai.types import Blob

return Part(
inline_data=Blob(
data=base64.b64decode(content.data),
mime_type=content.video_content_type,
)
)
elif isinstance(content, ContentVideoUrl):
from google.genai.types import FileData

# Not Part.from_uri(): it falls back to mimetypes.guess_type() when
# mime_type is omitted, which raises for a YouTube watch URL (no
# file extension to guess from). Gemini wants no mime_type at all
# here -- it determines the video format itself -- so build the
# Part directly instead.
return Part(file_data=FileData(file_uri=content.url))
elif isinstance(content, ContentToolRequest):
return Part(
function_call=FunctionCall(
Expand Down
6 changes: 6 additions & 0 deletions chatlas/_provider_openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
ContentToolRequestSearch,
ContentToolResult,
ContentUploaded,
ContentVideo,
ProviderAnnotation,
WebSource,
check_image_content_type_supported,
Expand Down Expand Up @@ -750,6 +751,11 @@ def as_input_param(content: Content, role: Role) -> "ResponseInputItemParam":
return as_message(as_input_file_param(content, "application/pdf"), role)
elif isinstance(content, ContentDocument):
return as_message(as_input_file_param(content, content.mime_type), role)
elif isinstance(content, ContentVideo):
raise NotImplementedError(
"Video input isn't supported by OpenAI. Only ChatGoogle() (Gemini) "
"supports video."
)
elif isinstance(content, ContentThinking):
# Filter out 'status' which is output-only and not accepted as input
extra = content.extra or {}
Expand Down
6 changes: 6 additions & 0 deletions chatlas/_provider_openai_completions.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
ContentToolRequest,
ContentToolResult,
ContentUploaded,
ContentVideo,
check_image_content_type_supported,
)
from ._content_file import ensure_bytes
Expand Down Expand Up @@ -433,6 +434,11 @@ def _turns_as_inputs(self, turns: list[Turn]) -> list["ChatCompletionMessagePara
"API), or pass the image inline via content_image_file()."
)
contents.append({"type": "file", "file": {"file_id": x.id}})
elif isinstance(x, ContentVideo):
raise NotImplementedError(
f"Video input isn't supported by {self.name}. Only "
"ChatGoogle() (Gemini) supports video."
)
elif isinstance(x, ContentToolResult):
tool_results.append(
ChatCompletionToolMessageParam(
Expand Down
Loading