Skip to content

Commit a202f60

Browse files
committed
Add input.transcribe op and server.transcribe method
1 parent 1913e50 commit a202f60

3 files changed

Lines changed: 83 additions & 0 deletions

File tree

chatkit/server.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
import base64
23
from abc import ABC, abstractmethod
34
from collections.abc import AsyncIterator
45
from contextlib import contextmanager
@@ -41,6 +42,7 @@
4142
ErrorEvent,
4243
FeedbackKind,
4344
HiddenContextItem,
45+
InputTranscribeReq,
4446
ItemsFeedbackReq,
4547
ItemsListReq,
4648
NonStreamingReq,
@@ -69,6 +71,7 @@
6971
ThreadStreamEvent,
7072
ThreadsUpdateReq,
7173
ThreadUpdatedEvent,
74+
TranscriptionResult,
7275
UserMessageInput,
7376
UserMessageItem,
7477
WidgetComponentUpdated,
@@ -319,6 +322,14 @@ async def add_feedback( # noqa: B027
319322
"""Persist user feedback for one or more thread items."""
320323
pass
321324

325+
async def transcribe( # noqa: B027
326+
self, audio_bytes: bytes, mime_type: str, context: TContext
327+
) -> TranscriptionResult:
328+
"""Transcribe speech audio to text. Override this method to support dictation."""
329+
raise NotImplementedError(
330+
"transcribe() must be overridden to support the input.transcribe request."
331+
)
332+
322333
def action(
323334
self,
324335
thread: ThreadMetadata,
@@ -446,6 +457,12 @@ async def _process_non_streaming(
446457
request.params.attachment_id, context=context
447458
)
448459
return b"{}"
460+
case InputTranscribeReq():
461+
audio_bytes = base64.b64decode(request.params.audio_base64)
462+
transcription_result = await self.transcribe(
463+
audio_bytes, request.params.mime_type, context=context
464+
)
465+
return self._serialize(transcription_result)
449466
case ItemsListReq():
450467
items_list_params = request.params
451468
items = await self.store.load_thread_items(

chatkit/types.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,29 @@ class AttachmentCreateParams(BaseModel):
174174
mime_type: str
175175

176176

177+
class InputTranscribeReq(BaseReq):
178+
"""Request to transcribe an audio payload into text."""
179+
180+
type: Literal["input.transcribe"] = "input.transcribe"
181+
params: InputTranscribeParams
182+
183+
184+
class InputTranscribeParams(BaseModel):
185+
"""Parameters for speech transcription."""
186+
187+
audio_base64: str
188+
"""Base64-encoded audio bytes."""
189+
190+
mime_type: str
191+
"""MIME type for the audio payload (e.g. 'audio/webm', 'audio/wav')."""
192+
193+
194+
class TranscriptionResult(BaseModel):
195+
"""Input speech transcription result."""
196+
197+
text: str
198+
199+
177200
class ItemsListReq(BaseReq):
178201
"""Request to list items inside a thread."""
179202

@@ -236,6 +259,7 @@ class ThreadDeleteParams(BaseModel):
236259
| AttachmentsDeleteReq
237260
| ThreadsUpdateReq
238261
| ThreadsDeleteReq
262+
| InputTranscribeReq
239263
)
240264
"""Union of request types that yield immediate responses."""
241265

tests/test_chatkit_server.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
import base64
23
import sqlite3
34
from contextlib import contextmanager
45
from datetime import datetime
@@ -38,6 +39,8 @@
3839
FileAttachment,
3940
ImageAttachment,
4041
InferenceOptions,
42+
InputTranscribeParams,
43+
InputTranscribeReq,
4144
ItemFeedbackParams,
4245
ItemsFeedbackReq,
4346
ItemsListParams,
@@ -75,6 +78,7 @@
7578
ThreadUpdatedEvent,
7679
ThreadUpdateParams,
7780
ToolChoice,
81+
TranscriptionResult,
7882
UserMessageInput,
7983
UserMessageItem,
8084
UserMessageTextContent,
@@ -159,6 +163,7 @@ def make_server(
159163
]
160164
| None = None,
161165
file_store: AttachmentStore | None = None,
166+
transcribe_callback: Callable[[bytes, str, Any], TranscriptionResult] | None = None,
162167
):
163168
global server_id
164169
db_path = f"file:{server_id}?mode=memory&cache=shared"
@@ -206,6 +211,13 @@ async def add_feedback(
206211
return
207212
handle_feedback(thread_id, item_ids, feedback, context)
208213

214+
async def transcribe(
215+
self, audio_bytes: bytes, mime_type: str, context: Any
216+
) -> TranscriptionResult:
217+
if transcribe_callback is None:
218+
return await super().transcribe(audio_bytes, mime_type, context)
219+
return transcribe_callback(audio_bytes, mime_type, context)
220+
209221
async def process_streaming(
210222
self, request_obj, context: Any | None = None
211223
) -> list[ThreadStreamEvent]:
@@ -1887,6 +1899,36 @@ async def responder(
18871899
assert any(e.type == "thread.item.done" for e in events)
18881900

18891901

1902+
async def test_input_transcribe_decodes_base64_and_passes_mime_type():
1903+
audio_bytes = b"hello audio"
1904+
audio_b64 = base64.b64encode(audio_bytes).decode("ascii")
1905+
seen: dict[str, Any] = {}
1906+
1907+
def transcribe_callback(
1908+
audio: bytes, mime: str, context: Any
1909+
) -> TranscriptionResult:
1910+
seen["audio"] = audio
1911+
seen["mime"] = mime
1912+
seen["context"] = context
1913+
return TranscriptionResult(text="ok")
1914+
1915+
with make_server(transcribe_callback=transcribe_callback) as server:
1916+
result = await server.process_non_streaming(
1917+
InputTranscribeReq(
1918+
params=InputTranscribeParams(
1919+
audio_base64=audio_b64,
1920+
mime_type="audio/wav",
1921+
)
1922+
)
1923+
)
1924+
parsed = TypeAdapter(TranscriptionResult).validate_json(result.json)
1925+
assert parsed.text == "ok"
1926+
1927+
assert seen["audio"] == audio_bytes
1928+
assert seen["mime"] == "audio/wav"
1929+
assert seen["context"] == DEFAULT_CONTEXT
1930+
1931+
18901932
async def test_retry_after_item_passes_tools_to_responder():
18911933
pass
18921934

0 commit comments

Comments
 (0)