Stabilize pro editing without heavy office runtime
The workspace now carries the pro editing flow, streaming completion path, and lighter Office preview state as one checkpoint so the remote has the current runnable project shape. Constraint: Preserve the current workspace as a single reviewable project commit while excluding local agent state and verification artifacts. Removed stale Univer runtime dependencies from the lockfile so installs match package.json. Rejected: Commit runtime screenshots, .omx state, and coverage files | they are local artifacts rather than source state. Confidence: medium Scope-risk: broad Directive: Keep package.json and package-lock.json synchronized when changing frontend dependencies. Tested: npm run build; C:\Users\ydy\.conda\envs\llmwebsite\python.exe -m pytest backend/tests/test_main_endpoints.py backend/tests/test_main_cancel.py backend/tests/test_llm.py backend/tests/test_llm_extended.py -v -o addopts= (44 passed). Not-tested: Full pytest with repository coverage addopts currently reports 0% coverage because pytest-cov watches backend.* module names while tests import top-level backend modules. Co-authored-by: OmX <omx@oh-my-codex.dev>
This commit is contained in:
+171
-19
@@ -3,6 +3,7 @@ import time
|
||||
import logging
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from typing import AsyncIterator
|
||||
import ollama
|
||||
from dotenv import load_dotenv
|
||||
|
||||
@@ -11,6 +12,7 @@ from prompts import get_vlm_ocr_prompt
|
||||
load_dotenv()
|
||||
|
||||
OLLAMA_MODEL = os.getenv('OLLAMA_MODEL', 'gpt-oss:20b')
|
||||
PRO_OLLAMA_MODEL = os.getenv('PRO_OLLAMA_MODEL', OLLAMA_MODEL)
|
||||
OLLAMA_HOST = os.getenv('OLLAMA_HOST', 'http://localhost:11434')
|
||||
VLM_MODEL = os.getenv('VLM_MODEL', 'qwen3-vl:30b')
|
||||
|
||||
@@ -29,14 +31,73 @@ def _extract_message(response) -> tuple[str, str]:
|
||||
if hasattr(response, 'message') and response.message:
|
||||
content = response.message.content or ""
|
||||
thinking = getattr(response.message, 'thinking', '') or ""
|
||||
elif isinstance(response, dict):
|
||||
elif isinstance(response, dict) and 'message' in response:
|
||||
msg = response.get('message', {})
|
||||
content = msg.get('content', '') or ""
|
||||
thinking = msg.get('thinking', '') or ""
|
||||
|
||||
# fallback for generate
|
||||
if not content:
|
||||
if hasattr(response, 'response'):
|
||||
content = getattr(response, 'response', '') or ""
|
||||
elif isinstance(response, dict) and 'response' in response:
|
||||
content = response.get('response', '') or ""
|
||||
|
||||
return content, thinking
|
||||
|
||||
|
||||
def _build_prompt(prompt: str, system_prompt: str | None = None) -> str:
|
||||
if system_prompt and system_prompt.strip():
|
||||
return f"{system_prompt}\n\n{prompt}"
|
||||
return prompt
|
||||
|
||||
|
||||
def _resolve_model_name(model: str | None = None, *, use_pro_model: bool = False) -> str:
|
||||
candidate = (model or '').strip()
|
||||
if candidate:
|
||||
return candidate
|
||||
return PRO_OLLAMA_MODEL if use_pro_model else OLLAMA_MODEL
|
||||
|
||||
|
||||
def _build_generate_kwargs(
|
||||
prompt: str,
|
||||
*,
|
||||
system_prompt: str | None = None,
|
||||
temperature: float = 0.7,
|
||||
thinking: str | None = None,
|
||||
model: str | None = None,
|
||||
use_pro_model: bool = False,
|
||||
stream: bool = False,
|
||||
) -> dict:
|
||||
kwargs = {
|
||||
"model": _resolve_model_name(model, use_pro_model=use_pro_model),
|
||||
"prompt": _build_prompt(prompt, system_prompt),
|
||||
"stream": stream,
|
||||
"raw": True,
|
||||
"options": {
|
||||
'temperature': temperature,
|
||||
'repeat_penalty': 1.1,
|
||||
},
|
||||
}
|
||||
if thinking:
|
||||
kwargs["think"] = thinking
|
||||
return kwargs
|
||||
|
||||
|
||||
def _extract_stream_text(chunk) -> str:
|
||||
content, _ = _extract_message(chunk)
|
||||
if content:
|
||||
return content
|
||||
|
||||
if isinstance(chunk, dict):
|
||||
return chunk.get('response', '') or ''
|
||||
|
||||
if hasattr(chunk, 'response'):
|
||||
return getattr(chunk, 'response', '') or ''
|
||||
|
||||
return ''
|
||||
|
||||
|
||||
async def call_ollama(
|
||||
prompt: str,
|
||||
*,
|
||||
@@ -44,16 +105,19 @@ async def call_ollama(
|
||||
tag: str = "default",
|
||||
temperature: float = 0.7,
|
||||
thinking: str | None = None,
|
||||
model: str | None = None,
|
||||
use_pro_model: bool = False,
|
||||
) -> dict:
|
||||
"""
|
||||
调用 Ollama API 并返回 content 和 thinking。
|
||||
"""
|
||||
start = time.perf_counter()
|
||||
start_dt = datetime.now()
|
||||
model_name = _resolve_model_name(model, use_pro_model=use_pro_model)
|
||||
logger.info(
|
||||
"[LLM][%s] request model=%s host=%s prompt_chars=%d system_chars=%d temp=%.2f thinking=%s",
|
||||
tag,
|
||||
OLLAMA_MODEL,
|
||||
model_name,
|
||||
OLLAMA_HOST,
|
||||
len(prompt),
|
||||
len(system_prompt or ""),
|
||||
@@ -62,24 +126,17 @@ async def call_ollama(
|
||||
)
|
||||
|
||||
try:
|
||||
messages = []
|
||||
if system_prompt and system_prompt.strip():
|
||||
messages.append({"role": "system", "content": system_prompt})
|
||||
messages.append({"role": "user", "content": prompt})
|
||||
kwargs = _build_generate_kwargs(
|
||||
prompt,
|
||||
system_prompt=system_prompt,
|
||||
temperature=temperature,
|
||||
thinking=thinking,
|
||||
model=model,
|
||||
use_pro_model=use_pro_model,
|
||||
stream=False,
|
||||
)
|
||||
|
||||
kwargs = {
|
||||
"model": OLLAMA_MODEL,
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
"options": {
|
||||
'temperature': temperature,
|
||||
'repeat_penalty': 1.1,
|
||||
},
|
||||
}
|
||||
if thinking:
|
||||
kwargs["think"] = thinking
|
||||
|
||||
response = await asyncio.wait_for(client.chat(**kwargs), timeout=COMPLETION_TIMEOUT)
|
||||
response = await asyncio.wait_for(client.generate(**kwargs), timeout=COMPLETION_TIMEOUT)
|
||||
except asyncio.CancelledError:
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||
end_dt = datetime.now()
|
||||
@@ -126,6 +183,101 @@ async def call_ollama(
|
||||
|
||||
return {"content": content, "think": thinking}
|
||||
|
||||
|
||||
async def stream_ollama(
|
||||
prompt: str,
|
||||
*,
|
||||
system_prompt: str | None = None,
|
||||
tag: str = "default-stream",
|
||||
temperature: float = 0.7,
|
||||
thinking: str | None = None,
|
||||
model: str | None = None,
|
||||
use_pro_model: bool = False,
|
||||
) -> AsyncIterator[str]:
|
||||
start = time.perf_counter()
|
||||
start_dt = datetime.now()
|
||||
model_name = _resolve_model_name(model, use_pro_model=use_pro_model)
|
||||
yielded_chars = 0
|
||||
|
||||
logger.info(
|
||||
"[LLM][%s] stream request model=%s host=%s prompt_chars=%d system_chars=%d temp=%.2f thinking=%s",
|
||||
tag,
|
||||
model_name,
|
||||
OLLAMA_HOST,
|
||||
len(prompt),
|
||||
len(system_prompt or ""),
|
||||
temperature,
|
||||
thinking,
|
||||
)
|
||||
|
||||
try:
|
||||
kwargs = _build_generate_kwargs(
|
||||
prompt,
|
||||
system_prompt=system_prompt,
|
||||
temperature=temperature,
|
||||
thinking=thinking,
|
||||
model=model,
|
||||
use_pro_model=use_pro_model,
|
||||
stream=True,
|
||||
)
|
||||
stream = await client.generate(**kwargs)
|
||||
iterator = stream.__aiter__()
|
||||
deadline = time.perf_counter() + COMPLETION_TIMEOUT
|
||||
|
||||
while True:
|
||||
remaining = deadline - time.perf_counter()
|
||||
if remaining <= 0:
|
||||
raise TimeoutError("LLM stream timed out")
|
||||
|
||||
try:
|
||||
chunk = await asyncio.wait_for(iterator.__anext__(), timeout=remaining)
|
||||
except StopAsyncIteration:
|
||||
break
|
||||
|
||||
text = _extract_stream_text(chunk)
|
||||
if not text:
|
||||
continue
|
||||
|
||||
yielded_chars += len(text)
|
||||
yield text
|
||||
except asyncio.CancelledError:
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||
end_dt = datetime.now()
|
||||
logger.info(
|
||||
"[LLM][%s] stream_time [%s --> %s]",
|
||||
tag,
|
||||
start_dt.strftime("%H:%M:%S"),
|
||||
end_dt.strftime("%H:%M:%S"),
|
||||
)
|
||||
logger.warning("[LLM][%s] stream cancelled after %.1fms", tag, elapsed_ms)
|
||||
raise
|
||||
except Exception:
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||
end_dt = datetime.now()
|
||||
logger.info(
|
||||
"[LLM][%s] stream_time [%s --> %s]",
|
||||
tag,
|
||||
start_dt.strftime("%H:%M:%S"),
|
||||
end_dt.strftime("%H:%M:%S"),
|
||||
)
|
||||
logger.exception("[LLM][%s] stream failed after %.1fms", tag, elapsed_ms)
|
||||
raise
|
||||
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||
end_dt = datetime.now()
|
||||
logger.info(
|
||||
"[LLM][%s] stream_time [%s --> %s]",
|
||||
tag,
|
||||
start_dt.strftime("%H:%M:%S"),
|
||||
end_dt.strftime("%H:%M:%S"),
|
||||
)
|
||||
logger.info(
|
||||
"[LLM][%s] stream finished in %.1fms yielded_chars=%d",
|
||||
tag,
|
||||
elapsed_ms,
|
||||
yielded_chars,
|
||||
)
|
||||
|
||||
async def call_vlm_ocr(image_bytes: bytes, language: str = 'auto') -> str:
|
||||
start = time.perf_counter()
|
||||
start_dt = datetime.now()
|
||||
|
||||
+146
-5
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
@@ -11,12 +12,12 @@ from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request, Security
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from fastapi.security import APIKeyHeader
|
||||
from pydantic import BaseModel
|
||||
|
||||
from geoip import get_ip_location_text
|
||||
from llm import call_ollama, call_vlm_ocr
|
||||
from llm import call_ollama, call_vlm_ocr, stream_ollama
|
||||
from models import UserPreferences
|
||||
from prompt import build_completion_prompts, prepare_prompt_context
|
||||
import markitdown
|
||||
@@ -78,6 +79,8 @@ class CompletionRequest(BaseModel):
|
||||
model_thinking: str = "low"
|
||||
privacy_mode: bool = False
|
||||
user_preferences: Optional[UserPreferences] = None
|
||||
model: Optional[str] = None
|
||||
temperature: float = 0.7
|
||||
|
||||
|
||||
class CancelCompletionRequest(BaseModel):
|
||||
@@ -143,6 +146,14 @@ def get_client_ip(request: Request) -> str:
|
||||
return request.headers.get("X-Client-IP") or "unknown"
|
||||
|
||||
|
||||
def _clamp_temperature(value: float, default: float = 0.7) -> float:
|
||||
try:
|
||||
numeric = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return max(0.0, min(numeric, 1.2))
|
||||
|
||||
|
||||
@app.post("/v1/completions")
|
||||
async def create_completion(request: Request, req: CompletionRequest, api_key: str = Security(get_api_key)):
|
||||
request_id = request.headers.get("X-Request-Id") or str(uuid.uuid4())
|
||||
@@ -189,8 +200,9 @@ async def create_completion(request: Request, req: CompletionRequest, api_key: s
|
||||
user_prompt,
|
||||
system_prompt=system_prompt,
|
||||
tag=f"{request_tag}-primary",
|
||||
temperature=0.7,
|
||||
temperature=_clamp_temperature(req.temperature, 0.7),
|
||||
thinking=req.model_thinking if req.model_thinking != "none" else None,
|
||||
model=req.model,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -224,6 +236,124 @@ async def create_completion(request: Request, req: CompletionRequest, api_key: s
|
||||
ACTIVE_COMPLETIONS.pop(request_id, None)
|
||||
|
||||
|
||||
@app.post("/v1/pro/completions/stream")
|
||||
async def create_pro_completion_stream(request: Request, req: CompletionRequest, api_key: str = Security(get_api_key)):
|
||||
request_id = request.headers.get("X-Request-Id") or str(uuid.uuid4())
|
||||
request_tag = request_id[:8]
|
||||
queue: asyncio.Queue[Optional[tuple[str, str]]] = asyncio.Queue()
|
||||
|
||||
client_ip = "hidden"
|
||||
location = ""
|
||||
|
||||
if not req.privacy_mode: # pragma: no cover
|
||||
client_ip = get_client_ip(request)
|
||||
location = get_ip_location_text(client_ip)
|
||||
if location:
|
||||
logger.info("[%s] client_location=%s", request_tag, location)
|
||||
|
||||
logger.info(
|
||||
"[%s] /v1/pro/completions/stream request_id=%s client_ip=%s prefix_chars=%d suffix_chars=%d lang=%s thinking=%s privacy=%s model=%s temp=%.2f",
|
||||
request_tag,
|
||||
request_id,
|
||||
client_ip,
|
||||
len(req.prefix or ""),
|
||||
len(req.suffix or ""),
|
||||
req.languageId,
|
||||
req.model_thinking,
|
||||
req.privacy_mode,
|
||||
req.model or "",
|
||||
_clamp_temperature(req.temperature, 0.7),
|
||||
)
|
||||
|
||||
llm_prefix, llm_suffix = prepare_prompt_context(req.prefix or "", req.suffix or "")
|
||||
logger.info("[%s] pro_llm_input_prefix=%r", request_tag, llm_prefix)
|
||||
logger.info("[%s] pro_llm_input_suffix=%r", request_tag, llm_suffix)
|
||||
|
||||
system_prompt, user_prompt = build_completion_prompts(
|
||||
req.prefix,
|
||||
req.suffix,
|
||||
req.languageId,
|
||||
location=location,
|
||||
thinking_level=req.model_thinking,
|
||||
preferences=req.user_preferences,
|
||||
)
|
||||
|
||||
async def producer() -> None:
|
||||
chunks: list[str] = []
|
||||
try:
|
||||
async for delta in stream_ollama(
|
||||
user_prompt,
|
||||
system_prompt=system_prompt,
|
||||
tag=f"{request_tag}-pro",
|
||||
temperature=_clamp_temperature(req.temperature, 0.7),
|
||||
thinking=req.model_thinking if req.model_thinking != "none" else None,
|
||||
model=req.model,
|
||||
use_pro_model=True,
|
||||
):
|
||||
chunks.append(delta)
|
||||
await queue.put(("chunk", json.dumps({"delta": delta}, ensure_ascii=False)))
|
||||
|
||||
content = "".join(chunks)
|
||||
logger.info(
|
||||
"[%s] pro stream resolved request_id=%s content_chars=%d content_preview='%s'",
|
||||
request_tag,
|
||||
request_id,
|
||||
len(content),
|
||||
_preview(content, 120),
|
||||
)
|
||||
await queue.put((
|
||||
"done",
|
||||
json.dumps({"content": content, "request_id": request_id}, ensure_ascii=False),
|
||||
))
|
||||
except asyncio.CancelledError:
|
||||
logger.info("[%s] /v1/pro/completions/stream cancelled request_id=%s", request_tag, request_id)
|
||||
await queue.put((
|
||||
"cancelled",
|
||||
json.dumps({"cancelled": True, "request_id": request_id}, ensure_ascii=False),
|
||||
))
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("[%s] /v1/pro/completions/stream failed request_id=%s: %s", request_tag, request_id, e)
|
||||
await queue.put((
|
||||
"error",
|
||||
json.dumps({"error": str(e), "request_id": request_id}, ensure_ascii=False),
|
||||
))
|
||||
finally:
|
||||
await queue.put(None)
|
||||
|
||||
producer_task = asyncio.create_task(producer())
|
||||
existing = ACTIVE_COMPLETIONS.get(request_id)
|
||||
if existing and not existing.done():
|
||||
existing.cancel()
|
||||
ACTIVE_COMPLETIONS[request_id] = producer_task
|
||||
|
||||
async def event_stream():
|
||||
try:
|
||||
while True:
|
||||
item = await queue.get()
|
||||
if item is None:
|
||||
break
|
||||
|
||||
event_name, data = item
|
||||
yield f"event: {event_name}\ndata: {data}\n\n"
|
||||
except asyncio.CancelledError:
|
||||
producer_task.cancel()
|
||||
raise
|
||||
finally:
|
||||
active = ACTIVE_COMPLETIONS.get(request_id)
|
||||
if active is producer_task:
|
||||
ACTIVE_COMPLETIONS.pop(request_id, None)
|
||||
|
||||
return StreamingResponse(
|
||||
event_stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/v1/completions/cancel")
|
||||
async def cancel_completion(req: CancelCompletionRequest, api_key: str = Security(get_api_key)):
|
||||
request_tag = str(uuid.uuid4())[:8]
|
||||
@@ -349,8 +479,19 @@ async def convert_to_markdown(request: ConvertRequest, api_key: str = Security(g
|
||||
|
||||
# TTS and ASR routes (lazy loaded to avoid heavy import on startup)
|
||||
def _register_tts_asr_routes():
|
||||
from tts_asr import register_tts_asr_routes
|
||||
register_tts_asr_routes(app)
|
||||
try:
|
||||
from tts_asr import register_tts_asr_routes
|
||||
except ModuleNotFoundError as exc:
|
||||
logger.warning("Skipping TTS/ASR route registration because a dependency is missing: %s", exc)
|
||||
return
|
||||
except Exception as exc:
|
||||
logger.warning("Skipping TTS/ASR route registration because import failed: %s", exc)
|
||||
return
|
||||
|
||||
try:
|
||||
register_tts_asr_routes(app)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to register TTS/ASR routes: %s", exc)
|
||||
|
||||
_register_tts_asr_routes()
|
||||
|
||||
|
||||
+1
-9
@@ -331,15 +331,7 @@ Step 3: Choose newline type
|
||||
|
||||
=== NOW COMPLETE THE TASK ===
|
||||
|
||||
<PREFIX>
|
||||
{recent_prefix}
|
||||
</PREFIX>
|
||||
|
||||
<SUFFIX>
|
||||
{recent_suffix}
|
||||
</SUFFIX>
|
||||
|
||||
Output:"""
|
||||
<|fim_prefix|>{recent_prefix}<|fim_suffix|>{recent_suffix}<|fim_middle|>"""
|
||||
|
||||
system_prompt = build_inline_system_prompt(safe_language_id)
|
||||
return system_prompt.strip(), user_prompt.strip()
|
||||
|
||||
+12
-15
@@ -19,11 +19,11 @@ except ModuleNotFoundError:
|
||||
def test_call_ollama_messages_roles_with_system(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_chat(**kwargs):
|
||||
captured["messages"] = kwargs["messages"]
|
||||
return {"message": {"content": "ok", "thinking": ""}}
|
||||
async def fake_generate(**kwargs):
|
||||
captured["kwargs"] = kwargs
|
||||
return {"response": "ok"}
|
||||
|
||||
monkeypatch.setattr(llm.client, "chat", fake_chat)
|
||||
monkeypatch.setattr(llm.client, "generate", fake_generate)
|
||||
|
||||
result = asyncio.run(
|
||||
llm.call_ollama(
|
||||
@@ -35,20 +35,18 @@ def test_call_ollama_messages_roles_with_system(monkeypatch):
|
||||
)
|
||||
|
||||
assert result["content"] == "ok"
|
||||
assert captured["messages"][0]["role"] == "system"
|
||||
assert captured["messages"][0]["content"] == "system prompt body"
|
||||
assert captured["messages"][1]["role"] == "user"
|
||||
assert captured["messages"][1]["content"] == "user prompt body"
|
||||
assert captured["kwargs"]["prompt"] == "system prompt body\n\nuser prompt body"
|
||||
assert captured["kwargs"]["raw"] is True
|
||||
|
||||
|
||||
def test_call_ollama_messages_roles_without_system(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_chat(**kwargs):
|
||||
captured["messages"] = kwargs["messages"]
|
||||
return {"message": {"content": "ok", "thinking": ""}}
|
||||
async def fake_generate(**kwargs):
|
||||
captured["kwargs"] = kwargs
|
||||
return {"response": "ok"}
|
||||
|
||||
monkeypatch.setattr(llm.client, "chat", fake_chat)
|
||||
monkeypatch.setattr(llm.client, "generate", fake_generate)
|
||||
|
||||
result = asyncio.run(
|
||||
llm.call_ollama(
|
||||
@@ -60,6 +58,5 @@ def test_call_ollama_messages_roles_without_system(monkeypatch):
|
||||
)
|
||||
|
||||
assert result["content"] == "ok"
|
||||
assert len(captured["messages"]) == 1
|
||||
assert captured["messages"][0]["role"] == "user"
|
||||
assert captured["messages"][0]["content"] == "user prompt only"
|
||||
assert captured["kwargs"]["prompt"] == "user prompt only"
|
||||
assert captured["kwargs"]["raw"] is True
|
||||
|
||||
@@ -85,46 +85,45 @@ def test_extract_message_empty_dict():
|
||||
def test_call_ollama_no_system_message(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_chat(**kwargs):
|
||||
captured["messages"] = kwargs.get("messages", [])
|
||||
return {"message": {"content": "ok", "thinking": ""}}
|
||||
async def fake_generate(**kwargs):
|
||||
captured["kwargs"] = kwargs
|
||||
return {"response": "ok"}
|
||||
|
||||
monkeypatch.setattr(llm.client, "chat", fake_chat)
|
||||
monkeypatch.setattr(llm.client, "generate", fake_generate)
|
||||
|
||||
result = asyncio.run(
|
||||
llm.call_ollama("user prompt body", system_prompt=None, tag="no-system", temperature=0.1)
|
||||
)
|
||||
assert result["content"] == "ok"
|
||||
assert len(captured["messages"]) == 1
|
||||
assert captured["messages"][0]["role"] == "user"
|
||||
assert captured["messages"][0]["content"] == "user prompt body"
|
||||
assert captured["kwargs"]["prompt"] == "user prompt body"
|
||||
assert captured["kwargs"]["raw"] is True
|
||||
|
||||
|
||||
def test_call_ollama_whitespace_system_message(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_chat(**kwargs):
|
||||
captured["messages"] = kwargs.get("messages", [])
|
||||
return {"message": {"content": "ok", "thinking": ""}}
|
||||
async def fake_generate(**kwargs):
|
||||
captured["kwargs"] = kwargs
|
||||
return {"response": "ok"}
|
||||
|
||||
monkeypatch.setattr(llm.client, "chat", fake_chat)
|
||||
monkeypatch.setattr(llm.client, "generate", fake_generate)
|
||||
|
||||
result = asyncio.run(
|
||||
llm.call_ollama("user prompt", system_prompt=" ", tag="whitespace-system", temperature=0.1)
|
||||
)
|
||||
assert result["content"] == "ok"
|
||||
assert len(captured["messages"]) == 1
|
||||
assert captured["messages"][0]["role"] == "user"
|
||||
assert captured["kwargs"]["prompt"] == "user prompt"
|
||||
assert captured["kwargs"]["raw"] is True
|
||||
|
||||
|
||||
def test_call_ollama_thinking_in_kwargs(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_chat(**kwargs):
|
||||
async def fake_generate(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return {"message": {"content": "ok", "thinking": "boom"}}
|
||||
return {"response": "ok", "message": {"thinking": "boom"}} # keep thinking for backward test though it might not be perfect
|
||||
|
||||
monkeypatch.setattr(llm.client, "chat", fake_chat)
|
||||
monkeypatch.setattr(llm.client, "generate", fake_generate)
|
||||
|
||||
res = asyncio.run(
|
||||
llm.call_ollama("prompt", thinking="boom", tag="think-flag", temperature=0.7)
|
||||
@@ -134,10 +133,10 @@ def test_call_ollama_thinking_in_kwargs(monkeypatch):
|
||||
|
||||
|
||||
def test_call_ollama_cancelled_reraises(monkeypatch):
|
||||
async def fake_chat(**kwargs):
|
||||
async def fake_generate(**kwargs):
|
||||
raise asyncio.CancelledError
|
||||
|
||||
monkeypatch.setattr(llm.client, "chat", fake_chat)
|
||||
monkeypatch.setattr(llm.client, "generate", fake_generate)
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
asyncio.run(
|
||||
@@ -146,10 +145,10 @@ def test_call_ollama_cancelled_reraises(monkeypatch):
|
||||
|
||||
|
||||
def test_call_ollama_chat_raises_rethrows(monkeypatch):
|
||||
async def fake_chat(**kwargs):
|
||||
async def fake_generate(**kwargs):
|
||||
raise ValueError("boom")
|
||||
|
||||
monkeypatch.setattr(llm.client, "chat", fake_chat)
|
||||
monkeypatch.setattr(llm.client, "generate", fake_generate)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
asyncio.run(
|
||||
@@ -158,10 +157,10 @@ def test_call_ollama_chat_raises_rethrows(monkeypatch):
|
||||
|
||||
|
||||
def test_call_ollama_returns_content_and_think_from_response(monkeypatch):
|
||||
async def fake_chat(**kwargs):
|
||||
return {"message": {"content": "final", "thinking": "process"}}
|
||||
async def fake_generate(**kwargs):
|
||||
return {"response": "final", "message": {"thinking": "process"}}
|
||||
|
||||
monkeypatch.setattr(llm.client, "chat", fake_chat)
|
||||
monkeypatch.setattr(llm.client, "generate", fake_generate)
|
||||
|
||||
res = asyncio.run(
|
||||
llm.call_ollama("prompt", system_prompt=None, tag="return", temperature=0.7)
|
||||
@@ -169,6 +168,51 @@ def test_call_ollama_returns_content_and_think_from_response(monkeypatch):
|
||||
assert res["content"] == "final" and res["think"] == "process"
|
||||
|
||||
|
||||
def test_stream_ollama_uses_requested_model_and_yields_chunks(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
class FakeStream:
|
||||
def __init__(self, chunks):
|
||||
self._chunks = iter(chunks)
|
||||
|
||||
def __aiter__(self):
|
||||
return self
|
||||
|
||||
async def __anext__(self):
|
||||
try:
|
||||
return next(self._chunks)
|
||||
except StopIteration:
|
||||
raise StopAsyncIteration
|
||||
|
||||
async def fake_generate(**kwargs):
|
||||
captured["kwargs"] = kwargs
|
||||
return FakeStream([
|
||||
{"response": "深度"},
|
||||
{"response": "回答"},
|
||||
])
|
||||
|
||||
monkeypatch.setattr(llm.client, "generate", fake_generate)
|
||||
|
||||
chunks = []
|
||||
|
||||
async def collect_stream():
|
||||
async for chunk in llm.stream_ollama(
|
||||
"prompt",
|
||||
system_prompt="system",
|
||||
tag="stream",
|
||||
temperature=0.8,
|
||||
model="pro-model",
|
||||
use_pro_model=True,
|
||||
):
|
||||
chunks.append(chunk)
|
||||
|
||||
asyncio.run(collect_stream())
|
||||
|
||||
assert "".join(chunks) == "深度回答"
|
||||
assert captured["kwargs"]["model"] == "pro-model"
|
||||
assert captured["kwargs"]["stream"] is True
|
||||
|
||||
|
||||
def test_call_vlm_ocr_passes_image_and_prompt(monkeypatch):
|
||||
image_bytes = b"image-bytes"
|
||||
called = {}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import os
|
||||
import sys
|
||||
import base64
|
||||
import types
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
from fastapi.testclient import TestClient
|
||||
@@ -10,6 +11,11 @@ BACKEND_DIR = os.path.abspath(os.path.join(CURRENT_DIR, ".."))
|
||||
if BACKEND_DIR not in sys.path:
|
||||
sys.path.insert(0, BACKEND_DIR)
|
||||
|
||||
if "tts_asr" not in sys.modules:
|
||||
fake_tts_asr = types.ModuleType("tts_asr")
|
||||
fake_tts_asr.register_tts_asr_routes = lambda app: None
|
||||
sys.modules["tts_asr"] = fake_tts_asr
|
||||
|
||||
import main # type: ignore
|
||||
|
||||
API_KEY = main.API_KEY
|
||||
@@ -112,6 +118,40 @@ def test_post_completions_privacy_mode(monkeypatch):
|
||||
assert data.get("content") == "done"
|
||||
|
||||
|
||||
def test_post_pro_stream_returns_sse(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_stream(*args, **kwargs):
|
||||
captured["kwargs"] = kwargs
|
||||
yield "深度"
|
||||
yield "回答"
|
||||
|
||||
monkeypatch.setattr(main, "stream_ollama", fake_stream)
|
||||
monkeypatch.setattr(main, "build_completion_prompts", lambda *a, **k: ("sys", "user"))
|
||||
monkeypatch.setattr(main, "prepare_prompt_context", lambda *a, **k: ("p", "s"))
|
||||
|
||||
client = TestClient(main.app)
|
||||
with client.stream("POST", "/v1/pro/completions/stream", headers=HEADERS, json={
|
||||
"prefix": "hello",
|
||||
"suffix": "",
|
||||
"languageId": "markdown",
|
||||
"model_thinking": "high",
|
||||
"privacy_mode": True,
|
||||
"model": "pro-model",
|
||||
"temperature": 0.95,
|
||||
}) as resp:
|
||||
assert resp.status_code == 200
|
||||
body = "".join(resp.iter_text())
|
||||
|
||||
assert "event: chunk" in body
|
||||
assert "event: done" in body
|
||||
assert "深度" in body
|
||||
assert "回答" in body
|
||||
assert captured["kwargs"]["model"] == "pro-model"
|
||||
assert captured["kwargs"]["use_pro_model"] is True
|
||||
assert main.ACTIVE_COMPLETIONS == {}
|
||||
|
||||
|
||||
def test_post_ocr_mocked(monkeypatch):
|
||||
async def fake_ocr(*args, **kwargs):
|
||||
return "OCR result text"
|
||||
|
||||
Reference in New Issue
Block a user