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.
This commit is contained in:
Cursor Agent
2026-08-27 23:06:23 +00:00
parent ab1c5b081c
commit 48246f31bd
53 changed files with 3005 additions and 314 deletions
+3 -3
View File
@@ -1,9 +1,9 @@
"""Provider-agnostic LLM client layer.
All Claude API calls in the backend route through this package via the
``LLMProvider`` interface. The default provider is Anthropic; per-stage
All model calls in the backend route through this package via the
``LLMProvider`` interface. The default provider is DeepSeek; per-stage
overrides via ``Settings.provider_*`` env vars route specific stages to
other providers (currently Anthropic + Gemini).
Anthropic or Gemini if those keys are configured.
"""
from backend.services.llm.factory import call_with_fallback, get_provider
@@ -274,6 +274,18 @@ class AnthropicProvider(LLMProvider):
except Exception:
skill_id, version = None, None
if not skill_id:
from backend.services.llm.local_skill import run_skill_locally
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,
)
# Build initial user content
user_content: list[dict] = []
if pdf_path:
+3 -5
View File
@@ -92,9 +92,7 @@ class LLMProvider(Protocol):
) -> tuple[dict, "Completion"]:
"""Execute a managed Skill and return (forced-tool input, Completion).
Anthropic uses Console Skills (skill_id + container + code_execution
beta). Gemini raises ``NotImplementedError`` — there is no
Gemini-managed-Skill equivalent today; if you want a Gemini path for
skill-style extraction, inline the SKILL.md content as ``system`` and
run validation locally."""
DeepSeek and Gemini inline ``skills/<name>/SKILL.md`` and run
``validate.py`` locally. Anthropic uses Console Skills when a
skill_id is configured, otherwise the same local path."""
...
+335
View File
@@ -0,0 +1,335 @@
"""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,
)
+7 -3
View File
@@ -15,10 +15,14 @@ log = logging.getLogger(__name__)
T = TypeVar("T")
@lru_cache(maxsize=4)
@lru_cache(maxsize=8)
def get_provider_by_name(name: str) -> LLMProvider:
"""Return a singleton provider instance for ``name`` ("anthropic" |
"gemini"). Used by :func:`get_provider` and :func:`call_with_fallback`."""
"""Return a singleton provider instance for ``name`` ("deepseek" |
"anthropic" | "gemini"). Used by :func:`get_provider` and
:func:`call_with_fallback`."""
if name == "deepseek":
from backend.services.llm.deepseek_provider import DeepSeekProvider
return DeepSeekProvider()
if name == "anthropic":
from backend.services.llm.anthropic_provider import AnthropicProvider
return AnthropicProvider()
+9 -5
View File
@@ -371,9 +371,13 @@ class GeminiProvider(LLMProvider):
pdf_path: str | None,
output_tool: ToolSchema,
) -> tuple[dict, Completion]:
raise NotImplementedError(
f"GeminiProvider.run_skill() not implemented (skill={skill_name!r}). "
f"Anthropic Console Skills have no Gemini equivalent. To migrate "
f"this skill to Gemini, inline its SKILL.md as the system prompt "
f"and run validate.py locally."
from backend.services.llm.local_skill import run_skill_locally
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,
)
+191
View File
@@ -0,0 +1,191 @@
"""Provider-agnostic local skill runner.
Anthropic Console Skills have no equivalent on DeepSeek (or Gemini). This
module inlines ``skills/<name>/SKILL.md`` as the system prompt, drives a
normal tool-calling session, and runs ``validate.py`` locally after each
``output_tool`` call. Used by DeepSeek and Gemini; Anthropic falls back
here when no Console skill id is configured.
"""
from __future__ import annotations
import importlib.util
import logging
import re
import time
from pathlib import Path
from backend.config import settings
from backend.services.llm.base import LLMProvider
from backend.services.llm.types import (
Completion,
Message,
PdfBlock,
TextBlock,
ToolResultBlock,
ToolSchema,
Usage,
)
log = logging.getLogger(__name__)
_SKILL_MAX_TURNS = 10
_FRONTMATTER = re.compile(r"^---\n.*?\n---\n", re.DOTALL)
_LOCAL_SKILL_TAIL = """
You cannot run shell commands or Python. Do not try to execute validate.py.
After extracting the data, call the `{tool}` tool with the structured result.
The server validates the payload. If validation fails you will receive the
errors and must call `{tool}` again with a corrected payload.
Do NOT write files to disk.
"""
def skills_dir() -> Path:
return Path(settings.skills_dir)
def load_skill_markdown(skill_name: str) -> str:
path = skills_dir() / skill_name / "SKILL.md"
if not path.is_file():
raise FileNotFoundError(
f"Skill {skill_name!r} not found at {path}. "
f"Expected skills/{skill_name}/SKILL.md in the repo."
)
raw = path.read_text(encoding="utf-8")
return _FRONTMATTER.sub("", raw).strip()
def load_skill_validator(skill_name: str):
"""Import ``skills/<name>/validate.py`` and return its ``validate`` fn."""
path = skills_dir() / skill_name / "validate.py"
if not path.is_file():
return None
spec = importlib.util.spec_from_file_location(
f"pinscope_skill_{skill_name.replace('-', '_')}_validate", path,
)
if spec is None or spec.loader is None:
return None
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
fn = getattr(mod, "validate", None)
return fn if callable(fn) else None
def _sum_usage(total: Usage, piece: Usage) -> Usage:
return Usage(
input_tokens=total.input_tokens + piece.input_tokens,
output_tokens=total.output_tokens + piece.output_tokens,
cache_creation_tokens=total.cache_creation_tokens + piece.cache_creation_tokens,
cache_read_tokens=total.cache_read_tokens + piece.cache_read_tokens,
)
async def run_skill_locally(
provider: LLMProvider,
*,
skill_name: str,
model: str,
system: str,
user_text: str,
pdf_path: str | None,
output_tool: ToolSchema,
max_turns: int = _SKILL_MAX_TURNS,
) -> tuple[dict, Completion]:
"""Run ``skill_name`` as an in-process tool loop on ``provider``."""
skill_md = load_skill_markdown(skill_name)
validator = load_skill_validator(skill_name)
full_system = (
skill_md
+ "\n\n"
+ system.strip()
+ "\n"
+ _LOCAL_SKILL_TAIL.format(tool=output_tool.name)
)
user_blocks: list = []
if pdf_path:
user_blocks.append(PdfBlock(path=Path(pdf_path), cacheable=True))
user_blocks.append(TextBlock(text=user_text, cacheable=True))
messages: list[Message] = [Message(role="user", content=user_blocks)]
total = Usage()
t0 = time.monotonic()
last_completion: Completion | None = None
session = await provider.create_session(
model=model, system=full_system, max_tokens=16384, temperature=0.0,
)
try:
for turn in range(max_turns):
force = turn >= max_turns - 2
completion = await session.complete(
messages=messages,
tools=[output_tool],
tool_choice={"name": output_tool.name} if force else "auto",
)
last_completion = completion
total = _sum_usage(total, completion.usage)
payload: dict | None = None
for tc in completion.tool_calls:
if tc.name == output_tool.name:
payload = dict(tc.input)
break
messages.append(Message(
role="assistant", content=completion.raw_assistant_blocks,
))
if payload is None:
messages.append(Message(role="user", content=[TextBlock(
text=(
f"You did not call {output_tool.name}. "
f"Call it now with the extracted data."
),
)]))
continue
errors: list[str] = []
if validator is not None:
try:
errors = list(validator(payload) or [])
except Exception as exc:
log.warning(
"Skill %s validate.py raised: %s", skill_name, exc,
)
errors = [f"validator crashed: {exc}"]
if not errors:
completion.usage = total
completion.turns = turn + 1 # type: ignore[attr-defined]
completion.duration_ms = int((time.monotonic() - t0) * 1000) # type: ignore[attr-defined]
return payload, completion
messages.append(Message(
role="user",
content=[
ToolResultBlock(
tool_use_id=completion.tool_calls[0].id,
name=output_tool.name,
content="VALIDATION FAILED:\n" + "\n".join(
f"- {e}" for e in errors
),
),
TextBlock(
text=(
"Fix the payload and call "
f"{output_tool.name} again."
),
),
],
))
finally:
await session.close()
raise RuntimeError(
f"Skill {skill_name!r} did not produce a valid {output_tool.name} "
f"in {max_turns} turns"
+ (f" (last stop_reason={last_completion.stop_reason})"
if last_completion else "")
)
+258
View File
@@ -0,0 +1,258 @@
"""Convert datasheet PDFs into text (and optional page images).
DeepSeek's Chat Completions API does not accept native PDF documents.
Anthropic/Gemini providers send the file bytes; DeepSeek instead extracts
text with PyMuPDF (pypdf fallback) and, on a vision model, renders the
pages that actually matter (pin tables, abs-max, electrical, application)
rather than always the first N pages.
"""
from __future__ import annotations
import base64
import io
import logging
import re
from pathlib import Path
log = logging.getLogger(__name__)
_DEFAULT_MAX_CHARS = 500_000
_DEFAULT_MAX_IMAGES = 32
_RENDER_ZOOM = 1.55
# Pages whose diagrams/tables the model must actually see.
_PAGE_KEYWORDS = re.compile(
r"pin\s*(out|diagram|configuration|description|assignment|function|name|table|map)"
r"|ball\s*map|package\s*(pin|drawing|outline)|signal\s+description"
r"|absolute\s+maximum|recommended\s+operating|electrical\s+characteristics"
r"|power\s+supply|thermal\s+(resistance|shutdown|pad)|ESD\s+(rating|tolerance)"
r"|decoupling|bypass\s+capacitor|typical\s+application"
r"|application\s+(circuit|schematic|information|note)|reference\s+design"
r"|ordering\s+information|device\s+information",
re.IGNORECASE,
)
def extract_pdf_text(path: Path | str, *, max_chars: int = _DEFAULT_MAX_CHARS) -> str:
"""Return datasheet text with page markers, truncated to ``max_chars``.
Prefers PyMuPDF (better on datasheet tables) and falls back to pypdf.
"""
pdf_path = Path(path)
blob = _extract_text_pymupdf(pdf_path)
if blob is None:
blob = _extract_text_pypdf(pdf_path)
if len(blob) > max_chars:
blob = blob[:max_chars] + "\n\n[truncated: remaining pages omitted]"
return blob
def _extract_text_pymupdf(pdf_path: Path) -> str | None:
try:
import fitz
except ImportError:
return None
try:
doc = fitz.open(str(pdf_path))
except Exception as exc:
log.warning("PyMuPDF failed to open %s: %s", pdf_path, exc)
return None
try:
parts: list[str] = [f"[PDF: {pdf_path.name}, {len(doc)} pages]"]
for i, page in enumerate(doc, start=1):
try:
text = page.get_text("text") or ""
except Exception:
text = ""
parts.append(f"--- page {i} ---\n{text.strip()}")
return "\n\n".join(parts)
finally:
doc.close()
def _extract_text_pypdf(pdf_path: Path) -> str:
from pypdf import PdfReader
try:
reader = PdfReader(str(pdf_path))
except Exception as exc:
log.warning("Failed to open PDF %s: %s", pdf_path, exc)
return f"[PDF {pdf_path.name}: unreadable ({exc})]"
parts: list[str] = [f"[PDF: {pdf_path.name}, {len(reader.pages)} pages]"]
for i, page in enumerate(reader.pages, start=1):
try:
text = page.extract_text() or ""
except Exception:
text = ""
parts.append(f"--- page {i} ---\n{text.strip()}")
return "\n\n".join(parts)
def relevant_page_indices(
path: Path | str,
*,
max_pages: int,
keywords: re.Pattern[str] = _PAGE_KEYWORDS,
) -> list[int]:
"""0-based page indices to send as images: front matter + keyword hits."""
pdf_path = Path(path)
try:
import fitz
doc = fitz.open(str(pdf_path))
except Exception:
return list(range(max_pages))
try:
total = len(doc)
if total <= max_pages:
return list(range(total))
hits: set[int] = set()
for i in range(total):
try:
text = doc[i].get_text("text") or ""
except Exception:
text = ""
if keywords.search(text):
for neighbor in (i - 1, i, i + 1):
if 0 <= neighbor < total:
hits.add(neighbor)
front = set(range(min(5, total)))
ranked_hits = sorted(hits)
if len(ranked_hits) >= max_pages:
keep_front = [i for i in ranked_hits if i < 5][:2]
rest = [i for i in ranked_hits if i not in keep_front]
need = max_pages - len(keep_front)
return sorted(keep_front + rest[-need:])
chosen = set(hits)
for i in sorted(front) + list(range(total)):
if len(chosen) >= max_pages:
break
chosen.add(i)
return sorted(chosen)
finally:
doc.close()
def render_pdf_page_jpegs(
path: Path | str,
*,
max_pages: int = _DEFAULT_MAX_IMAGES,
zoom: float = _RENDER_ZOOM,
page_indices: list[int] | None = None,
) -> list[tuple[int, bytes]]:
"""Render selected pages as JPEG bytes.
``page_indices`` is 0-based. When omitted, keyword-relevant pages are
chosen instead of always rendering the front of the PDF.
Returns a list of (1-based page number, jpeg bytes). Empty if PyMuPDF
is not installed or rendering fails — callers should still send text.
"""
try:
import fitz # PyMuPDF
except ImportError:
log.info("PyMuPDF not installed — DeepSeek vision page images skipped")
return []
pdf_path = Path(path)
if page_indices is None:
page_indices = relevant_page_indices(pdf_path, max_pages=max_pages)
out: list[tuple[int, bytes]] = []
try:
doc = fitz.open(str(pdf_path))
except Exception as exc:
log.warning("PyMuPDF failed to open %s: %s", pdf_path, exc)
return []
try:
matrix = fitz.Matrix(zoom, zoom)
for i in page_indices:
if i < 0 or i >= len(doc):
continue
page = doc[i]
pix = page.get_pixmap(matrix=matrix, alpha=False)
jpeg = pix.tobytes("jpeg")
out.append((i + 1, jpeg))
if len(out) >= max_pages:
break
except Exception as exc:
log.warning("PyMuPDF render failed for %s: %s", pdf_path, exc)
return out
finally:
doc.close()
return out
def jpeg_data_url(jpeg: bytes) -> str:
b64 = base64.standard_b64encode(jpeg).decode("ascii")
return f"data:image/jpeg;base64,{b64}"
def pdf_to_openai_content(
path: Path | str,
*,
vision: bool,
max_chars: int = _DEFAULT_MAX_CHARS,
max_images: int = _DEFAULT_MAX_IMAGES,
) -> list[dict]:
"""OpenAI-style content parts for one PDF: text, plus images if vision."""
text = extract_pdf_text(path, max_chars=max_chars)
parts: list[dict] = [{"type": "text", "text": text}]
if not vision:
return parts
images = render_pdf_page_jpegs(path, max_pages=max_images)
if not images:
return parts
parts.append({
"type": "text",
"text": (
f"The following {len(images)} image(s) are rendered pages of "
f"{Path(path).name} (pin tables, abs-max, electrical, and "
f"application sections preferred over the front matter). "
f"Use them for diagrams and tables that text extraction may have missed."
),
})
for page_no, jpeg in images:
parts.append({
"type": "text",
"text": f"[page {page_no} image]",
})
parts.append({
"type": "image_url",
"image_url": {"url": jpeg_data_url(jpeg), "detail": "high"},
})
return parts
def make_text_pdf(pages: list[str]) -> bytes:
"""Build a tiny text-only PDF for tests. Uses PyMuPDF when available,
otherwise a hand-rolled one-page PDF."""
try:
import fitz
doc = fitz.open()
for body in pages:
page = doc.new_page()
page.insert_text((72, 72), body, fontsize=11)
buf = io.BytesIO()
doc.save(buf)
doc.close()
return buf.getvalue()
except ImportError:
pass
# Minimal one-page PDF with the first page's text.
payload = (pages[0] if pages else "test").encode("latin-1", "replace")
stream = b"BT /F1 12 Tf 72 720 Td (" + payload.replace(b"(", b"[").replace(b")", b"]") + b") Tj ET"
return (
b"%PDF-1.1\n"
b"1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n"
b"2 0 obj<</Type/Pages/Count 1/Kids[3 0 R]>>endobj\n"
b"3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]"
b"/Contents 4 0 R/Resources<</Font<</F1 5 0 R>>>>>>endobj\n"
b"4 0 obj<</Length " + str(len(stream)).encode() + b">>stream\n"
+ stream + b"\nendstream\nendobj\n"
b"5 0 obj<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>endobj\n"
b"xref\n0 6\n0000000000 65535 f \n"
b"trailer<</Size 6/Root 1 0 R>>\nstartxref\n0\n%%EOF\n"
)
+14 -4
View File
@@ -8,10 +8,19 @@ from __future__ import annotations
# Per-million-token USD rates. Source-of-truth links:
# DeepSeek: https://api-docs.deepseek.com/quick_start/pricing
# Anthropic: https://docs.anthropic.com/en/docs/about-claude/pricing
# Google: https://ai.google.dev/pricing
# Last updated: 2026-07-01
# Last updated: 2026-08-27
PRICING: dict[str, dict[str, dict[str, float]]] = {
"deepseek": {
# Peak-hour rates (conservative). Off-peak is 50% of these.
# Cache-hit input is billed via CACHE_RATES["deepseek"]["read"].
"deepseek-v4-flash": {"input": 0.44, "output": 1.32},
"deepseek-v4-flash-vision-exp": {"input": 0.44, "output": 1.32},
"deepseek-v4-pro": {"input": 1.32, "output": 3.96},
"default": {"input": 0.44, "output": 1.32},
},
"anthropic": {
"claude-opus-4-6": {"input": 5.00, "output": 25.00},
"claude-opus-4-5": {"input": 5.00, "output": 25.00},
@@ -54,6 +63,7 @@ PRICING: dict[str, dict[str, dict[str, float]]] = {
# normal input pass)
# read: cost when a cached prefix is *reused* (much cheaper)
CACHE_RATES: dict[str, dict[str, float]] = {
"deepseek": {"create": 1.00, "read": 0.032},
"anthropic": {"create": 1.25, "read": 0.10},
"gemini": {"create": 1.00, "read": 0.25},
}
@@ -62,10 +72,10 @@ CACHE_RATES: dict[str, dict[str, float]] = {
def cost_for_entry(entry: dict) -> float:
"""USD cost for an api_logs entry. Reads ``provider`` (default
``anthropic`` for legacy entries) and ``model`` to pick rates."""
provider = entry.get("provider") or "anthropic"
table = PRICING.get(provider) or PRICING["anthropic"]
provider = entry.get("provider") or "deepseek"
table = PRICING.get(provider) or PRICING["deepseek"]
rates = table.get(entry.get("model", ""), table["default"])
cache_rates = CACHE_RATES.get(provider, CACHE_RATES["anthropic"])
cache_rates = CACHE_RATES.get(provider, CACHE_RATES["deepseek"])
input_rate = rates["input"]
output_rate = rates["output"]
return (
+8 -2
View File
@@ -26,12 +26,17 @@ class TextBlock:
# this turn is fed back into the conversation, or the next call 400s.
# Anthropic: always None.
thought_signature: bytes | None = None
# DeepSeek thinking-mode: assistant ``reasoning_content`` that must be
# replayed on the next turn or the API returns 400.
reasoning_content: str | None = None
@dataclass
class PdfBlock:
"""Inline PDF document. Provider encodes as base64 (Anthropic) or
inline_data (Gemini) and applies caching policy if cacheable=True."""
"""Inline PDF document. Anthropic encodes as base64, Gemini as
inline_data. DeepSeek does not accept PDFs natively — the provider
converts the file to extracted text (and page images on a vision
model) before sending."""
path: Path
cacheable: bool = False
@@ -45,6 +50,7 @@ class ToolCall:
# Same purpose as TextBlock.thought_signature — Gemini 3 attaches one
# to every function_call part when thinking is on. Round-trip required.
thought_signature: bytes | None = None
reasoning_content: str | None = None
@dataclass