Spaces:
Running
Running
| """共享请求/响应工具:CORS、provider 选择辅助、usage 记录。""" | |
| from __future__ import annotations | |
| import json | |
| import time | |
| import uuid | |
| from typing import Any, Optional | |
| from fastapi import Request | |
| from fastapi.responses import JSONResponse | |
| from ..config import get_settings | |
| from ..database import get_conn | |
| from ..models.config import AppConfig, Provider | |
| from ..providers.keypool import choose_provider_api_key | |
| from ..providers.policy import assert_model_allowed | |
| from ..providers.resolver import normalize_model, resolve_provider | |
| CORS_HEADERS = { | |
| "Access-Control-Allow-Origin": "*", | |
| "Access-Control-Allow-Methods": "*", | |
| "Access-Control-Allow-Headers": "*", | |
| "Access-Control-Expose-Headers": "x-xtc-provider, x-xtc-model, x-xtc-image-fix-mode", | |
| } | |
| def ok_json(payload: dict, *, status: int = 200, extra_headers: Optional[dict] = None) -> JSONResponse: | |
| headers = dict(CORS_HEADERS) | |
| if extra_headers: | |
| headers.update(extra_headers) | |
| return JSONResponse(status_code=status, content=payload, headers=headers) | |
| def ok_with_cors(payload: dict, *, status: int = 200, extra_headers: Optional[dict] = None) -> JSONResponse: | |
| body = {"ok": True, **payload} | |
| return ok_json(body, status=status, extra_headers=extra_headers) | |
| def gen_trace_id() -> str: | |
| return uuid.uuid4().hex[:12] | |
| async def select_provider_and_key( | |
| *, | |
| config: AppConfig, | |
| provider_id: Optional[str], | |
| model: Optional[str], | |
| fallback_model: Optional[str] = None, | |
| ) -> tuple[Provider, str, str]: | |
| """统一流程:解析 provider -> 校验策略 -> 选 key -> 归一化模型名。""" | |
| provider = resolve_provider(config, provider_id=provider_id, model=model) | |
| clean_model = normalize_model(provider, model, fallback=fallback_model) | |
| assert_model_allowed(provider, clean_model) | |
| api_key = choose_provider_api_key(provider) | |
| return provider, api_key, clean_model | |
| def record_usage( | |
| *, | |
| access_key: Optional[str], | |
| provider: str, | |
| model: str, | |
| usage: Optional[dict], | |
| ok: bool, | |
| error_code: Optional[str] = None, | |
| ) -> None: | |
| now = int(time.time()) | |
| sql = ( | |
| "INSERT INTO usage_log(ts, access_key, provider, model, prompt_tokens, completion_tokens, total_tokens, ok, error_code) " | |
| "VALUES(?,?,?,?,?,?,?,?,?)" | |
| ) | |
| args = ( | |
| now, | |
| access_key, | |
| provider, | |
| model, | |
| int((usage or {}).get("prompt_tokens") or 0), | |
| int((usage or {}).get("completion_tokens") or 0), | |
| int((usage or {}).get("total_tokens") or 0), | |
| 1 if ok else 0, | |
| error_code, | |
| ) | |
| def _task(c): | |
| try: | |
| c.execute(sql, args) | |
| except Exception as e: | |
| print(f"[usage] record failed: {e}") | |
| # 转交后台写线程,避免在事件循环上同步 INSERT | |
| try: | |
| from ..db_writer import enqueue, is_started | |
| if is_started(): | |
| enqueue(_task) | |
| else: | |
| with get_conn() as conn: | |
| _task(conn) | |
| except Exception: | |
| try: | |
| with get_conn() as conn: | |
| _task(conn) | |
| except Exception as e: | |
| print(f"[usage] record failed: {e}") | |
| # Webhook 通知(fire-and-forget,失败不影响主流程) | |
| try: | |
| from ..services import webhook_store | |
| event = "chat.completed" if ok else "chat.failed" | |
| webhook_store.notify_fire_and_forget( | |
| event, | |
| { | |
| "access_key": access_key, | |
| "provider": provider, | |
| "model": model, | |
| "ok": ok, | |
| "error_code": error_code, | |
| "usage": usage, | |
| "ts": int(time.time()), | |
| }, | |
| ) | |
| except Exception: | |
| pass | |
| async def read_json_body(request: Request) -> dict: | |
| """读取 JSON body,失败返回空 dict(兼容 multipart 场景)。""" | |
| try: | |
| data = await request.json() | |
| if isinstance(data, dict): | |
| return data | |
| except Exception: | |
| pass | |
| return {} | |
| def normalize_chat_body(body: dict) -> tuple[list, str, str]: | |
| """把 XTC 简化 body 归一化为 (messages, model, provider_id)。 | |
| 兼容前端 api.js 的两种调用形态: | |
| - 简化形态(默认):``{input: "文本", images: ["url"...], files: [...]}`` | |
| 自动组装为 OpenAI messages,等价于旧 Netlify 版 xtc-client-api.mjs 的行为。 | |
| - OpenAI 形态:``{messages: [{role, content}]}`` 直接透传。 | |
| 同时处理附件文本(files/file_names),追加为最后一条 user message。 | |
| file_names 兼容 JSON 字符串(前端 upload 路径会发 JSON.stringify(names))。 | |
| """ | |
| if not isinstance(body, dict): | |
| return [], "", "" | |
| messages = body.get("messages") | |
| if not isinstance(messages, list): | |
| messages = [] | |
| input_text = str(body.get("input") or "").strip() | |
| images = body.get("images") | |
| if not isinstance(images, list): | |
| images = [images] if images else [] | |
| image_urls: list[str] = [] | |
| for u in images: | |
| s = str(u or "").strip() | |
| if s: | |
| image_urls.append(s) | |
| # 简化形态:只有 input/images 时,组装成 OpenAI messages | |
| if not messages and (input_text or image_urls): | |
| content: list = [] | |
| if input_text: | |
| content.append({"type": "text", "text": input_text}) | |
| for url in image_urls: | |
| content.append({"type": "image_url", "image_url": {"url": url}}) | |
| messages = [{"role": "user", "content": content}] | |
| if not isinstance(messages, list): | |
| messages = [] | |
| # 附件文本拼到末尾(前端 normalizeFileInputs 上传的文本类文件) | |
| files = body.get("files") | |
| if not isinstance(files, list): | |
| files = [files] if files else [] | |
| file_names_raw = body.get("file_names") | |
| # file_names 兼容 JSON 字符串(前端 callUpload 会发 JSON.stringify(names)) | |
| if isinstance(file_names_raw, str) and file_names_raw.strip().startswith("["): | |
| try: | |
| parsed = json.loads(file_names_raw) | |
| if isinstance(parsed, list): | |
| file_names_raw = [str(x) for x in parsed] | |
| except Exception: | |
| file_names_raw = [file_names_raw] | |
| if not isinstance(file_names_raw, list): | |
| file_names_raw = [file_names_raw] if file_names_raw else [] | |
| text_parts: list[str] = [] | |
| for i, f in enumerate(files): | |
| if not isinstance(f, str): | |
| f = str(f or "") | |
| if not f.strip(): | |
| continue | |
| fname = "" | |
| if i < len(file_names_raw): | |
| fname = str(file_names_raw[i] or "").strip() | |
| fname = fname or f"file_{i + 1}" | |
| text_parts.append(f"[{fname}]\n{f}") | |
| if text_parts: | |
| messages = list(messages) + [ | |
| {"role": "user", "content": "\n\n".join(text_parts)} | |
| ] | |
| model = str(body.get("model") or "").strip() | |
| provider_id = body.get("provider") or body.get("provider_id") or "" | |
| provider_id = str(provider_id or "").strip() | |
| return messages, model, provider_id | |