豆包网页端服务转Api服务

This commit is contained in:
Cx330 committed 2026-08-20 11:32:32 +08:00
1 parent e7e5c0f83d
commit b37295d2d5
17 files changed
+1829 -1

No files matched your search

View File
Whitespace-only changes.
+193
View File
@@ -0,0 +1,193 @@
import asyncio
import json
from typing import Optional
from loguru import logger
from playwright.async_api import (
Browser,
BrowserContext,
Page,
async_playwright,
)
from .config import settings
STEALTH_JS = """
Object.defineProperty(navigator, 'webdriver', {get: () => undefined});
Object.defineProperty(navigator, 'plugins', {
get: () => [1, 2, 3, 4, 5],
});
Object.defineProperty(navigator, 'languages', {
get: () => ['zh-CN', 'zh', 'en'],
});
Object.defineProperty(navigator, 'platform', {
get: () => 'Win32',
});
window.chrome = { runtime: {} };
const originalQuery = window.navigator.permissions.query;
window.navigator.permissions.query = (parameters) =>
parameters.name === 'notifications'
? Promise.resolve({ state: Notification.permission })
: originalQuery(parameters);
"""
class BrowserManager:
_instance: Optional["BrowserManager"] = None
_lock: asyncio.Lock = asyncio.Lock()
def __new__(cls) -> "BrowserManager":
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
if not hasattr(self, "_initialized"):
self._initialized = True
self.playwright = None
self.browser: Optional[Browser] = None
self.context: Optional[BrowserContext] = None
self.page: Optional[Page] = None
self._ready = False
@property
def ready(self) -> bool:
return self._ready and self.page is not None and not self.page.is_closed()
async def start(self) -> Page:
async with self._lock:
if self.ready:
return self.page
logger.info("Starting Playwright browser...")
self.playwright = await async_playwright().start()
self.browser = await self.playwright.chromium.launch(
headless=settings.browser_headless,
args=[
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--disable-blink-features=AutomationControlled",
"--disable-features=IsolateOrigins,site-per-process",
"--window-size=1280,720",
],
)
storage_state = (
str(settings.state_file) if settings.state_file.exists() else None
)
self.context = await self.browser.new_context(
viewport={
"width": settings.viewport_width,
"height": settings.viewport_height,
},
user_agent=(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/131.0.0.0 Safari/537.36"
),
locale="zh-CN",
timezone_id="Asia/Shanghai",
storage_state=storage_state,
)
await self.context.add_init_script(STEALTH_JS)
self.context.set_default_timeout(settings.page_load_timeout)
self.context.set_default_navigation_timeout(60000)
self.page = await self.context.new_page()
await self._navigate_to_doubao()
self._ready = True
logger.info("Browser started and navigated to Doubao")
return self.page
async def _navigate_to_doubao(self):
if not self.page:
return
try:
await self.page.goto(settings.doubao_url, wait_until="domcontentloaded")
await self.page.wait_for_timeout(3000)
except Exception as e:
logger.warning(f"Navigation warning: {e}")
async def save_state(self):
if self.context:
await self.context.storage_state(path=str(settings.state_file))
cookies = await self.context.cookies()
with open(settings.cookies_file, "w", encoding="utf-8") as f:
json.dump(cookies, f, ensure_ascii=False, indent=2)
logger.info(f"Saved browser state ({len(cookies)} cookies)")
async def check_login(self) -> bool:
"""Check if the user is logged in by looking for login indicators."""
if not self.ready:
return False
try:
login_indicators = await self.page.evaluate(
"""
() => {
const body = document.body ? document.body.innerText : '';
const url = window.location.href;
if (url.includes('login') || url.includes('passport')) return false;
const loginBtn = document.querySelector(
'[class*="login"], [class*="sign-in"], [data-testid*="login"]'
);
if (loginBtn && loginBtn.offsetParent !== null) return false;
const inputArea = document.querySelector(
'textarea, [contenteditable="true"], [role="textbox"]'
);
return !!inputArea;
}
"""
)
return login_indicators
except Exception as e:
logger.warning(f"Login check failed: {e}")
return False
async def get_page(self) -> Page:
if not self.ready:
await self.start()
return self.page
async def reload(self):
if self.page:
await self.page.reload(wait_until="domcontentloaded")
await self.page.wait_for_timeout(2000)
async def restart(self):
logger.info("Restarting browser session...")
await self.stop()
await asyncio.sleep(1)
await self.start()
async def stop(self):
self._ready = False
try:
if self.context:
await self.save_state()
except Exception as e:
logger.warning(f"Failed to save state during stop: {e}")
try:
if self.browser:
await self.browser.close()
except Exception:
pass
try:
if self.playwright:
await self.playwright.stop()
except Exception:
pass
self.page = None
self.context = None
self.browser = None
self.playwright = None
logger.info("Browser stopped")
browser_manager = BrowserManager()
+38
View File
@@ -0,0 +1,38 @@
from pathlib import Path
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
api_key: str = "sk-your-api-key-here"
api_port: int = 8000
browser_headless: bool = False
doubao_url: str = "https://www.doubao.com/chat/"
doubao_model: str = "doubao-pro"
viewport_width: int = 1280
viewport_height: int = 720
data_dir: Path = Path("/app/data")
cookies_file: Path = Path("/app/data/cookies.json")
state_file: Path = Path("/app/data/browser_state.json")
response_timeout: int = 120
page_load_timeout: int = 30000
typing_delay_min: int = 30
typing_delay_max: int = 80
inter_message_delay: float = 0.5
log_level: str = "INFO"
vnc_port: int = 5900
novnc_port: int = 6080
vnc_view_only: bool = False
screen_width: int = 1280
screen_height: int = 720
screen_depth: int = 24
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
settings = Settings()
settings.data_dir.mkdir(parents=True, exist_ok=True)
+396
View File
@@ -0,0 +1,396 @@
import asyncio
import random
import time
from typing import AsyncGenerator, List, Optional, Tuple
from loguru import logger
from playwright.async_api import Locator, Page, TimeoutError as PWTimeout
from .config import settings
from .models import ChatMessage
FIND_INPUT_JS = """
() => {
const selectors = [
'.tiptap.ProseMirror[contenteditable="true"]',
'div[contenteditable="true"]',
'textarea[placeholder*="发"]',
'textarea[placeholder*="输入"]',
'textarea[placeholder*="问"]',
'textarea',
'[role="textbox"]',
];
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el && el.offsetParent !== null) {
const rect = el.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) return sel;
}
}
return null;
}
"""
FIND_SEND_BTN_JS = """
() => {
const selectors = [
'button[aria-label*="发送"]',
'button[aria-label*="Send"]',
'button[type="submit"]',
'button[class*="send"]',
'button[class*="submit"]',
'[data-testid*="send"]',
'button[class*="Send"]',
];
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el && el.offsetParent !== null) return sel;
}
return null;
}
"""
GET_RESPONSE_TEXT_JS = """
() => {
const messageList = document.querySelector('[class*="message-list"]');
if (messageList) {
const msgs = Array.from(messageList.children).filter(el => {
const cls = el.className || '';
return el.offsetParent !== null
&& !cls.includes('action-bar')
&& !cls.includes('suggest-')
&& el.innerText.trim();
});
if (msgs.length > 0) {
return msgs[msgs.length - 1].innerText.trim();
}
}
return null;
}
"""
IS_GENERATING_JS = """
() => {
const stopSelectors = [
'[class*="stop-generate"]',
'[class*="stop_generate"]',
'[aria-label*="停止"]',
'[aria-label*="Stop"]',
'[data-testid*="stop"]',
'button[disabled][class*="send"]',
];
for (const sel of stopSelectors) {
const el = document.querySelector(sel);
if (el && el.offsetParent !== null) return true;
}
const spinnerSelectors = [
'[class*="typing-indicator"]',
'[class*="thinking"]',
'[class*="loading-dot"]',
'svg[class*="loading"]',
'[class*="generating"]',
];
for (const sel of spinnerSelectors) {
const el = document.querySelector(sel);
if (el && el.offsetParent !== null) return true;
}
return false;
}
"""
NEW_CHAT_BTN_JS = """
() => {
const selectors = [
'button[aria-label*="新对话"]',
'button[aria-label*="New"]',
'a[aria-label*="新对话"]',
'a[aria-label*="New"]',
'[class*="new-chat"]',
'[class*="new_chat"]',
'[data-testid*="new-chat"]',
'[data-testid*="new_chat"]',
'a[href*="/chat"][class*="new"]',
];
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el && el.offsetParent !== null) return sel;
}
return null;
}
"""
INSPECT_DOM_JS = """
() => {
const result = {
url: window.location.href,
title: document.title,
textareas: [],
contentEditables: [],
buttons: [],
messageContainers: [],
};
document.querySelectorAll('textarea').forEach(el => {
result.textareas.push({
placeholder: el.placeholder,
className: el.className.slice(0, 80),
visible: el.offsetParent !== null,
});
});
document.querySelectorAll('[contenteditable="true"]').forEach(el => {
result.contentEditables.push({
className: el.className.slice(0, 80),
visible: el.offsetParent !== null,
});
});
document.querySelectorAll('button').forEach(el => {
if (el.offsetParent !== null) {
result.buttons.push({
text: el.innerText.slice(0, 40),
ariaLabel: el.getAttribute('aria-label'),
className: el.className.slice(0, 80),
});
}
});
const msgPatterns = ['message', 'chat-item', 'conversation', 'receive', 'bot', 'ai-msg'];
msgPatterns.forEach(pattern => {
document.querySelectorAll(`[class*="${pattern}"]`).forEach(el => {
if (el.offsetParent !== null && result.messageContainers.length < 20) {
result.messageContainers.push({
pattern: pattern,
className: el.className.slice(0, 80),
text: el.innerText.slice(0, 100),
childCount: el.children.length,
});
}
});
});
return result;
}
"""
class DoubaoChat:
def __init__(self, page: Page):
self.page = page
async def start_new_chat(self) -> bool:
"""Try to start a new chat conversation."""
try:
selector = await self.page.evaluate(NEW_CHAT_BTN_JS)
if selector:
btn = self.page.locator(selector).first
await btn.click()
await self.page.wait_for_timeout(2000)
logger.info("Started new chat via button")
return True
except Exception as e:
logger.debug(f"New chat button not found: {e}")
try:
await self.page.goto(settings.doubao_url, wait_until="domcontentloaded")
await self.page.wait_for_timeout(3000)
logger.info("Navigated to fresh chat URL")
return True
except Exception as e:
logger.warning(f"Failed to navigate to chat URL: {e}")
return False
async def _find_input(self) -> Optional[Tuple[Locator, str]]:
"""Find the input element, returns (locator, selector)."""
selector = await self.page.evaluate(FIND_INPUT_JS)
if not selector:
return None
loc = self.page.locator(selector).first
try:
await loc.wait_for(state="visible", timeout=5000)
return loc, selector
except PWTimeout:
return None
async def _find_send_button(self) -> Optional[Locator]:
"""Find the send button."""
selector = await self.page.evaluate(FIND_SEND_BTN_JS)
if not selector:
return None
loc = self.page.locator(selector).first
try:
await loc.wait_for(state="visible", timeout=3000)
return loc
except PWTimeout:
return None
async def _type_message(self, input_loc: Locator, text: str):
"""Type a message with human-like delays."""
await input_loc.click()
await asyncio.sleep(0.2)
if len(text) > 1000:
await self.page.keyboard.insert_text(text)
await asyncio.sleep(0.3)
else:
delay = random.randint(settings.typing_delay_min, settings.typing_delay_max)
await self.page.keyboard.type(text, delay=delay)
await asyncio.sleep(settings.inter_message_delay)
async def _send_via_enter(self, input_loc: Locator) -> bool:
"""Try sending by pressing Enter."""
await input_loc.press("Enter")
await asyncio.sleep(0.5)
return True
async def _send_via_button(self) -> bool:
"""Try sending by clicking the send button."""
btn = await self._find_send_button()
if btn:
await btn.click()
await asyncio.sleep(0.5)
return True
return False
async def _is_input_empty(self, input_loc: Locator, selector: str) -> bool:
"""Check if the input element is empty (message was sent)."""
try:
if "textarea" in selector:
val = await input_loc.input_value()
else:
val = await input_loc.inner_text()
return not val.strip()
except Exception:
return True
async def send_message(self, text: str) -> str:
"""Send a message to the Doubao chat.
Returns the previous response text (for change detection).
"""
result = await self._find_input()
if not result:
logger.error("Input element not found on page")
raise RuntimeError("无法找到输入框,请通过VNC检查页面状态")
input_loc, selector = result
old_response = await self.page.evaluate(GET_RESPONSE_TEXT_JS) or ""
await self._type_message(input_loc, text)
sent = await self._send_via_enter(input_loc)
if sent:
await asyncio.sleep(0.8)
if not await self._is_input_empty(input_loc, selector):
logger.info("Enter didn't send, trying send button")
sent = await self._send_via_button()
if not sent:
logger.error("Failed to send message")
raise RuntimeError("消息发送失败,请通过VNC检查页面状态")
return old_response
async def _wait_for_response_start(
self, old_text: str, timeout: int = 30
) -> Optional[str]:
"""Wait for the response to start appearing.
Uses two-phase detection:
Phase 1: wait for user message to appear (text changes from old_text)
Phase 2: wait for AI response to start (text changes from baseline)
Returns baseline text (after user message, before AI response),
or None on timeout.
"""
start = time.time()
phase = 1
baseline = None
while time.time() - start < timeout:
current = await self.page.evaluate(GET_RESPONSE_TEXT_JS) or ""
if phase == 1:
if current and current != old_text:
baseline = current
phase = 2
await asyncio.sleep(0.5)
elif phase == 2:
if current != baseline:
logger.debug("Response started")
return baseline
await asyncio.sleep(0.3)
if baseline is not None:
logger.warning("Response may have started during baseline capture")
return baseline
logger.warning("Response did not start within timeout")
return None
async def stream_response(self, old_text: str = "") -> AsyncGenerator[str, None]:
"""Yield response text chunks as they appear.
Args:
old_text: Previous response text from before the message was sent.
Used to detect when the new response starts.
"""
baseline = await self._wait_for_response_start(old_text)
if baseline is None:
return
last_text = baseline
stable_count = 0
STABLE_THRESHOLD = 6
POLL_INTERVAL = 0.15
start_time = time.time()
while True:
current_text = await self.page.evaluate(GET_RESPONSE_TEXT_JS) or ""
if current_text and len(current_text) > len(last_text):
new_chunk = current_text[len(last_text):]
last_text = current_text
stable_count = 0
if new_chunk.strip():
yield new_chunk
elif current_text and current_text == last_text:
stable_count += 1
if stable_count >= STABLE_THRESHOLD:
is_gen = await self.page.evaluate(IS_GENERATING_JS)
if not is_gen:
logger.debug("Response complete (stable + not generating)")
break
elapsed = time.time() - start_time
if elapsed > settings.response_timeout:
logger.warning(f"Response timeout after {elapsed:.1f}s")
break
await asyncio.sleep(POLL_INTERVAL)
async def get_response_full(self, old_text: str = "") -> str:
"""Get the complete response text (non-streaming)."""
chunks = []
async for chunk in self.stream_response(old_text):
chunks.append(chunk)
return "".join(chunks)
async def inspect(self) -> dict:
"""Return DOM structure info for debugging."""
return await self.page.evaluate(INSPECT_DOM_JS)
@staticmethod
def messages_to_prompt(messages: List[ChatMessage]) -> str:
"""Convert OpenAI messages array to a single text prompt."""
if len(messages) == 1 and messages[0].role == "user":
return messages[0].content
parts: List[str] = []
for msg in messages:
if msg.role == "system":
parts.append(f"【系统设定】\n{msg.content}")
elif msg.role == "user":
parts.append(f"【用户】\n{msg.content}")
elif msg.role == "assistant":
parts.append(f"【AI回复】\n{msg.content}")
return "\n\n---\n\n".join(parts)
+346
View File
@@ -0,0 +1,346 @@
import asyncio
import json
import os
import subprocess
import time
import uuid
from contextlib import asynccontextmanager
from pathlib import Path
from typing import AsyncGenerator, Optional
from fastapi import Depends, FastAPI, Header, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, StreamingResponse
from loguru import logger
from .browser_manager import browser_manager
from .config import settings
from .doubao import DoubaoChat
from .models import (
ChatCompletionChunk,
ChatCompletionRequest,
ChatCompletionResponse,
Choice,
ChoiceMessage,
ChunkChoice,
ChunkDelta,
ModelInfo,
ModelListResponse,
SUPPORTED_MODELS,
)
DEFAULT_API_KEY = "sk-your-api-key-here"
VNC_MODE_FILE = Path("/app/data/.vnc_mode")
def _read_vnc_mode() -> str:
if VNC_MODE_FILE.exists():
mode = VNC_MODE_FILE.read_text().strip()
return "viewonly" if mode in ("true", "viewonly") else "interactive"
return "viewonly" if settings.vnc_view_only else "interactive"
def _write_vnc_mode(mode: str):
VNC_MODE_FILE.parent.mkdir(parents=True, exist_ok=True)
VNC_MODE_FILE.write_text(mode)
def _restart_x11vnc():
try:
subprocess.run(
["supervisorctl", "restart", "x11vnc"],
capture_output=True, text=True, timeout=10,
)
except Exception as e:
logger.warning(f"supervisorctl restart failed: {e}")
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("DouBao2Api starting up...")
try:
await browser_manager.start()
logged_in = await browser_manager.check_login()
if not logged_in:
logger.warning(
"Not logged in! Open noVNC at "
f"http://localhost:{settings.novnc_port}/vnc.html to login."
)
else:
logger.info("Login detected, ready to serve requests.")
except Exception as e:
logger.error(f"Browser startup failed: {e}")
yield
logger.info("DouBao2Api shutting down...")
await browser_manager.stop()
app = FastAPI(title="DouBao2Api", version="1.0.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
_request_lock = asyncio.Lock()
async def verify_api_key(authorization: Optional[str] = Header(None)):
if not settings.api_key or settings.api_key == DEFAULT_API_KEY:
return
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing Authorization header. Expected: Bearer <key>")
key = authorization[7:]
if key != settings.api_key:
raise HTTPException(status_code=401, detail="Invalid API key")
@app.get("/")
async def root():
return {
"service": "DouBao2Api",
"version": "1.0.0",
"browser_ready": browser_manager.ready,
"endpoints": {
"chat_completions": "POST /v1/chat/completions",
"models": "GET /v1/models",
"new_chat": "POST /chat/new",
"login_status": "GET /login/status",
"save_state": "POST /login/save",
"inspect": "GET /inspect",
"vnc_mode": "GET/POST /vnc/mode",
"restart_browser": "POST /browser/restart",
},
"vnc_url": f"http://localhost:{settings.novnc_port}/vnc.html",
"vnc_mode": _read_vnc_mode(),
}
@app.get("/health")
async def health():
return {"status": "ok", "browser_ready": browser_manager.ready}
@app.get("/v1/models")
async def list_models():
return ModelListResponse(data=[ModelInfo(**m) for m in SUPPORTED_MODELS])
@app.get("/login/status")
async def login_status():
logged_in = await browser_manager.check_login()
return {
"logged_in": logged_in,
"vnc_url": f"http://localhost:{settings.novnc_port}/vnc.html",
"instructions": (
"Open the VNC URL in your browser, login to Doubao manually, "
"then call POST /login/save to persist the session."
)
if not logged_in
else "Session is active.",
}
@app.post("/login/save")
async def save_state():
await browser_manager.save_state()
return {"status": "saved", "message": "Browser state persisted to disk."}
@app.get("/inspect")
async def inspect_dom():
page = await browser_manager.get_page()
doubao = DoubaoChat(page)
result = await doubao.inspect()
return result
@app.post("/chat/new")
async def new_chat(_: bool = Depends(verify_api_key)):
page = await browser_manager.get_page()
doubao = DoubaoChat(page)
ok = await doubao.start_new_chat()
return {"status": "new_chat_started" if ok else "failed", "ok": ok}
@app.get("/vnc/mode")
async def get_vnc_mode():
mode = _read_vnc_mode()
return {
"mode": mode,
"vnc_url": f"http://localhost:{settings.novnc_port}/vnc.html",
"hint": "POST /vnc/mode with {'mode':'interactive'} to enable mouse/keyboard" if mode == "viewonly" else "VNC is interactive",
}
@app.post("/vnc/mode")
async def set_vnc_mode(request: Request, _: bool = Depends(verify_api_key)):
body = await request.json()
mode = body.get("mode", "").lower()
if mode not in ("viewonly", "interactive"):
raise HTTPException(status_code=400, detail="mode must be 'viewonly' or 'interactive'")
_write_vnc_mode(mode)
_restart_x11vnc()
logger.info(f"VNC mode set to {mode}, x11vnc restarted")
return {"status": "ok", "mode": mode}
@app.post("/browser/restart")
async def restart_browser(_: bool = Depends(verify_api_key)):
await browser_manager.restart()
return {"status": "restarted", "browser_ready": browser_manager.ready}
@app.post("/v1/chat/completions")
async def chat_completions(
request: ChatCompletionRequest,
_=Depends(verify_api_key),
):
async with _request_lock:
if not browser_manager.ready:
try:
await browser_manager.start()
except Exception as e:
raise HTTPException(
status_code=503,
detail=f"Browser not ready: {e}. Please retry shortly.",
)
if not await browser_manager.check_login():
raise HTTPException(
status_code=401,
detail=(
"Not logged in to Doubao. Open "
f"http://localhost:{settings.novnc_port}/vnc.html "
"to login manually, then call POST /login/save."
),
)
page = await browser_manager.get_page()
doubao = DoubaoChat(page)
if request.new_chat:
await doubao.start_new_chat()
logger.info("Started new chat session")
prompt = DoubaoChat.messages_to_prompt(request.messages)
logger.info(f"Sending prompt ({len(prompt)} chars), stream={request.stream}")
try:
old_text = await doubao.send_message(prompt)
except RuntimeError as e:
raise HTTPException(status_code=500, detail=str(e))
except Exception as e:
logger.error(f"Unexpected send error: {e}")
raise HTTPException(status_code=500, detail=f"Failed to send message: {e}")
if request.stream:
return StreamingResponse(
_stream_sse(doubao, request.model, old_text),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
try:
full_response = await doubao.get_response_full(old_text)
except Exception as e:
logger.error(f"Response capture failed: {e}")
raise HTTPException(status_code=502, detail=f"Failed to get response: {e}")
await browser_manager.save_state()
return ChatCompletionResponse(
model=request.model,
choices=[
Choice(
index=0,
message=ChoiceMessage(role="assistant", content=full_response),
finish_reason="stop",
)
],
)
async def _stream_sse(
doubao: DoubaoChat,
model: str,
old_text: str,
) -> AsyncGenerator[str, None]:
chat_id = f"chatcmpl-{uuid.uuid4().hex[:24]}"
created = int(time.time())
first_chunk = ChatCompletionChunk(
id=chat_id,
created=created,
model=model,
choices=[ChunkChoice(index=0, delta=ChunkDelta(role="assistant"))],
)
yield f"data: {first_chunk.model_dump_json()}\n\n"
try:
async for chunk_text in doubao.stream_response(old_text):
if not chunk_text:
continue
chunk = ChatCompletionChunk(
id=chat_id,
created=created,
model=model,
choices=[
ChunkChoice(index=0, delta=ChunkDelta(content=chunk_text))
],
)
yield f"data: {chunk.model_dump_json()}\n\n"
except Exception as e:
logger.error(f"Streaming error: {e}")
err_chunk = ChatCompletionChunk(
id=chat_id,
created=created,
model=model,
choices=[
ChunkChoice(
index=0,
delta=ChunkDelta(content=f"\n[Error: {e}]"),
finish_reason="stop",
)
],
)
yield f"data: {err_chunk.model_dump_json()}\n\n"
else:
final_chunk = ChatCompletionChunk(
id=chat_id,
created=created,
model=model,
choices=[
ChunkChoice(index=0, delta=ChunkDelta(), finish_reason="stop")
],
)
yield f"data: {final_chunk.model_dump_json()}\n\n"
yield "data: [DONE]\n\n"
try:
await browser_manager.save_state()
except Exception:
pass
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"app.main:app",
host="0.0.0.0",
port=settings.api_port,
log_level=settings.log_level.lower(),
)
+87
View File
@@ -0,0 +1,87 @@
import time
import uuid
from typing import Any, Dict, List, Literal, Optional
from pydantic import BaseModel, Field
class ChatMessage(BaseModel):
role: Literal["system", "user", "assistant", "tool"]
content: str
class ChatCompletionRequest(BaseModel):
model: str = "doubao-pro"
messages: List[ChatMessage]
temperature: Optional[float] = 0.7
max_tokens: Optional[int] = None
stream: Optional[bool] = False
top_p: Optional[float] = 1.0
frequency_penalty: Optional[float] = 0.0
presence_penalty: Optional[float] = 0.0
stop: Optional[Any] = None
user: Optional[str] = None
new_chat: Optional[bool] = False
class CompletionUsage(BaseModel):
prompt_tokens: int = 0
completion_tokens: int = 0
total_tokens: int = 0
class ChoiceMessage(ChatMessage):
pass
class Choice(BaseModel):
index: int = 0
message: ChoiceMessage
finish_reason: str = "stop"
class ChunkDelta(BaseModel):
role: Optional[str] = None
content: Optional[str] = None
class ChunkChoice(BaseModel):
index: int = 0
delta: ChunkDelta = ChunkDelta()
finish_reason: Optional[str] = None
class ChatCompletionResponse(BaseModel):
id: str = Field(default_factory=lambda: f"chatcmpl-{uuid.uuid4().hex[:24]}")
object: str = "chat.completion"
created: int = Field(default_factory=lambda: int(time.time()))
model: str = "doubao-pro"
choices: List[Choice]
usage: CompletionUsage = CompletionUsage()
class ChatCompletionChunk(BaseModel):
id: str
object: str = "chat.completion.chunk"
created: int = Field(default_factory=lambda: int(time.time()))
model: str = "doubao-pro"
choices: List[ChunkChoice]
class ModelInfo(BaseModel):
id: str
object: str = "model"
created: int = Field(default_factory=lambda: int(time.time()))
owned_by: str = "doubao"
class ModelListResponse(BaseModel):
object: str = "list"
data: List[ModelInfo]
SUPPORTED_MODELS = [
{"id": "doubao-pro", "owned_by": "doubao"},
{"id": "doubao-lite", "owned_by": "doubao"},
{"id": "doubao-pro-32k", "owned_by": "doubao"},
]