openai/chatkit-python

Public

mirrored from https://github.com/openai/chatkit-pythonAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
v1.1.1

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

chatkit/server.py

728lines · modeblame

f688d870victor-openai9 months ago1import asyncio
2from abc import ABC, abstractmethod
3from collections.abc import AsyncIterator
4from contextlib import contextmanager
5from datetime import datetime
6from typing import (
7Any,
8AsyncGenerator,
9AsyncIterable,
10Callable,
11Generic,
12)
13
14import agents
15from agents.models.chatcmpl_helpers import (
16HEADERS_OVERRIDE as chat_completions_headers_override,
17)
18from agents.models.openai_responses import (
19_HEADERS_OVERRIDE as responses_headers_override,
20)
21from pydantic import BaseModel, TypeAdapter
3de07454Dan Wang8 months ago22from typing_extensions import TypeVar, assert_never
f688d870victor-openai9 months ago23
24from chatkit.errors import CustomStreamError, StreamError
25
26from .logger import logger
27from .store import AttachmentStore, Store, StoreItemType, default_generate_id
28from .types import (
29Action,
30AttachmentsCreateReq,
31AttachmentsDeleteReq,
32ChatKitReq,
33ClientToolCallItem,
34ErrorCode,
35ErrorEvent,
36FeedbackKind,
37HiddenContextItem,
38ItemsFeedbackReq,
39ItemsListReq,
40NonStreamingReq,
41Page,
42StreamingReq,
43Thread,
44ThreadCreatedEvent,
45ThreadItem,
46ThreadItemAddedEvent,
47ThreadItemDoneEvent,
48ThreadItemRemovedEvent,
49ThreadItemReplacedEvent,
50ThreadItemUpdated,
51ThreadMetadata,
52ThreadsAddClientToolOutputReq,
53ThreadsAddUserMessageReq,
54ThreadsCreateReq,
55ThreadsCustomActionReq,
56ThreadsDeleteReq,
57ThreadsGetByIdReq,
58ThreadsListReq,
59ThreadsRetryAfterItemReq,
60ThreadStreamEvent,
61ThreadsUpdateReq,
62ThreadUpdatedEvent,
63UserMessageInput,
64UserMessageItem,
65WidgetComponentUpdated,
66WidgetItem,
67WidgetRootUpdated,
68WidgetStreamingTextValueDelta,
69is_streaming_req,
70)
71from .version import __version__
72from .widgets import Markdown, Text, WidgetComponent, WidgetComponentBase, WidgetRoot
73
74DEFAULT_PAGE_SIZE = 20
75DEFAULT_ERROR_MESSAGE = "An error occurred when generating a response."
76
77
78def diff_widget(
79before: WidgetRoot, after: WidgetRoot
80) -> list[WidgetStreamingTextValueDelta | WidgetRootUpdated | WidgetComponentUpdated]:
81"""
82Compare two WidgetRoots and return a list of deltas.
83"""
84
85def full_replace(before: WidgetComponentBase, after: WidgetComponentBase) -> bool:
86if (
87before.type != after.type
88or before.id != after.id
89or before.key != after.key
90):
91return True
92
93def full_replace_value(before_value: Any, after_value: Any) -> bool:
94if isinstance(before_value, list) and isinstance(after_value, list):
95if len(before_value) != len(after_value):
96return True
97for nth_before_value, nth_after_value in zip(before_value, after_value):
98if full_replace_value(nth_before_value, nth_after_value):
99return True
100elif before_value != after_value:
101if isinstance(before_value, WidgetComponentBase) and isinstance(
102after_value, WidgetComponentBase
103):
104return full_replace(before_value, after_value)
105else:
106return True
107return False
108
109for field in before.model_fields_set.union(after.model_fields_set):
110if (
111isinstance(before, (Markdown, Text))
112and isinstance(after, (Markdown, Text))
113and field == "value"
114and after.value.startswith(before.value)
115):
116# Appends to the value prop of Markdown or Text do not trigger a full replace
117continue
118if full_replace_value(getattr(before, field), getattr(after, field)):
119return True
120
121return False
122
123if full_replace(before, after):
124return [WidgetRootUpdated(widget=after)]
125
126deltas: list[
127WidgetStreamingTextValueDelta | WidgetComponentUpdated | WidgetRootUpdated
128] = []
129
130def find_all_streaming_text_components(
131component: WidgetComponent | WidgetRoot,
132) -> dict[str, Markdown | Text]:
133components = {}
134
135def recurse(component: WidgetComponent | WidgetRoot):
136if isinstance(component, (Markdown, Text)) and component.id:
137components[component.id] = component
138
139if hasattr(component, "children"):
140children = getattr(component, "children", None) or []
141for child in children:
142recurse(child)
143
144recurse(component)
145return components
146
147before_nodes = find_all_streaming_text_components(before)
148after_nodes = find_all_streaming_text_components(after)
149
150for id, after_node in after_nodes.items():
151before_node = before_nodes.get(id)
152if before_node is None:
153raise ValueError(
154f"Node {id} was not present when the widget was initially rendered. All nodes with ID must persist across all widget updates."
155)
156
157if before_node.value != after_node.value:
158if not after_node.value.startswith(before_node.value):
159raise ValueError(
160f"Node {id} was updated with a new value that is not a prefix of the initial value. All widget updates must be cumulative."
161)
162done = not after_node.streaming
163deltas.append(
164WidgetStreamingTextValueDelta(
165component_id=id,
166delta=after_node.value[len(before_node.value) :],
167done=done,
168)
169)
170
171return deltas
172
173
174async def stream_widget(
175thread: ThreadMetadata,
176widget: WidgetRoot | AsyncGenerator[WidgetRoot, None],
177copy_text: str | None = None,
178generate_id: Callable[[StoreItemType], str] = default_generate_id,
179) -> AsyncIterator[ThreadStreamEvent]:
180item_id = generate_id("message")
181
182if not isinstance(widget, AsyncGenerator):
183yield ThreadItemDoneEvent(
184item=WidgetItem(
185id=item_id,
186thread_id=thread.id,
187created_at=datetime.now(),
188widget=widget,
189copy_text=copy_text,
190),
191)
192return
193
194initial_state = await widget.__anext__()
195
196item = WidgetItem(
197id=item_id,
198created_at=datetime.now(),
199widget=initial_state,
200copy_text=copy_text,
201thread_id=thread.id,
202)
203
204yield ThreadItemAddedEvent(item=item)
205
206last_state = initial_state
207
208while widget:
209try:
210new_state = await widget.__anext__()
211for update in diff_widget(last_state, new_state):
212yield ThreadItemUpdated(
213item_id=item_id,
214update=update,
215)
216last_state = new_state
217except StopAsyncIteration:
218break
219
220yield ThreadItemDoneEvent(
221item=item.model_copy(update={"widget": last_state}),
222)
223
224
225@contextmanager
226def agents_sdk_user_agent_override():
227ua = f"Agents/Python {agents.__version__} ChatKit/Python {__version__}"
228chat_completions_token = chat_completions_headers_override.set({"User-Agent": ua})
229responses_token = responses_headers_override.set({"User-Agent": ua})
230
231yield
232
233chat_completions_headers_override.reset(chat_completions_token)
234responses_headers_override.reset(responses_token)
235
236
237class StreamingResult(AsyncIterable[bytes]):
238def __init__(self, stream: AsyncGenerator[bytes, None]):
239self.json_events = stream
240
241async def __aiter__(self):
242async for event in self.json_events:
243yield event
244
245
246class NonStreamingResult:
247def __init__(self, result: bytes):
248self.json = result
249
250
251TContext = TypeVar("TContext", default=Any)
252
253
254class ChatKitServer(ABC, Generic[TContext]):
255def __init__(
256self,
257store: Store[TContext],
258attachment_store: AttachmentStore[TContext] | None = None,
259):
260self.store = store
261self.attachment_store = attachment_store
262
263def _get_attachment_store(self) -> AttachmentStore[TContext]:
264"""Return the configured AttachmentStore or raise if missing."""
265if self.attachment_store is None:
266raise RuntimeError(
267"AttachmentStore is not configured. Provide a AttachmentStore to ChatKitServer to handle file operations."
268)
269return self.attachment_store
270
271@abstractmethod
272def respond(
273self,
274thread: ThreadMetadata,
275input_user_message: UserMessageItem | None,
276context: TContext,
277) -> AsyncIterator[ThreadStreamEvent]:
6988ea0avictor-openai9 months ago278"""Stream `ThreadStreamEvent` instances for a new user message.
279
280Args:
281thread: Metadata for the thread being processed.
282input_user_message: The incoming message the server should respond to, if any.
283context: Arbitrary per-request context provided by the caller.
284
285Returns:
286An async iterator that yields events representing the server's response.
287"""
f688d870victor-openai9 months ago288pass
289
290async def add_feedback( # noqa: B027
291self,
292thread_id: str,
293item_ids: list[str],
294feedback: FeedbackKind,
295context: TContext,
296) -> None:
297pass
298
299def action(
300self,
301thread: ThreadMetadata,
302action: Action[str, Any],
303sender: WidgetItem | None,
304context: TContext,
305) -> AsyncIterator[ThreadStreamEvent]:
306raise NotImplementedError(
307"The action() method must be overridden to react to actions. "
93a34923Vincent Lin9 months ago308"See https://github.com/openai/chatkit-python/blob/main/docs/widgets.md#widget-actions"
f688d870victor-openai9 months ago309)
310
311async def process(
312self, request: str | bytes | bytearray, context: TContext
313) -> StreamingResult | NonStreamingResult:
314parsed_request = TypeAdapter[ChatKitReq](ChatKitReq).validate_json(request)
315logger.info(f"Received request op: {parsed_request.type}")
316
317if is_streaming_req(parsed_request):
318return StreamingResult(self._process_streaming(parsed_request, context))
319else:
320return NonStreamingResult(
321await self._process_non_streaming(parsed_request, context)
322)
323
324async def _process_non_streaming(
325self, request: NonStreamingReq, context: TContext
326) -> bytes:
327match request:
328case ThreadsGetByIdReq():
329thread = await self._load_full_thread(
330request.params.thread_id, context=context
331)
332return self._serialize(self._to_thread_response(thread))
333case ThreadsListReq():
334params = request.params
335threads = await self.store.load_threads(
336limit=params.limit or DEFAULT_PAGE_SIZE,
337after=params.after,
338order=params.order,
339context=context,
340)
341return self._serialize(
342Page(
343has_more=threads.has_more,
344after=threads.after,
345data=[
346self._to_thread_response(thread) for thread in threads.data
347],
348)
349)
350case ItemsFeedbackReq():
351await self.add_feedback(
352request.params.thread_id,
353request.params.item_ids,
354request.params.kind,
355context,
356)
357return b"{}"
358case AttachmentsCreateReq():
359attachment_store = self._get_attachment_store()
360attachment = await attachment_store.create_attachment(
361request.params, context
362)
363return self._serialize(attachment)
364case AttachmentsDeleteReq():
365attachment_store = self._get_attachment_store()
366await attachment_store.delete_attachment(
367request.params.attachment_id, context=context
368)
369await self.store.delete_attachment(
370request.params.attachment_id, context=context
371)
372return b"{}"
373case ItemsListReq():
374items_list_params = request.params
375items = await self.store.load_thread_items(
376items_list_params.thread_id,
377limit=items_list_params.limit or DEFAULT_PAGE_SIZE,
378order=items_list_params.order,
379after=items_list_params.after,
380context=context,
381)
382# filter out HiddenContextItems
383items.data = [
384item
385for item in items.data
386if not isinstance(item, HiddenContextItem)
387]
388return self._serialize(items)
389case ThreadsUpdateReq():
390thread = await self.store.load_thread(
391request.params.thread_id, context=context
392)
393thread.title = request.params.title
394await self.store.save_thread(thread, context=context)
395return self._serialize(self._to_thread_response(thread))
396case ThreadsDeleteReq():
397await self.store.delete_thread(
398request.params.thread_id, context=context
399)
400return b"{}"
401case _:
402assert_never(request)
403
404async def _process_streaming(
405self, request: StreamingReq, context: TContext
406) -> AsyncGenerator[bytes, None]:
407try:
408async for event in self._process_streaming_impl(request, context):
409b = self._serialize(event)
410yield b"data: " + b + b"\n\n"
411except Exception:
412logger.exception("Error while generating streamed response")
413raise
414
415async def _process_streaming_impl(
416self, request: StreamingReq, context: TContext
417) -> AsyncGenerator[ThreadStreamEvent, None]:
418match request:
419case ThreadsCreateReq():
420thread = Thread(
421id=self.store.generate_thread_id(context),
422created_at=datetime.now(),
423items=Page(),
424)
8885a10bquettabit9 months ago425await self.store.save_thread(
426ThreadMetadata(**thread.model_dump()),
427context=context,
428)
f688d870victor-openai9 months ago429yield ThreadCreatedEvent(thread=self._to_thread_response(thread))
430user_message = await self._build_user_message_item(
431request.params.input, thread, context
432)
433async for event in self._process_new_thread_item_respond(
434thread,
435user_message,
436context,
437):
438yield event
439
440case ThreadsAddUserMessageReq():
441thread = await self.store.load_thread(
442request.params.thread_id, context=context
443)
444user_message = await self._build_user_message_item(
445request.params.input, thread, context
446)
447async for event in self._process_new_thread_item_respond(
448thread,
449user_message,
450context,
451):
452yield event
453
454case ThreadsAddClientToolOutputReq():
455thread = await self.store.load_thread(
456request.params.thread_id, context=context
457)
458items = await self.store.load_thread_items(
459thread.id, None, 1, "desc", context
460)
461tool_call = next(
462(
463item
464for item in items.data
465if isinstance(item, ClientToolCallItem)
466and item.status == "pending"
467),
468None,
469)
470if not tool_call:
471raise ValueError(
472f"Last thread item in {thread.id} was not a ClientToolCallItem"
473)
474
475tool_call.output = request.params.result
476tool_call.status = "completed"
477
478await self.store.save_item(thread.id, tool_call, context=context)
479
480# Safety against dangling pending tool calls if there are
481# multiple in a row, which should be impossible, and
482# integrations should ultimately filter out pending tool calls
483# when creating input response messages.
484await self._cleanup_pending_client_tool_call(thread, context)
485
486async for event in self._process_events(
487thread,
488context,
489lambda: self.respond(thread, None, context),
490):
491yield event
492
493case ThreadsRetryAfterItemReq():
494thread_metadata = await self.store.load_thread(
495request.params.thread_id, context=context
496)
497
498# Collect items to remove (all items after the user message)
499items_to_remove: list[ThreadItem] = []
500user_message_item = None
501
502async for item in self._paginate_thread_items_reverse(
503request.params.thread_id, context
504):
505if item.id == request.params.item_id:
506if not isinstance(item, UserMessageItem):
507raise ValueError(
508f"Item {request.params.item_id} is not a user message"
509)
510user_message_item = item
511break
512items_to_remove.append(item)
513
514if user_message_item:
515for item in items_to_remove:
516await self.store.delete_thread_item(
517request.params.thread_id, item.id, context=context
518)
519async for event in self._process_events(
520thread_metadata,
521context,
522lambda: self.respond(
523thread_metadata,
524user_message_item,
525context,
526),
527):
528yield event
529case ThreadsCustomActionReq():
530thread_metadata = await self.store.load_thread(
531request.params.thread_id, context=context
532)
533
534item: ThreadItem | None = None
535if request.params.item_id:
536item = await self.store.load_item(
537request.params.thread_id,
538request.params.item_id,
539context=context,
540)
541
542if item and not isinstance(item, WidgetItem):
543# shouldn't happen if the caller is using the API correctly.
544yield ErrorEvent(
545code=ErrorCode.STREAM_ERROR,
546allow_retry=False,
547)
548return
549
550async for event in self._process_events(
551thread_metadata,
552context,
553lambda: self.action(
554thread_metadata,
555request.params.action,
556item,
557context,
558),
559):
560yield event
561
562case _:
563assert_never(request)
564
565async def _cleanup_pending_client_tool_call(
566self, thread: ThreadMetadata, context: TContext
567) -> None:
568items = await self.store.load_thread_items(
569thread.id, None, DEFAULT_PAGE_SIZE, "desc", context
570)
571for tool_call in items.data:
572if not isinstance(tool_call, ClientToolCallItem):
573continue
574if tool_call.status == "pending":
575logger.warning(
576f"Client tool call {tool_call.call_id} was not completed, ignoring"
577)
578await self.store.delete_thread_item(
579thread.id, tool_call.id, context=context
580)
581
582async def _process_new_thread_item_respond(
583self,
584thread: ThreadMetadata,
585item: UserMessageItem,
586context: TContext,
587) -> AsyncIterator[ThreadStreamEvent]:
588await self.store.add_thread_item(thread.id, item, context=context)
589await self._cleanup_pending_client_tool_call(thread, context)
590yield ThreadItemDoneEvent(item=item)
591
592async for event in self._process_events(
593thread,
594context,
595lambda: self.respond(thread, item, context),
596):
597yield event
598
599async def _process_events(
600self,
601thread: ThreadMetadata,
602context: TContext,
603stream: Callable[[], AsyncIterator[ThreadStreamEvent]],
604) -> AsyncIterator[ThreadStreamEvent]:
605await asyncio.sleep(0) # allow the response to start streaming
606
607last_thread = thread.model_copy(deep=True)
608
609try:
610with agents_sdk_user_agent_override():
611async for event in stream():
612match event:
613case ThreadItemDoneEvent():
614await self.store.add_thread_item(
615thread.id, event.item, context=context
616)
617case ThreadItemRemovedEvent():
618await self.store.delete_thread_item(
619thread.id, event.item_id, context=context
620)
621case ThreadItemReplacedEvent():
622await self.store.save_item(
623thread.id, event.item, context=context
624)
625
626# special case - don't send hidden context items back to the client
627should_swallow_event = isinstance(
628event, ThreadItemDoneEvent
629) and isinstance(event.item, HiddenContextItem)
630
631if not should_swallow_event:
632yield event
633
634# in case user updated the thread while streaming
635if thread != last_thread:
636last_thread = thread.model_copy(deep=True)
637await self.store.save_thread(thread, context=context)
638yield ThreadUpdatedEvent(
639thread=self._to_thread_response(thread)
640)
641# in case user updated the thread while streaming
642if thread != last_thread:
643last_thread = thread.model_copy(deep=True)
644await self.store.save_thread(thread, context=context)
645yield ThreadUpdatedEvent(thread=self._to_thread_response(thread))
646except CustomStreamError as e:
647yield ErrorEvent(
648code="custom",
649message=e.message,
650allow_retry=e.allow_retry,
651)
652except StreamError as e:
653yield ErrorEvent(
654code=e.code,
655allow_retry=e.allow_retry,
656)
657except Exception as e:
658yield ErrorEvent(
659code=ErrorCode.STREAM_ERROR,
660allow_retry=True,
661)
662logger.exception(e)
663
664if thread != last_thread:
665# in case user updated the thread at the end of the stream
666await self.store.save_thread(thread, context=context)
667yield ThreadUpdatedEvent(thread=self._to_thread_response(thread))
668
669async def _build_user_message_item(
670self, input: UserMessageInput, thread: ThreadMetadata, context: TContext
671) -> UserMessageItem:
672return UserMessageItem(
673id=self.store.generate_item_id("message", thread, context),
674content=input.content,
675thread_id=thread.id,
676attachments=[
677await self.store.load_attachment(attachment_id, context)
678for attachment_id in input.attachments
679],
680quoted_text=input.quoted_text,
681inference_options=input.inference_options,
682created_at=datetime.now(),
683)
684
685async def _load_full_thread(self, thread_id: str, context: TContext) -> Thread:
686thread_meta = await self.store.load_thread(thread_id, context=context)
687thread_items = await self.store.load_thread_items(
688thread_id,
689after=None,
690limit=DEFAULT_PAGE_SIZE,
691order="asc",
692context=context,
693)
694return Thread(**thread_meta.model_dump(), items=thread_items)
695
696async def _paginate_thread_items_reverse(
697self, thread_id: str, context: TContext
698) -> AsyncIterator[ThreadItem]:
699"""Paginate through thread items in reverse order (newest first)."""
700after = None
701while True:
702items = await self.store.load_thread_items(
703thread_id, after, DEFAULT_PAGE_SIZE, "desc", context
704)
705for item in items.data:
706yield item
707
708if not items.has_more:
709break
710after = items.after
711
712def _serialize(self, obj: BaseModel) -> bytes:
713return obj.model_dump_json(by_alias=True, exclude_none=True).encode("utf-8")
714
715def _to_thread_response(self, thread: ThreadMetadata | Thread) -> Thread:
716def is_hidden(item: ThreadItem) -> bool:
717return isinstance(item, HiddenContextItem)
718
719items = thread.items if isinstance(thread, Thread) else Page()
720items.data = [item for item in items.data if not is_hidden(item)]
721
722return Thread(
723id=thread.id,
724title=thread.title,
725created_at=thread.created_at,
726items=items,
727status=thread.status,
728)