Files
periscope/backend/services/llm/deepseek_provider.py
T
Cursor Agent 48246f31bd Adapt Pinscope to DeepSeek, auto datasheets, and a shared library.
Based on manvalan/pinscope main. Default LLM is DeepSeek with local
skills and PDF ingest. Datasheets are fetched from LCSC/TI, stored in
the component library, and review extracts abs-max with a deeper
checklist. Adds scripts/update-pinscope.sh for the production host.
2026-08-27 23:06:23 +00:00

336 lines
10 KiB
Python

"""DeepSeek provider — OpenAI-compatible Chat Completions.
Translates the unified ``Message`` / ``Completion`` shapes into DeepSeek's
OpenAI-style chat format. DeepSeek does not accept native PDF documents, so
``PdfBlock`` is converted to extracted text (and page images when the
session model is a vision model). Thinking-mode ``reasoning_content`` is
round-tripped on subsequent turns.
Extraction skills run locally via :mod:`backend.services.llm.local_skill`
(DeepSeek has no Anthropic Console Skills equivalent).
"""
from __future__ import annotations
import json
import logging
from typing import Any
from openai import AsyncOpenAI
from backend.config import settings
from backend.services.llm.base import LLMProvider, LLMSession
from backend.services.llm.local_skill import run_skill_locally
from backend.services.llm.pdf_ingest import pdf_to_openai_content
from backend.services.llm.types import (
Completion,
ContentBlock,
Message,
PdfBlock,
TextBlock,
ToolCall,
ToolChoice,
ToolResultBlock,
ToolSchema,
Usage,
)
log = logging.getLogger(__name__)
_VISION_HINT = "vision"
def _is_vision_model(model: str) -> bool:
return _VISION_HINT in model.lower()
def _to_openai_tool(t: ToolSchema) -> dict:
return {
"type": "function",
"function": {
"name": t.name,
"description": t.description,
"parameters": t.input_schema,
},
}
def _to_openai_tool_choice(c: ToolChoice) -> dict | str:
if c == "auto":
return "auto"
if c == "none":
return "none"
if isinstance(c, dict) and "name" in c:
return {"type": "function", "function": {"name": c["name"]}}
raise ValueError(f"Invalid tool_choice: {c!r}")
def _reasoning_from_blocks(blocks: list[ContentBlock]) -> str | None:
for b in blocks:
rc = getattr(b, "reasoning_content", None)
if rc:
return rc
return None
def _pdf_parts(path, *, vision: bool) -> list[dict]:
return pdf_to_openai_content(
path,
vision=vision,
max_chars=settings.deepseek_pdf_max_chars,
max_images=settings.deepseek_pdf_image_pages,
)
def _user_content_parts(blocks: list[ContentBlock], *, vision: bool) -> list[dict]:
"""Flatten user-side blocks (text / pdf) into OpenAI content parts."""
parts: list[dict] = []
for b in blocks:
if isinstance(b, TextBlock):
parts.append({"type": "text", "text": b.text})
elif isinstance(b, PdfBlock):
parts.extend(_pdf_parts(b.path, vision=vision))
else:
raise TypeError(
f"Unexpected block in user content: {type(b).__name__}"
)
return parts
def messages_to_openai(messages: list[Message], *, vision: bool) -> list[dict]:
"""Convert unified messages into DeepSeek/OpenAI chat messages.
Tool results become ``role=tool`` messages (OpenAI does not mix
``tool_result`` with documents in one user turn). Any PdfBlocks that
accompanied tool results are emitted as a following user message.
"""
out: list[dict] = []
for m in messages:
if m.role == "assistant":
text_parts = [b.text for b in m.content if isinstance(b, TextBlock)]
tool_calls = [b for b in m.content if isinstance(b, ToolCall)]
msg: dict[str, Any] = {"role": "assistant"}
text = "".join(text_parts)
msg["content"] = text if text else None
if tool_calls:
msg["tool_calls"] = [
{
"id": tc.id,
"type": "function",
"function": {
"name": tc.name,
"arguments": json.dumps(tc.input),
},
}
for tc in tool_calls
]
reasoning = _reasoning_from_blocks(m.content)
if reasoning:
msg["reasoning_content"] = reasoning
out.append(msg)
continue
# user
tool_results = [b for b in m.content if isinstance(b, ToolResultBlock)]
other = [b for b in m.content if not isinstance(b, ToolResultBlock)]
for tr in tool_results:
out.append({
"role": "tool",
"tool_call_id": tr.tool_use_id,
"content": tr.content,
})
if other:
parts = _user_content_parts(other, vision=vision)
if len(parts) == 1 and parts[0].get("type") == "text":
out.append({"role": "user", "content": parts[0]["text"]})
else:
out.append({"role": "user", "content": parts})
elif not tool_results:
out.append({"role": "user", "content": ""})
return out
def _parse_tool_arguments(raw: str | None) -> dict:
if not raw:
return {}
try:
data = json.loads(raw)
except json.JSONDecodeError:
log.warning("DeepSeek tool arguments were not valid JSON: %s", raw[:200])
return {}
return data if isinstance(data, dict) else {}
def _cache_hit_tokens(usage: Any) -> int:
hit = getattr(usage, "prompt_cache_hit_tokens", None)
if hit:
return int(hit)
details = getattr(usage, "prompt_tokens_details", None)
if details is not None:
cached = getattr(details, "cached_tokens", None)
if cached:
return int(cached)
return 0
def completion_from_openai(resp: Any) -> Completion:
choice = resp.choices[0]
msg = choice.message
text = msg.content or ""
reasoning = getattr(msg, "reasoning_content", None) or None
tool_calls: list[ToolCall] = []
raw_blocks: list[ContentBlock] = []
if text or reasoning:
raw_blocks.append(TextBlock(text=text or "", reasoning_content=reasoning))
for i, tc in enumerate(msg.tool_calls or []):
fn = tc.function
parsed = _parse_tool_arguments(getattr(fn, "arguments", None))
call = ToolCall(
id=tc.id or f"{fn.name}_{i}",
name=fn.name,
input=parsed,
reasoning_content=reasoning if i == 0 and not text else None,
)
tool_calls.append(call)
raw_blocks.append(call)
usage_md = getattr(resp, "usage", None)
if usage_md is not None:
prompt = int(usage_md.prompt_tokens or 0)
cached = _cache_hit_tokens(usage_md)
usage = Usage(
input_tokens=max(0, prompt - cached),
output_tokens=int(usage_md.completion_tokens or 0),
cache_creation_tokens=0,
cache_read_tokens=cached,
)
else:
usage = Usage()
stop = choice.finish_reason or "unknown"
return Completion(
text=text,
tool_calls=tool_calls,
usage=usage,
stop_reason=str(stop),
raw_assistant_blocks=raw_blocks,
)
class DeepSeekSession(LLMSession):
provider_name = "deepseek"
def __init__(
self,
*,
client: AsyncOpenAI,
model: str,
system: str,
max_tokens: int,
temperature: float | None = None,
thinking: bool = True,
reasoning_effort: str = "medium",
) -> None:
self._client = client
self.model = model
self._system = system
self._max_tokens = max_tokens
self._temperature = temperature
self._thinking = thinking
self._reasoning_effort = reasoning_effort
self._vision = _is_vision_model(model)
async def complete(
self,
*,
messages: list[Message],
tools: list[ToolSchema] | None = None,
tool_choice: ToolChoice = "auto",
) -> Completion:
oai_messages: list[dict] = [
{"role": "system", "content": self._system},
]
oai_messages.extend(messages_to_openai(messages, vision=self._vision))
extra_body: dict[str, Any] = {
"thinking": {"type": "enabled" if self._thinking else "disabled"},
}
if self._thinking:
extra_body["reasoning_effort"] = self._reasoning_effort
kwargs: dict[str, Any] = {
"model": self.model,
"messages": oai_messages,
"max_tokens": self._max_tokens,
"extra_body": extra_body,
}
if self._temperature is not None:
kwargs["temperature"] = self._temperature
if tools:
kwargs["tools"] = [_to_openai_tool(t) for t in tools]
kwargs["tool_choice"] = _to_openai_tool_choice(tool_choice)
resp = await self._client.chat.completions.create(**kwargs)
return completion_from_openai(resp)
async def close(self) -> None:
return None
class DeepSeekProvider(LLMProvider):
name = "deepseek"
def __init__(self) -> None:
api_key = settings.deepseek_api_key
if not api_key:
raise RuntimeError(
"DEEPSEEK_API_KEY is not set. Copy backend/.env.example to "
"backend/.env and add a key from https://platform.deepseek.com/"
)
self._client = AsyncOpenAI(
api_key=api_key,
base_url=settings.deepseek_base_url,
)
async def create_session(
self,
*,
model: str,
system: str,
max_tokens: int = 4096,
temperature: float | None = None,
) -> LLMSession:
thinking = settings.deepseek_thinking.strip().lower() != "disabled"
effort = settings.deepseek_reasoning_effort
if max_tokens >= 16000:
effort = "high"
return DeepSeekSession(
client=self._client,
model=model,
system=system,
max_tokens=max_tokens,
temperature=temperature,
thinking=thinking,
reasoning_effort=effort,
)
async def run_skill(
self,
*,
skill_name: str,
model: str,
system: str,
user_text: str,
pdf_path: str | None,
output_tool: ToolSchema,
) -> tuple[dict, Completion]:
return await run_skill_locally(
self,
skill_name=skill_name,
model=model,
system=system,
user_text=user_text,
pdf_path=pdf_path,
output_tool=output_tool,
)