Spaces:
Running
Running
| """OpenAI 兼容三件套:/v1/chat/completions、/v1/embeddings、/v1/models。 | |
| 完全兼容 OpenAI SDK 调用,头部支持 Authorization: Bearer 或 x-xtc-access-key。 | |
| """ | |
| from __future__ import annotations | |
| import json | |
| from typing import Any, AsyncIterator, Optional | |
| from fastapi import APIRouter, Depends, Header, Query, Request | |
| from fastapi.responses import StreamingResponse | |
| from sse_starlette.sse import EventSourceResponse | |
| from ..adapters import gemini_api, openai as openai_adapter | |
| from ..auth import require_access_key | |
| from ..config import GEMINI_DEFAULT_MODEL, get_settings | |
| from ..errors import HttpError | |
| from ..providers.policy import is_model_allowed | |
| from ..services.config_store import load_config | |
| from ._common import ( | |
| CORS_HEADERS, | |
| ok_with_cors, | |
| record_usage, | |
| select_provider_and_key, | |
| ) | |
| router = APIRouter(prefix="/v1", tags=["openai-compat"]) | |
| def _provider_hint_header(provider_id: str, model: str, image_fix_mode: Optional[str] = None) -> dict: | |
| h = {"x-xtc-provider": provider_id, "x-xtc-model": model} | |
| if image_fix_mode: | |
| h["x-xtc-image-fix-mode"] = image_fix_mode | |
| return h | |
| async def list_models(_key: str = Depends(require_access_key)) -> dict: | |
| config = await load_config() | |
| # 聚合所有启用厂商的模型列表(取缓存或拉取) | |
| # 复用 xtc._fetch_provider_models:上游失败抛 HttpError、仅缓存非空结果, | |
| # 避免静默吞掉异常导致"该厂商无模型"的假象。 | |
| from .xtc import _fetch_provider_models | |
| out: list[dict] = [] | |
| for p in config.providers: | |
| if not p.enabled: | |
| continue | |
| try: | |
| models = await _fetch_provider_models(p) | |
| except HttpError as e: | |
| print(f"[models] provider {p.id} list failed: {e.message}") | |
| continue | |
| for m in models: | |
| mid = m.get("id") or "" | |
| if not mid: | |
| continue | |
| # 按厂商模型策略(黑/白名单)过滤,让前端选择器与实际可请求模型一致 | |
| if not is_model_allowed(p, mid): | |
| continue | |
| full_id = f"{p.model_prefix}{mid}" if p.model_prefix else mid | |
| out.append({"id": full_id, "object": "model", "created": 0, "owned_by": p.id}) | |
| return ok_with_cors({"object": "list", "data": out}) | |
| async def chat_completions( | |
| request: Request, | |
| _key: str = Depends(require_access_key), | |
| x_provider: Optional[str] = Header(default=None, alias="x-provider"), | |
| ): | |
| body = await request.json() | |
| if not isinstance(body, dict): | |
| raise HttpError("invalid body", status=400, code="bad_request") | |
| model = body.get("model") or GEMINI_DEFAULT_MODEL | |
| messages = body.get("messages") or [] | |
| if not isinstance(messages, list) or not messages: | |
| raise HttpError("messages is required", status=400, code="bad_request") | |
| stream = bool(body.get("stream")) | |
| config = await load_config() | |
| provider, api_key, clean_model = await select_provider_and_key( | |
| config=config, | |
| provider_id=x_provider, | |
| model=model, | |
| fallback_model=GEMINI_DEFAULT_MODEL, | |
| ) | |
| if stream: | |
| return _stream_chat(provider, api_key, clean_model, body, _key) | |
| return await _nonstream_chat(provider, api_key, clean_model, body, messages, _key) | |
| async def _nonstream_chat( | |
| provider, | |
| api_key: str, | |
| clean_model: str, | |
| body: dict, | |
| messages: list, | |
| access_key: str, | |
| ): | |
| try: | |
| if provider.type == "gemini": | |
| result = await gemini_api.chat_completions( | |
| api_key=api_key, | |
| model=clean_model, | |
| messages=messages, | |
| body=body, | |
| stream=False, | |
| ) | |
| else: | |
| body_with_model = {**body, "model": clean_model} | |
| result = await openai_adapter.chat_completions( | |
| base_url=provider.base_url, | |
| api_key=api_key, | |
| body=body_with_model, | |
| stream=False, | |
| ) | |
| usage = (result or {}).get("usage") | |
| record_usage( | |
| access_key=access_key, | |
| provider=provider.id, | |
| model=clean_model, | |
| usage=usage, | |
| ok=True, | |
| ) | |
| return ok_with_cors( | |
| result, | |
| extra_headers=_provider_hint_header(provider.id, clean_model), | |
| ) | |
| except HttpError as e: | |
| record_usage( | |
| access_key=access_key, | |
| provider=provider.id, | |
| model=clean_model, | |
| usage=None, | |
| ok=False, | |
| error_code=e.code, | |
| ) | |
| raise | |
| async def _stream_chat(provider, api_key: str, clean_model: str, body: dict, access_key: str): | |
| """流式响应:Gemini 转 OpenAI chunk,OpenAI 直接透传。""" | |
| from ..utils.sse import sse_data, sse_done, passthrough_with_usage | |
| # 让上游在最后一个流式 chunk 里返回 usage,否则 token 计数永远为 0。 | |
| body = {**body, "stream_options": {**(body.get("stream_options") or {}), "include_usage": True}} | |
| if provider.type == "gemini": | |
| # Gemini 转 OpenAI chunk 流 | |
| captured: dict = {} | |
| async def gen() -> AsyncIterator[str]: | |
| try: | |
| chunk_iter = await gemini_api.chat_completions( | |
| api_key=api_key, | |
| model=clean_model, | |
| messages=body.get("messages") or [], | |
| body=body, | |
| stream=True, | |
| ) | |
| async for chunk in chunk_iter: | |
| if isinstance(chunk, dict) and chunk.get("usage"): | |
| captured["usage"] = chunk["usage"] | |
| yield sse_data(chunk) | |
| yield sse_done() | |
| except HttpError as e: | |
| # 错误以 SSE event 形式返回 | |
| record_usage( | |
| access_key=access_key, | |
| provider=provider.id, | |
| model=clean_model, | |
| usage=None, | |
| ok=False, | |
| error_code=e.code, | |
| ) | |
| yield sse_data( | |
| { | |
| "error": { | |
| "code": e.code, | |
| "message": e.message, | |
| "status": e.status, | |
| } | |
| } | |
| ) | |
| yield sse_done() | |
| else: | |
| record_usage( | |
| access_key=access_key, | |
| provider=provider.id, | |
| model=clean_model, | |
| usage=captured.get("usage"), | |
| ok=True, | |
| ) | |
| return EventSourceResponse( | |
| gen(), | |
| headers={ | |
| **CORS_HEADERS, | |
| **_provider_hint_header(provider.id, clean_model), | |
| "Cache-Control": "no-cache", | |
| "x-accel-buffering": "no", | |
| }, | |
| ) | |
| # OpenAI 透传 | |
| body_with_model = {**body, "model": clean_model} | |
| captured_pt: dict = {} | |
| async def gen_passthrough() -> AsyncIterator[bytes]: | |
| try: | |
| chunk_iter = await openai_adapter.chat_completions( | |
| base_url=provider.base_url, | |
| api_key=api_key, | |
| body=body_with_model, | |
| stream=True, | |
| ) | |
| async for chunk in passthrough_with_usage(chunk_iter, captured_pt): | |
| yield chunk | |
| except HttpError as e: | |
| record_usage( | |
| access_key=access_key, | |
| provider=provider.id, | |
| model=clean_model, | |
| usage=None, | |
| ok=False, | |
| error_code=e.code, | |
| ) | |
| err_payload = sse_data( | |
| {"error": {"code": e.code, "message": e.message, "status": e.status}} | |
| ) | |
| yield err_payload.encode("utf-8") | |
| yield sse_done().encode("utf-8") | |
| else: | |
| record_usage( | |
| access_key=access_key, | |
| provider=provider.id, | |
| model=clean_model, | |
| usage=captured_pt.get("usage"), | |
| ok=True, | |
| ) | |
| return StreamingResponse( | |
| gen_passthrough(), | |
| media_type="text/event-stream", | |
| headers={ | |
| **CORS_HEADERS, | |
| **_provider_hint_header(provider.id, clean_model), | |
| "Cache-Control": "no-cache", | |
| "x-accel-buffering": "no", | |
| }, | |
| ) | |
| async def embeddings( | |
| request: Request, | |
| _key: str = Depends(require_access_key), | |
| x_provider: Optional[str] = Header(default=None, alias="x-provider"), | |
| ): | |
| body = await request.json() | |
| if not isinstance(body, dict): | |
| raise HttpError("invalid body", status=400, code="bad_request") | |
| input_data = body.get("input") | |
| if input_data is None: | |
| raise HttpError("input is required", status=400, code="bad_request") | |
| config = await load_config() | |
| provider, api_key, clean_model = await select_provider_and_key( | |
| config=config, | |
| provider_id=x_provider, | |
| model=body.get("model"), | |
| fallback_model="text-embedding-3-small", | |
| ) | |
| try: | |
| if provider.type == "gemini": | |
| result = await gemini_api.embeddings( | |
| api_key=api_key, | |
| model=clean_model, | |
| input_data=input_data, | |
| ) | |
| else: | |
| body_with_model = {**body, "model": clean_model} | |
| result = await openai_adapter.embeddings( | |
| base_url=provider.base_url, | |
| api_key=api_key, | |
| body=body_with_model, | |
| ) | |
| record_usage( | |
| access_key=_key, | |
| provider=provider.id, | |
| model=clean_model, | |
| usage=(result or {}).get("usage"), | |
| ok=True, | |
| ) | |
| return ok_with_cors( | |
| result, extra_headers=_provider_hint_header(provider.id, clean_model) | |
| ) | |
| except HttpError as e: | |
| record_usage( | |
| access_key=_key, | |
| provider=provider.id, | |
| model=clean_model, | |
| usage=None, | |
| ok=False, | |
| error_code=e.code, | |
| ) | |
| raise | |