CoolFace
Apppublic

Rhinox13/chatapi

sourceHugging Faceupdated 3mo agoView on Hugging Face
0likes
turn_coordinator.py566 linesDownload Raw Back to services
1from __future__ import annotations
2
3import uuid
4from dataclasses import dataclass
5from typing import Any, Callable
6
7from ..core import AppDependencies
8from ..repositories import build_title
9from .automation_rules import AutomationRuleEngine
10from .ntfy import notify_new_message
11from .output_controller import TurnOutputController
12from .payload_anthropic import build_anthropic_message_response
13from .payload_chat_completions import build_chat_completion_response
14from .payload_openai import (
15    build_openai_error,
16    build_openai_response,
17    estimate_usage,
18)
19from .pending import PendingTurn
20from .turn_request_tools import (
21    extract_tool_names,
22    extract_tool_schemas,
23    find_response_message_metadata,
24)
25from .turn_protocols import (
26    build_message_debug_metadata,
27    extract_chatbox_comparable_request_messages,
28    extract_context_text,
29    extract_request_messages,
30    normalize_chatbox_history_content,
31    normalize_message_text,
32    request_input_payload,
33    resolve_conversation_for_request,
34)
35
36
37def _normalize_reasoning_stream_mode(value: Any) -> str:
38    mode = str(value or "").strip().lower().replace("-", "_")
39    if mode == "summery":
40        mode = "summary"
41    elif mode == "reasoning":
42        mode = "reasoning_text"
43    if mode in {"summary", "reasoning_text"}:
44        return mode
45    return ""
46
47
48def _normalize_request_format(value: Any) -> str:
49    request_format = str(value or "").strip().lower().replace("-", "_")
50    if request_format in {"responses", "chat_completions", "anthropic_messages"}:
51        return request_format
52    return ""
53
54
55def _conversation_request_format(conversation: Any) -> str:
56    metadata = dict(getattr(conversation, "metadata", {}) or {})
57    return _normalize_request_format(metadata.get("request_format"))
58
59
60@dataclass(frozen=True)
61class PreparedTurn:
62    pending: PendingTurn
63    conversation_id: str
64
65
66class TurnCoordinator:
67    def __init__(
68        self,
69        deps: AppDependencies,
70        *,
71        extensions: dict[str, Any],
72        logger: Any,
73        publish_sync: Callable[[str, str | None], None] | None = None,
74    ):
75        self._deps = deps
76        self._extensions = extensions
77        self._logger = logger
78        self._publish_sync = publish_sync
79        self._output_controller = TurnOutputController(
80            store=deps.store,
81            pending_turns=deps.pending_turns,
82            publish_sync=publish_sync,
83        )
84        self._automation_rules = AutomationRuleEngine(
85            user_store=deps.user_store,
86            output_controller=self._output_controller,
87        )
88
89    @property
90    def auth(self):
91        return self._deps.auth
92
93    @property
94    def pending_turns(self):
95        return self._deps.pending_turns
96
97    @property
98    def store(self):
99        return self._deps.store
100
101    @property
102    def user_store(self):
103        return self._deps.user_store
104
105    @property
106    def settings(self):
107        return self._deps.settings
108
109    @property
110    def message_rate_limiter(self):
111        return self._deps.message_rate_limiter
112
113    def get_stream_heartbeat_settings(self, owner_id: str) -> dict[str, Any]:
114        return self._automation_rules.get_heartbeat_rule_settings(owner_id)
115
116    def update_stream_heartbeat_settings(
117        self,
118        owner_id: str,
119        *,
120        heartbeat_text: str,
121        heartbeat_interval_seconds: float,
122    ) -> dict[str, Any]:
123        return self._automation_rules.update_heartbeat_rule_settings(
124            owner_id,
125            heartbeat_text=heartbeat_text,
126            interval_seconds=heartbeat_interval_seconds,
127        )
128
129    def get_automation_rules(self, owner_id: str) -> list[dict[str, Any]]:
130        return self._automation_rules.load_rule_payloads(owner_id)
131
132    def update_automation_rules(self, owner_id: str, rules: list[dict[str, Any]]) -> list[dict[str, Any]]:
133        return self._automation_rules.save_rule_payloads(owner_id, rules)
134
135    def build_abort_error(self, message_text: str) -> tuple[dict[str, Any], int]:
136        return build_openai_error(
137            message_text or "request aborted",
138            code="request_aborted",
139            status=400,
140        )
141
142    def build_not_found_error(self, message: str, *, code: str = "not_found", status: int = 404):
143        return build_openai_error(message, code=code, status=status)
144
145    def _pending_limits(self) -> dict[str, Any]:
146        return self._deps.system_config_store.get_pending_limits()
147
148    def _mark_aborted_pending_turns(self, pending_turns: list[PendingTurn]) -> None:
149        for pending in pending_turns:
150            conversation = self.store.get_conversation(pending.conversation_id, pending.owner_id)
151            self.store.update_conversation(
152                pending.conversation_id,
153                pending.owner_id,
154                metadata={
155                    **(conversation.metadata if conversation else {}),
156                    "realtime_status": "aborted",
157                    "realtime_draft_text": "",
158                },
159            )
160            self._notify(pending.owner_id, pending.conversation_id)
161
162    def enforce_pending_limits(self, owner_id: str) -> dict[str, Any]:
163        limits = self._pending_limits()
164        abort_message = str(limits.get("abort_message") or "本次回复等待超过限制,已自动结束,请重新发送。")
165        aborted = self.pending_turns.abort_expired(
166            max_age_seconds=float(limits.get("max_age_seconds") or 0),
167            error_message=abort_message,
168        )
169        aborted.extend(
170            self.pending_turns.abort_owner_over_limit(
171                owner_id=owner_id,
172                max_active=int(limits.get("max_per_user") or 10),
173                error_message=abort_message,
174            )
175        )
176        if aborted:
177            self._mark_aborted_pending_turns(aborted)
178        return limits
179
180    def _resolve_reasoning_stream_mode(
181        self,
182        data: dict[str, Any],
183        *,
184        request_format: str,
185        conversation: Any | None,
186    ) -> str:
187        if request_format != "responses":
188            return ""
189
190        requested_mode = _normalize_reasoning_stream_mode(
191            data.get("reasoning_stream_mode")
192            or data.get("responses_reasoning_stream_mode")
193            or data.get("reasoning_mode")
194        )
195        return requested_mode
196
197    def _resolve_conversation_protocol(
198        self,
199        data: dict[str, Any],
200        *,
201        request_format: str,
202        conversation: Any | None,
203    ) -> str:
204        if conversation is None:
205            return request_format
206        metadata = dict(conversation.metadata or {})
207        locked_format = _normalize_request_format(metadata.get("request_format"))
208        if locked_format and locked_format != request_format:
209            raise ValueError("conversation protocol is already locked")
210        if request_format == "responses":
211            return "responses"
212        if locked_format:
213            return locked_format
214        return request_format
215
216    def prepare_pending_turn(self, data: dict[str, Any], request_format: str):
217        if not isinstance(data, dict):
218            return build_openai_error("request body must be a JSON object")
219
220        owner = self.auth.owner_id()
221        normalized_data = self._deps.image_store.normalize_request_data(data, owner_id=owner)
222        context_text = extract_context_text(normalized_data, request_format)
223        if not context_text:
224            return build_openai_error("input is required")
225
226        model = str(normalized_data.get("model") or "mock-gpt-4.1-mini")
227        rate_limit = self.user_store.get_effective_messages_per_minute_limit(owner, 0)
228        if not self.message_rate_limiter.allow(owner, rate_limit):
229            return build_openai_error(
230                f"rate limit exceeded: max {rate_limit} messages per minute",
231                code="rate_limit_exceeded",
232                status=429,
233            )
234
235        resolved_conversation, conversation_error = resolve_conversation_for_request(
236            self.store,
237            normalized_data,
238            owner,
239            request_format,
240        )
241        if conversation_error is not None:
242            message, status = conversation_error
243            error_code = "conflict" if status == 409 else "not_found"
244            return build_openai_error(message, code=error_code, status=status)
245        if (
246            resolved_conversation is not None
247            and resolved_conversation.source in {"history", "tool_call_id"}
248            and _conversation_request_format(resolved_conversation.conversation)
249            and _conversation_request_format(resolved_conversation.conversation) != request_format
250        ):
251            resolved_conversation = None
252        conversation = (
253            resolved_conversation.conversation
254            if resolved_conversation is not None
255            else None
256        )
257        if conversation is None:
258            conversation = self.store.create_conversation(owner, title=build_title(context_text))
259
260        existing_pending = self.pending_turns.get_by_conversation(conversation.id)
261        if existing_pending is not None:
262            if resolved_conversation is not None and resolved_conversation.source == "history":
263                conversation = self.store.create_conversation(owner, title=build_title(context_text))
264            else:
265                return build_openai_error(
266                    "conversation is waiting for a reply",
267                    code="conflict",
268                    status=409,
269                )
270
271        try:
272            request_format = self._resolve_conversation_protocol(
273                normalized_data,
274                request_format=request_format,
275                conversation=conversation,
276            )
277            reasoning_stream_mode = self._resolve_reasoning_stream_mode(
278                normalized_data,
279                request_format=request_format,
280                conversation=conversation,
281            )
282        except ValueError as error:
283            return build_openai_error(str(error), code="conflict", status=409)
284
285        conversation_metadata = {
286            **conversation.metadata,
287            "request_format": request_format,
288        }
289        conversation = self.store.update_conversation(
290            conversation.id,
291            owner,
292            metadata=conversation_metadata,
293        )
294
295        extracted_messages = extract_request_messages(
296            self.store,
297            normalized_data,
298            conversation_id=conversation.id,
299            owner=owner,
300            request_format=request_format,
301        )
302        comparable_messages = extract_chatbox_comparable_request_messages(
303            self.store,
304            normalized_data,
305            owner=owner,
306            request_format=request_format,
307        )
308        history_prefix_length = self.store.get_request_history_prefix_length(
309            conversation.id,
310            owner,
311            comparable_messages,
312            normalize_stored_content=normalize_chatbox_history_content,
313        )
314        if history_prefix_length > 0 and len(extracted_messages) >= history_prefix_length:
315            extracted_messages = extracted_messages[history_prefix_length:]
316        has_tool_results = any(
317            str(message_payload.get("metadata", {}).get("response_mode", "")).strip()
318            == "tool_result"
319            for message_payload in extracted_messages
320            if isinstance(message_payload, dict)
321        )
322        if has_tool_results:
323            extracted_messages = [
324                message_payload
325                for message_payload in extracted_messages
326                if str(message_payload.get("role") or "").strip() != "user"
327            ]
328        updated_conversation = self.store.update_conversation(
329            conversation.id,
330            owner,
331            title=conversation.title
332            if conversation.title not in {"新会话", "New conversation", ""}
333            else build_title(context_text),
334            last_user_text=context_text[:1000],
335        )
336        pending_limits = self.enforce_pending_limits(owner)
337        pending = self.pending_turns.register(
338            conversation_id=conversation.id,
339            owner_id=owner,
340            model=model,
341            input_text=context_text,
342            request_format=request_format,
343            reasoning_stream_mode=reasoning_stream_mode,
344            max_age_seconds=float(pending_limits.get("max_age_seconds") or 0),
345            auto_abort_message=str(pending_limits.get("abort_message") or ""),
346            max_output_chars=int(
347                300
348                if pending_limits.get("max_output_chars") is None
349                else pending_limits.get("max_output_chars")
350            ),
351            output_limit_abort_message=str(pending_limits.get("output_limit_abort_message") or ""),
352            available_tool_names=extract_tool_names(normalized_data),
353            available_tool_schemas=extract_tool_schemas(normalized_data),
354        )
355        try:
356            request_debug_metadata = build_message_debug_metadata(
357                auth=self.auth,
358                request_format=request_format,
359                request_data=normalized_data,
360                input_text=context_text,
361                input_payload=request_input_payload(normalized_data, request_format),
362                request_id=pending.request_id,
363                resolved_model=model,
364            )
365            if extracted_messages:
366                for index, message_payload in enumerate(extracted_messages):
367                    metadata = dict(message_payload.get("metadata") or {})
368                    if index == len(extracted_messages) - 1:
369                        metadata = {**metadata, **request_debug_metadata}
370                    self.store.add_message(
371                        conversation.id,
372                        str(message_payload.get("role") or "user"),
373                        str(message_payload.get("content") or ""),
374                        metadata=metadata,
375                    )
376            else:
377                self.store.add_message(
378                    conversation.id,
379                    "user",
380                    context_text,
381                    metadata={
382                        "turn": "user",
383                        "status": "pending",
384                        "source": request_format,
385                        **request_debug_metadata,
386                    },
387                )
388            notify_new_message(
389                self._deps.system_config_store,
390                self.user_store,
391                owner,
392                conversation_title=updated_conversation.title or build_title(context_text),
393                message_text=context_text,
394                logger=self._logger,
395            )
396            self.store.update_conversation(
397                conversation.id,
398                owner,
399                metadata={
400                    **updated_conversation.metadata,
401                    "realtime_status": "waiting",
402                    "realtime_draft_text": "",
403                    "request_format": request_format,
404                },
405            )
406            self._notify(owner, conversation.id)
407            self._automation_rules.start_for_pending(pending)
408        except Exception:
409            self.pending_turns.discard(
410                conversation_id=conversation.id,
411                owner_id=owner,
412            )
413            raise
414        return PreparedTurn(pending=pending, conversation_id=updated_conversation.id)
415
416    def finalize_pending_turn(self, pending: PendingTurn) -> dict[str, Any]:
417        updated_conversation = self.store.get_conversation(pending.conversation_id, pending.owner_id)
418        if updated_conversation is None:
419            raise ValueError("conversation not found")
420        usage = estimate_usage(pending.input_text, pending.assistant_text)
421        try:
422            messages = self.store.get_messages(pending.conversation_id, pending.owner_id)
423        except ValueError:
424            messages = []
425        message_metadata = find_response_message_metadata(messages, pending.response_id)
426        tool_name = str(message_metadata.get("tool_name", "")).strip()
427        tool_call_id = str(message_metadata.get("tool_call_id", "")).strip()
428        arguments = str(message_metadata.get("arguments", "")).strip()
429
430        if pending.request_format == "chat_completions":
431            return build_chat_completion_response(
432                response_id=pending.response_id,
433                model=pending.model,
434                assistant_text=pending.assistant_text,
435                usage=usage,
436                response_mode=pending.response_mode,
437                tool_name=tool_name,
438                tool_call_id=tool_call_id,
439                arguments=arguments,
440            )
441        if pending.request_format == "anthropic_messages":
442            return build_anthropic_message_response(
443                response_id=pending.response_id,
444                model=pending.model,
445                assistant_text=pending.assistant_text,
446                usage=usage,
447                response_mode=pending.response_mode,
448                tool_name=tool_name,
449                tool_call_id=tool_call_id,
450                arguments=arguments,
451            )
452        payload = build_openai_response(
453            response_id=pending.response_id,
454            model=pending.model,
455            conversation_id=updated_conversation.id,
456            assistant_text=pending.assistant_text,
457            usage=usage,
458            output_items=pending.response_output_items or None,
459            output_text=pending.response_output_text,
460        )
461        payload["conversation"] = updated_conversation.to_dict()
462        payload["input_text"] = pending.input_text
463        return payload
464
465    def complete_manual_output(self, data: dict[str, Any]):
466        if not isinstance(data, dict):
467            return {"error": "request body must be a JSON object"}, 400
468        mode = str(data.get("mode", "assistant_message")).strip() or "assistant_message"
469        if mode not in {"assistant_message", "thinking", "tool_call"}:
470            return {"error": "unsupported mode"}, 400
471        text = str(data.get("text", "")).strip()
472        tool_name = str(data.get("tool_name", "")).strip()
473        tool_call_id = str(data.get("tool_call_id", "")).strip()
474        reasoning_stream_mode = str(data.get("reasoning_stream_mode", "")).strip()
475        if mode == "tool_call":
476            if not tool_name:
477                return {"error": "tool_name is required"}, 400
478            if not text:
479                return {"error": "tool arguments are required"}, 400
480            if not tool_call_id:
481                tool_call_id = f"call_{uuid.uuid4().hex[:24]}"
482        conversation_id = str(data.get("conversation_id", "")).strip()
483        owner = self.auth.owner_id()
484        if not conversation_id:
485            return {"error": "conversation_id is required"}, 400
486
487        pending = self.pending_turns.get_by_conversation(conversation_id)
488        if pending is None:
489            return {"error": "conversation is not waiting for a reply"}, 409
490        if pending.owner_id != owner:
491            return {"error": "conversation not found"}, 404
492
493        try:
494            if mode == "tool_call":
495                pending, assistant_metadata = self._output_controller.complete_tool_call(
496                    conversation_id=conversation_id,
497                    owner_id=owner,
498                    tool_name=tool_name,
499                    arguments=text,
500                    provider="human",
501                    model=str(data.get("model") or pending.model or "mock-gpt-4.1-mini"),
502                    tool_call_id=tool_call_id,
503                    reasoning_stream_mode=reasoning_stream_mode,
504                )
505                assistant_text = pending.assistant_text
506            else:
507                pending, assistant_metadata = self._output_controller.complete_assistant_message(
508                    conversation_id=conversation_id,
509                    owner_id=owner,
510                    provider="human",
511                    model=str(data.get("model") or pending.model or "mock-gpt-4.1-mini"),
512                    reasoning_stream_mode=reasoning_stream_mode,
513                )
514                assistant_text = pending.assistant_text
515        except ValueError as error:
516            return {"error": str(error)}, 409
517
518        conversation = self.store.get_conversation(conversation_id, owner)
519        return {
520            "ok": True,
521            "conversation": conversation.to_dict() if conversation else None,
522            "message": {
523                "role": "assistant" if mode == "assistant_message" else "tool_call",
524                "content": assistant_text,
525                "response_id": pending.response_id,
526                "metadata": assistant_metadata,
527            },
528        }
529
530    def add_manual_output_delta(self, data: dict[str, Any]):
531        if not isinstance(data, dict):
532            return {"error": "request body must be a JSON object"}, 400
533        text = normalize_message_text(str(data.get("text", "")).strip())
534        if not text:
535            return {"error": "text is required"}, 400
536        conversation_id = str(data.get("conversation_id", "")).strip()
537        reasoning_stream_mode = str(data.get("reasoning_stream_mode", "")).strip()
538        chunk_kind = "thinking" if str(data.get("kind", "")).strip() == "thinking" else "answer"
539        owner = self.auth.owner_id()
540        if not conversation_id:
541            return {"error": "conversation_id is required"}, 400
542
543        try:
544            pending = self._output_controller.add_text_delta(
545                conversation_id=conversation_id,
546                owner_id=owner,
547                text=text,
548                reasoning_stream_mode=reasoning_stream_mode,
549                kind=chunk_kind,
550            )
551        except ValueError as error:
552            return {"error": str(error)}, 409
553
554        return {
555            "ok": True,
556            "conversation_id": pending.conversation_id,
557            "request_id": pending.request_id,
558            "draft_text": pending.draft_text,
559            "draft_length": len(pending.draft_text),
560        }
561
562    def _notify(self, owner_id: str, conversation_id: str) -> None:
563        if self._publish_sync is None:
564            return
565        self._publish_sync(owner_id, conversation_id)
566