File size: 5,896 Bytes
59af925
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
"""第三方接入模型的**文本对话**路由(把 /v1/chat/completions 接到中转站上)。

第三方接入(`services/third_party_image_service.py`)本来就是"一个中转站 key 下的
模型清单",里面的模型可能只能生图,也可能只能对话(能力字段含 ``chat``)。

这个模块负责协议侧的那一半:

* 判断一次对话请求该不该走第三方(模型命中 + 能力含 ``chat`` + 权限通过);
* 把上游的 OpenAI 兼容响应规范成本站 `chat.completion` / `chat.completion.chunk`;
* 上游报错时抛出可读的异常,交给 `LoggedCall` 记日志并回 502。

**计费**:与站内文本一致 —— 文本对话本来就只记日志、不扣积分,第三方文本同样不扣。
(生图侧才走 `ImageBillingReservation`。)
"""

from __future__ import annotations

import time
import uuid
from typing import Any, Iterator

from fastapi import HTTPException

from services.protocol.openai_v1_chat_complete import completion_chunk, completion_response
from services.third_party_image_service import ThirdPartyRoute, third_party_image_service
from utils.helper import is_image_chat_request


def resolve_route(body: dict[str, Any], identity: dict[str, Any] | None) -> ThirdPartyRoute | None:
    """这次对话请求命中的第三方路由;不该接管时返回 ``None``。

    以下情况一律不接管(交回原来的账号池逻辑):

    * 没有身份(内部调用,如 Skills 生成、反推提示词);
    * 请求本身是**生图**请求(模型是生图模型,或 ``modalities`` 含 image);
    * 模型名没有命中任何能力含 ``chat`` 的第三方模型。
    """

    if not isinstance(identity, dict) or not identity:
        return None
    if is_image_chat_request(body):
        return None
    model = str(body.get("model") or "").strip()
    if not model:
        return None
    route = third_party_image_service.find_chat_route(model)
    if route is None:
        return None
    try:
        third_party_image_service.assert_access(identity, route)
    except PermissionError as exc:
        raise HTTPException(status_code=403, detail={"error": str(exc)}) from exc
    return route


def _upstream_failure_message(payload: object) -> str:
    if isinstance(payload, dict):
        error = payload.get("error")
        if isinstance(error, dict) and error.get("message"):
            return str(error["message"])
        if isinstance(error, str) and error:
            return error
        if payload.get("message"):
            return str(payload["message"])
    return "第三方对话接口返回了无法解析的响应"


def _first_message(payload: dict[str, Any]) -> dict[str, Any]:
    choices = payload.get("choices")
    first = choices[0] if isinstance(choices, list) and choices and isinstance(choices[0], dict) else {}
    message = first.get("message")
    return message if isinstance(message, dict) else {}


def chat_completion_response(body: dict[str, Any], route: ThirdPartyRoute) -> dict[str, Any]:
    """非流式:转发给第三方,规范成本站的 `chat.completion`。"""

    model = str(body.get("model") or "")
    messages = body.get("messages") if isinstance(body.get("messages"), list) else None
    raw = third_party_image_service.chat_completion(dict(body), route)
    if not isinstance(raw, dict) or not isinstance(raw.get("choices"), list) or not raw.get("choices"):
        raise RuntimeError(_upstream_failure_message(raw))

    first = raw["choices"][0] if isinstance(raw["choices"][0], dict) else {}
    message = _first_message(raw)
    content = message.get("content")
    normalized = completion_response(model, content if isinstance(content, str) else "", messages=messages)
    target = normalized["choices"][0]["message"]
    # 上游可能带上本站不产出的字段(工具调用 / 思考内容),透传以免丢信息。
    for key in ("tool_calls", "reasoning_content", "annotations", "images"):
        if message.get(key):
            target[key] = message[key]
    normalized["choices"][0]["finish_reason"] = first.get("finish_reason") or "stop"
    usage = raw.get("usage")
    if isinstance(usage, dict) and usage:
        # 上游的 usage 更准(它是真正调用方),给了就整份采用,不做半吊子合并。
        normalized["usage"] = usage
    normalized["model"] = model
    return normalized


def stream_chat_chunks(body: dict[str, Any], route: ThirdPartyRoute) -> Iterator[dict[str, Any]]:
    """流式:把上游 SSE 的增量包成 `chat.completion.chunk`。"""

    model = str(body.get("model") or "")
    completion_id = f"chatcmpl-{uuid.uuid4().hex}"
    created = int(time.time())
    sent_role = False
    sent_any = False
    for item in third_party_image_service.stream_chat_completion(dict(body), route):
        choices = item.get("choices")
        first = choices[0] if isinstance(choices, list) and choices and isinstance(choices[0], dict) else {}
        delta = first.get("delta")
        if not isinstance(delta, dict):
            continue
        sent_any = True
        if not sent_role:
            sent_role = True
            delta = {"role": "assistant", **delta}
        yield completion_chunk(model, dict(delta), first.get("finish_reason"), completion_id, created)
    if not sent_any:
        yield completion_chunk(model, {"role": "assistant", "content": ""}, None, completion_id, created)
    yield completion_chunk(model, {}, "stop", completion_id, created)


def handle(body: dict[str, Any], route: ThirdPartyRoute):
    """按请求体里的 ``stream`` 分派到流式 / 非流式。"""

    if body.get("stream"):
        return stream_chat_chunks(body, route)
    return chat_completion_response(body, route)


__all__ = [
    "resolve_route",
    "handle",
    "chat_completion_response",
    "stream_chat_chunks",
]