Pinscope open-source core

Agentic schematic validation: datasheet extraction via Claude Console
Skills, netlist/BOM design graph, per-IC direct datasheet review with
page citations, capacitor derating, Next.js report UI.

Extracted from the Pinscope cloud codebase. Auth and billing live in the
private gateway repo behind stable seams (billing_hook.py, adapter files
listed in CLAUDE.md).
This commit is contained in:
Siddharth Kothari
2026-07-16 21:29:45 -07:00
commit 6672d2be57
254 changed files with 56662 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
"""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
overrides via ``Settings.provider_*`` env vars route specific stages to
other providers (currently Anthropic + Gemini).
"""
from backend.services.llm.factory import call_with_fallback, get_provider
from backend.services.llm.types import (
Completion,
ContentBlock,
Message,
PdfBlock,
TextBlock,
ToolCall,
ToolChoice,
ToolResultBlock,
ToolSchema,
Usage,
)
__all__ = [
"Completion",
"ContentBlock",
"Message",
"PdfBlock",
"TextBlock",
"ToolCall",
"ToolChoice",
"ToolResultBlock",
"ToolSchema",
"Usage",
"call_with_fallback",
"get_provider",
]
+363
View File
@@ -0,0 +1,363 @@
"""Anthropic provider — wraps AsyncAnthropic + Console Skills.
Translates the unified ``Message`` / ``Completion`` shapes into Anthropic's
native message-block format and back. Caching is per-block via
``cache_control: ephemeral``."""
from __future__ import annotations
import base64
import re
import time
from pathlib import Path
import anthropic
from backend.config import settings
from backend.services.llm.base import LLMProvider, LLMSession
from backend.services.llm.types import (
Completion,
ContentBlock,
Message,
PdfBlock,
TextBlock,
ToolCall,
ToolChoice,
ToolResultBlock,
ToolSchema,
Usage,
)
_SKILL_MAX_TURNS = 10
# Sampling params were removed on newer Claude models (Sonnet 5, Opus 4.7+,
# Fable/Mythos 5) — sending `temperature` returns 400 "`temperature` is
# deprecated for this model". Allowlist the families that still accept it so
# unknown/future models fail safe (omit → default sampling) instead of
# 400-ing every call in the session.
_TEMPERATURE_OK = re.compile(r"^claude-(3-|opus-4-[0-6]|sonnet-4-|haiku-)")
def _model_accepts_temperature(model: str) -> bool:
return bool(_TEMPERATURE_OK.match(model))
# ---------------------------------------------------------------------------
# Translation helpers — unified types ↔ Anthropic dicts
# ---------------------------------------------------------------------------
def _encode_pdf_block(path: Path | str, *, cache: bool) -> dict:
data = base64.standard_b64encode(Path(path).read_bytes()).decode()
block: dict = {
"type": "document",
"source": {"type": "base64", "media_type": "application/pdf", "data": data},
}
if cache:
block["cache_control"] = {"type": "ephemeral"}
return block
def _to_anthropic_block(b: ContentBlock) -> dict:
if isinstance(b, TextBlock):
d: dict = {"type": "text", "text": b.text}
if b.cacheable:
d["cache_control"] = {"type": "ephemeral"}
return d
if isinstance(b, PdfBlock):
return _encode_pdf_block(b.path, cache=b.cacheable)
if isinstance(b, ToolCall):
return {"type": "tool_use", "id": b.id, "name": b.name, "input": b.input}
if isinstance(b, ToolResultBlock):
return {
"type": "tool_result",
"tool_use_id": b.tool_use_id,
"content": b.content,
}
raise TypeError(f"Unknown ContentBlock: {type(b).__name__}")
def _to_anthropic_message(m: Message) -> dict:
return {"role": m.role, "content": [_to_anthropic_block(b) for b in m.content]}
# Anthropic allows at most 4 cache_control breakpoints per request. The system
# prompt always consumes one (see AnthropicSession.complete), leaving 3 for
# message content. A multi-turn review attaches a cacheable PDF for each
# get_datasheet_excerpt fetch (validation_tools.py), so a hub IC that verifies
# two interface excerpts produced 5 breakpoints — system + initial PDF + initial
# context + 2 excerpts — and the API rejected the request with
# "A maximum of 4 blocks with cache_control may be provided. Found 5."
#
# Cap the message-block breakpoints in the translated request, keeping the most
# valuable ones: the first cacheable block (the full-datasheet anchor — a stable,
# guaranteed cache hit every turn) plus the two most recent (incremental caching
# of the growing tail). Any caller-set cache_control beyond that is dropped.
_MAX_MESSAGE_CACHE_BREAKPOINTS = 3
def _enforce_cache_breakpoint_limit(messages: list[dict]) -> None:
"""Strip excess cache_control markers from message blocks in place so that
system(1) + message breakpoints never exceed Anthropic's per-request limit."""
marked: list[dict] = []
for m in messages:
content = m.get("content")
if not isinstance(content, list):
continue
for block in content:
if isinstance(block, dict) and "cache_control" in block:
marked.append(block)
if len(marked) <= _MAX_MESSAGE_CACHE_BREAKPOINTS:
return
keep = {id(marked[0]), id(marked[-1]), id(marked[-2])}
for block in marked:
if id(block) not in keep:
block.pop("cache_control", None)
def _to_anthropic_tool(t: ToolSchema) -> dict:
return {"name": t.name, "description": t.description, "input_schema": t.input_schema}
def _to_anthropic_tool_choice(c: ToolChoice) -> dict:
if c == "auto":
return {"type": "auto"}
if c == "none":
return {"type": "none"}
if isinstance(c, dict) and "name" in c:
return {"type": "tool", "name": c["name"]}
raise ValueError(f"Invalid tool_choice: {c!r}")
def _from_anthropic_response(resp) -> Completion:
"""Parse an Anthropic message response into a unified Completion."""
text_parts: list[str] = []
tool_calls: list[ToolCall] = []
raw_blocks: list[ContentBlock] = []
for block in resp.content:
btype = getattr(block, "type", None)
if btype == "text":
text_parts.append(block.text)
raw_blocks.append(TextBlock(text=block.text))
elif btype == "tool_use":
tc = ToolCall(id=block.id, name=block.name, input=dict(block.input))
tool_calls.append(tc)
raw_blocks.append(tc)
# Other block types (server tool calls etc.) are pass-through ignored
usage = Usage(
input_tokens=resp.usage.input_tokens,
output_tokens=resp.usage.output_tokens,
cache_creation_tokens=getattr(resp.usage, "cache_creation_input_tokens", 0) or 0,
cache_read_tokens=getattr(resp.usage, "cache_read_input_tokens", 0) or 0,
)
return Completion(
text="".join(text_parts),
tool_calls=tool_calls,
usage=usage,
stop_reason=resp.stop_reason or "unknown",
raw_assistant_blocks=raw_blocks,
)
# ---------------------------------------------------------------------------
# Session
# ---------------------------------------------------------------------------
class AnthropicSession(LLMSession):
provider_name = "anthropic"
def __init__(
self,
*,
client: anthropic.AsyncAnthropic,
model: str,
system: str,
max_tokens: int,
temperature: float | None = None,
) -> None:
self._client = client
self.model = model
self._system = system
self._max_tokens = max_tokens
self._temperature = temperature
async def complete(
self,
*,
messages: list[Message],
tools: list[ToolSchema] | None = None,
tool_choice: ToolChoice = "auto",
) -> Completion:
kwargs: dict = {
"model": self.model,
"max_tokens": self._max_tokens,
"system": [{
"type": "text",
"text": self._system,
"cache_control": {"type": "ephemeral"},
}],
"messages": [_to_anthropic_message(m) for m in messages],
}
_enforce_cache_breakpoint_limit(kwargs["messages"])
if self._temperature is not None and _model_accepts_temperature(self.model):
kwargs["temperature"] = self._temperature
if tools:
kwargs["tools"] = [_to_anthropic_tool(t) for t in tools]
kwargs["tool_choice"] = _to_anthropic_tool_choice(tool_choice)
# Streaming, not create(): SDK 0.83+ raises ValueError pre-flight on
# `messages.create` whenever max_tokens crosses ~21k for Sonnet
# (the "may take longer than 10 minutes" guard). Review uses 32k
# max_tokens for Gemini thinking headroom; streaming bypasses that
# client-side timeout cap. get_final_message() returns the same
# shape as create(), so _from_anthropic_response is reused as-is.
async with self._client.messages.stream(**kwargs) as stream:
resp = await stream.get_final_message()
return _from_anthropic_response(resp)
async def close(self) -> None:
# Anthropic ephemeral cache cleans up on its own (5-min TTL).
pass
# ---------------------------------------------------------------------------
# Provider
# ---------------------------------------------------------------------------
class AnthropicProvider(LLMProvider):
name = "anthropic"
def __init__(self) -> None:
self._client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
async def create_session(
self,
*,
model: str,
system: str,
max_tokens: int = 4096,
temperature: float | None = None,
) -> LLMSession:
return AnthropicSession(
client=self._client,
model=model,
system=system,
max_tokens=max_tokens,
temperature=temperature,
)
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]:
"""Anthropic Console Skills — multi-turn skill execution with the
``skills-2025-10-02`` + ``code-execution-2025-08-25`` betas.
Skill mounts in a per-call container; the model reads ``SKILL.md``,
runs ``validate.py`` server-side via code_execution, and voluntarily
calls ``output_tool`` once it has well-formed data.
"""
skill_id, version = settings.get_skill(skill_name)
# Build initial user content
user_content: list[dict] = []
if pdf_path:
user_content.append(_encode_pdf_block(pdf_path, cache=True))
user_content.append({"type": "text", "text": user_text})
messages: list[dict] = [{"role": "user", "content": user_content}]
container: dict = {
"skills": [{
"type": "custom",
"skill_id": skill_id,
"version": version,
}],
}
total_input = 0
total_output = 0
total_cache_creation = 0
total_cache_read = 0
t0 = time.monotonic()
last_resp = None
for turn in range(_SKILL_MAX_TURNS):
resp = await self._client.beta.messages.create(
model=model,
max_tokens=16384,
system=[{
"type": "text",
"text": system,
"cache_control": {"type": "ephemeral"},
}],
tools=[
{"type": "code_execution_20250825", "name": "code_execution"},
_to_anthropic_tool(output_tool),
],
container=container,
messages=messages,
betas=["skills-2025-10-02", "code-execution-2025-08-25"],
)
last_resp = resp
total_input += resp.usage.input_tokens
total_output += resp.usage.output_tokens
total_cache_creation += getattr(resp.usage, "cache_creation_input_tokens", 0) or 0
total_cache_read += getattr(resp.usage, "cache_read_input_tokens", 0) or 0
# Reuse container for subsequent turns
if hasattr(resp, "container") and resp.container:
container = {"id": resp.container.id}
for block in resp.content:
if (
getattr(block, "type", None) == "tool_use"
and block.name == output_tool.name
):
completion = Completion(
text="",
tool_calls=[ToolCall(id=block.id, name=block.name, input=dict(block.input))],
usage=Usage(
input_tokens=total_input,
output_tokens=total_output,
cache_creation_tokens=total_cache_creation,
cache_read_tokens=total_cache_read,
),
stop_reason=resp.stop_reason or "unknown",
)
# Stash turns count via attribute for callers that need it
completion.turns = turn + 1 # type: ignore[attr-defined]
completion.duration_ms = int((time.monotonic() - t0) * 1000) # type: ignore[attr-defined]
return dict(block.input), completion
messages.append({"role": "assistant", "content": resp.content})
if resp.stop_reason == "pause_turn":
continue
if resp.stop_reason == "end_turn":
messages.append({
"role": "user",
"content": f"Please call {output_tool.name} with the extracted data.",
})
continue
# tool_use from code_execution — let the loop continue
continue
raise RuntimeError(
f"Skill {skill_name!r} did not produce {output_tool.name} "
f"in {_SKILL_MAX_TURNS} turns"
)
+100
View File
@@ -0,0 +1,100 @@
"""Abstract LLMProvider + LLMSession interfaces."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Protocol
from backend.services.llm.types import (
Completion,
Message,
ToolChoice,
ToolSchema,
)
class LLMSession(ABC):
"""A multi-turn conversation with provider-specific cache lifecycle.
Lifecycle::
session = await provider.create_session(model=..., system=...)
try:
messages = [Message("user", [
PdfBlock(path, cacheable=True),
TextBlock(context, cacheable=True),
])]
for turn in range(N):
completion = await session.complete(
messages=messages, tools=..., tool_choice=...,
)
# process tool_calls, append to messages, repeat
finally:
await session.close()
Caching: blocks with ``cacheable=True`` participate in provider caching.
Anthropic stamps ``cache_control: ephemeral`` on each cacheable block on
every call. Gemini collects all cacheable blocks (plus the system prompt)
on the first ``complete()`` call into a ``CachedContent`` object and
references it on subsequent calls. The system prompt is always cached.
"""
provider_name: str
"""Provider identifier ("anthropic", "gemini") — used for api_logs."""
model: str
@abstractmethod
async def complete(
self,
*,
messages: list[Message],
tools: list[ToolSchema] | None = None,
tool_choice: ToolChoice = "auto",
) -> Completion:
"""Run one inference turn."""
@abstractmethod
async def close(self) -> None:
"""Release any provider-side resources (e.g. delete a cache object).
Safe to call multiple times."""
class LLMProvider(Protocol):
"""Top-level provider interface."""
name: str
async def create_session(
self,
*,
model: str,
system: str,
max_tokens: int = 4096,
temperature: float | None = None,
) -> LLMSession:
"""Construct a session. ``system`` is always cached by the session.
``temperature`` — if not None, applied to every ``complete()`` call on
this session. ``None`` means use the provider's default. Set to 0.0
for deterministic-as-possible behavior in agentic loops where the same
inputs should produce the same outputs."""
...
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"]:
"""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."""
...
+73
View File
@@ -0,0 +1,73 @@
"""Provider factory + per-stage routing."""
from __future__ import annotations
import asyncio
import logging
from functools import lru_cache
from typing import Awaitable, Callable, TypeVar
from backend.config import settings
from backend.services.llm.base import LLMProvider
log = logging.getLogger(__name__)
T = TypeVar("T")
@lru_cache(maxsize=4)
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`."""
if name == "anthropic":
from backend.services.llm.anthropic_provider import AnthropicProvider
return AnthropicProvider()
if name == "gemini":
from backend.services.llm.gemini_provider import GeminiProvider
return GeminiProvider()
raise ValueError(f"Unknown LLM provider: {name!r}")
# Backwards-compatible alias
_get_provider_by_name = get_provider_by_name
def get_provider(stage: str) -> LLMProvider:
"""Return the provider configured for ``stage``.
Falls back to ``settings.provider_default`` if no per-stage override.
Providers are cached per-name, so repeated calls return the same
instance (and share the underlying SDK client)."""
name = settings.provider_for_stage(stage)
return get_provider_by_name(name)
async def call_with_fallback(
stage: str,
body: Callable[[LLMProvider, str], Awaitable[T]],
) -> T:
"""Run ``body(provider, model)`` for ``stage``; on any exception,
retry once with the fallback provider/model if one is configured via
``FALLBACK_PROVIDER_<STAGE>`` / ``FALLBACK_MODEL_<STAGE>``.
The fallback runs ``body`` from scratch — any tokens spent in the
primary attempt are lost (and not logged). ``asyncio.CancelledError``
is always re-raised so cancellation still works.
"""
primary_provider = get_provider(stage)
primary_model = settings.model_for_stage(stage)
try:
return await body(primary_provider, primary_model)
except asyncio.CancelledError:
raise
except Exception as exc:
fb = settings.fallback_for_stage(stage)
if fb is None:
raise
log.warning(
"[%s] primary %s/%s failed (%s) — falling back to %s/%s",
stage, primary_provider.name, primary_model,
exc, fb[0], fb[1],
)
fallback_provider = get_provider_by_name(fb[0])
return await body(fallback_provider, fb[1])
+379
View File
@@ -0,0 +1,379 @@
"""Gemini provider — wraps google-genai async client.
Translates the unified ``Message`` / ``Completion`` shapes into Gemini's
native ``Content`` / ``Part`` format. Caching uses ``CachedContent``: on the
first ``complete()`` call, cacheable blocks (system + any block flagged
``cacheable=True`` in the first user message) are uploaded as a
``CachedContent`` with TTL=30min; subsequent calls reference the cache by
name. On ``close()`` the cache is deleted. If creation fails (e.g.
sub-threshold token count), the session falls back to inline content with no
caching for the remainder of the conversation.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
from google import genai
from google.genai import types as gtypes
from backend.config import settings
from backend.services.llm.base import LLMProvider, LLMSession
from backend.services.llm.types import (
Completion,
ContentBlock,
Message,
PdfBlock,
TextBlock,
ToolCall,
ToolChoice,
ToolResultBlock,
ToolSchema,
Usage,
)
log = logging.getLogger(__name__)
_CACHE_TTL = "1800s" # 30 min — covers our longest agent loop with margin
# ---------------------------------------------------------------------------
# Translation helpers — unified types ↔ Gemini Parts/Contents
# ---------------------------------------------------------------------------
def _block_to_part(b: ContentBlock) -> gtypes.Part:
if isinstance(b, TextBlock):
return gtypes.Part(
text=b.text,
thought_signature=b.thought_signature,
)
if isinstance(b, PdfBlock):
return gtypes.Part(
inline_data=gtypes.Blob(
mime_type="application/pdf",
data=Path(b.path).read_bytes(),
),
)
if isinstance(b, ToolCall):
return gtypes.Part(
function_call=gtypes.FunctionCall(
id=b.id or None,
name=b.name,
args=b.input,
),
thought_signature=b.thought_signature,
)
if isinstance(b, ToolResultBlock):
return gtypes.Part(
function_response=gtypes.FunctionResponse(
id=b.tool_use_id or None,
name=b.name,
# FunctionResponse.response is a dict — wrap string content
response={"result": b.content},
),
)
raise TypeError(f"Unknown ContentBlock: {type(b).__name__}")
def _message_to_content(m: Message) -> gtypes.Content:
# Gemini uses "user" and "model" (not "assistant")
role = "model" if m.role == "assistant" else "user"
return gtypes.Content(
role=role,
parts=[_block_to_part(b) for b in m.content],
)
def _tool_to_function_declaration(t: ToolSchema) -> gtypes.FunctionDeclaration:
return gtypes.FunctionDeclaration(
name=t.name,
description=t.description,
parameters_json_schema=t.input_schema,
)
def _tools_to_gemini(tools: list[ToolSchema]) -> list[gtypes.Tool]:
return [
gtypes.Tool(
function_declarations=[_tool_to_function_declaration(t) for t in tools],
),
]
def _tool_choice_to_config(c: ToolChoice) -> gtypes.ToolConfig:
if c == "auto":
return gtypes.ToolConfig(
function_calling_config=gtypes.FunctionCallingConfig(mode="AUTO"),
)
if c == "none":
return gtypes.ToolConfig(
function_calling_config=gtypes.FunctionCallingConfig(mode="NONE"),
)
if isinstance(c, dict) and "name" in c:
return gtypes.ToolConfig(
function_calling_config=gtypes.FunctionCallingConfig(
mode="ANY",
allowed_function_names=[c["name"]],
),
)
raise ValueError(f"Invalid tool_choice: {c!r}")
def _from_gemini_response(resp: Any) -> Completion:
"""Parse a Gemini GenerateContentResponse into a unified Completion."""
text_parts: list[str] = []
tool_calls: list[ToolCall] = []
raw_blocks: list[ContentBlock] = []
stop_reason = "unknown"
candidates = getattr(resp, "candidates", None) or []
if candidates:
cand = candidates[0]
finish = getattr(cand, "finish_reason", None)
if finish:
stop_reason = str(finish).lower().split(".")[-1]
content = getattr(cand, "content", None)
if content and content.parts:
for part in content.parts:
# Preserve thought_signature (Gemini 3 thinking-mode) for
# exact replay on subsequent turns; missing signatures cause
# 400 INVALID_ARGUMENT on the next call.
sig = getattr(part, "thought_signature", None)
if getattr(part, "text", None):
text_parts.append(part.text)
raw_blocks.append(TextBlock(
text=part.text, thought_signature=sig,
))
elif getattr(part, "function_call", None):
fc = part.function_call
tc = ToolCall(
id=fc.id or f"{fc.name}_{len(tool_calls)}",
name=fc.name,
input=dict(fc.args or {}),
thought_signature=sig,
)
tool_calls.append(tc)
raw_blocks.append(tc)
usage_md = getattr(resp, "usage_metadata", None)
if usage_md is not None:
prompt_tokens = usage_md.prompt_token_count or 0
cached_tokens = usage_md.cached_content_token_count or 0
# Gemini reports prompt_token_count as the TOTAL prompt tokens —
# cached tokens are billed at the cache-read rate, the rest at the
# input rate. Subtract so they don't double-count.
non_cached = max(0, prompt_tokens - cached_tokens)
# Thinking-mode models (2.5 Pro, 3 series) report reasoning tokens
# in thoughts_token_count, billed at the output rate. Fold into
# output_tokens so cost accounting matches Gemini's actual bill.
thoughts_tokens = getattr(usage_md, "thoughts_token_count", 0) or 0
usage = Usage(
input_tokens=non_cached,
output_tokens=(usage_md.candidates_token_count or 0) + thoughts_tokens,
cache_creation_tokens=0, # Gemini doesn't expose this separately
cache_read_tokens=cached_tokens,
)
else:
usage = Usage()
return Completion(
text="".join(text_parts),
tool_calls=tool_calls,
usage=usage,
stop_reason=stop_reason,
raw_assistant_blocks=raw_blocks,
)
def _is_first_user_message_fully_cacheable(messages: list[Message]) -> bool:
"""We cache only when EVERY block in the very first user message is
flagged cacheable. This matches our actual usage (validation + power
tree both pass entirely cacheable initial messages) and avoids brittle
partial-cache scenarios."""
if not messages:
return False
first = messages[0]
if first.role != "user" or not first.content:
return False
return all(
isinstance(b, (TextBlock, PdfBlock)) and b.cacheable
for b in first.content
)
# ---------------------------------------------------------------------------
# Session
# ---------------------------------------------------------------------------
class GeminiSession(LLMSession):
provider_name = "gemini"
def __init__(
self,
*,
client: genai.Client,
model: str,
system: str,
max_tokens: int,
temperature: float | None = None,
) -> None:
self._client = client
self.model = model
self._system = system
self._max_tokens = max_tokens
self._temperature = temperature
self._cache_name: str | None = None
self._cache_attempted = False
async def _try_create_cache(self, first_msg: Message) -> str | None:
"""Attempt to create a CachedContent from system + first user message.
Returns the cache name on success, None on failure."""
try:
parts = [_block_to_part(b) for b in first_msg.content]
cache = await self._client.aio.caches.create(
model=self.model,
config=gtypes.CreateCachedContentConfig(
system_instruction=self._system,
contents=[gtypes.Content(role="user", parts=parts)],
ttl=_CACHE_TTL,
),
)
log.info(
"Gemini cache created (%s, model=%s, ttl=%s)",
cache.name, self.model, _CACHE_TTL,
)
return cache.name
except Exception as exc:
log.info(
"Gemini cache creation skipped (%s) — falling back to inline",
exc,
)
return None
async def complete(
self,
*,
messages: list[Message],
tools: list[ToolSchema] | None = None,
tool_choice: ToolChoice = "auto",
) -> Completion:
if not messages:
raise ValueError("Gemini complete() requires at least one message")
# First call: decide whether to cache
if not self._cache_attempted:
self._cache_attempted = True
if _is_first_user_message_fully_cacheable(messages):
self._cache_name = await self._try_create_cache(messages[0])
# Build per-call contents
if self._cache_name:
# Skip the cached first message — its contents are in the cache
contents = [_message_to_content(m) for m in messages[1:]]
else:
contents = [_message_to_content(m) for m in messages]
# Build config
config_kwargs: dict[str, Any] = {
"max_output_tokens": self._max_tokens,
}
if self._temperature is not None:
config_kwargs["temperature"] = self._temperature
if self._cache_name:
config_kwargs["cached_content"] = self._cache_name
else:
config_kwargs["system_instruction"] = self._system
if tools:
config_kwargs["tools"] = _tools_to_gemini(tools)
config_kwargs["tool_config"] = _tool_choice_to_config(tool_choice)
config = gtypes.GenerateContentConfig(**config_kwargs)
# When using cached_content, Gemini still requires non-empty contents.
# If the cached path leaves us with no per-call contents (only happens
# on the very first turn with a cached initial message), seed with a
# minimal continuation prompt.
if self._cache_name and not contents:
contents = [gtypes.Content(role="user", parts=[gtypes.Part(text="Continue.")])]
try:
resp = await self._client.aio.models.generate_content(
model=self.model,
contents=contents,
config=config,
)
except Exception as exc:
# Cache may have expired mid-loop — drop it and retry inline once
if self._cache_name and "cache" in str(exc).lower():
log.warning("Gemini cache failed (%s) — retrying inline", exc)
self._cache_name = None
return await self.complete(
messages=messages, tools=tools, tool_choice=tool_choice,
)
raise
return _from_gemini_response(resp)
async def close(self) -> None:
if self._cache_name:
try:
await self._client.aio.caches.delete(name=self._cache_name)
except Exception as exc:
log.warning("Gemini cache delete failed (%s): %s", self._cache_name, exc)
finally:
self._cache_name = None
# ---------------------------------------------------------------------------
# Provider
# ---------------------------------------------------------------------------
class GeminiProvider(LLMProvider):
name = "gemini"
def __init__(self) -> None:
api_key = settings.gemini_api_key
if not api_key:
raise RuntimeError(
"GEMINI_API_KEY is not set. Either set it in .env or route "
"this stage to Anthropic via PROVIDER_<STAGE>=anthropic."
)
self._client = genai.Client(api_key=api_key)
async def create_session(
self,
*,
model: str,
system: str,
max_tokens: int = 4096,
temperature: float | None = None,
) -> LLMSession:
return GeminiSession(
client=self._client,
model=model,
system=system,
max_tokens=max_tokens,
temperature=temperature,
)
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]:
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."
)
+81
View File
@@ -0,0 +1,81 @@
"""Per-provider pricing tables and cost computation.
Replaces the flat ``PRICING`` dict that used to live in
``backend/services/api_logs.py``. Indexed by (provider, model).
"""
from __future__ import annotations
# Per-million-token USD rates. Source-of-truth links:
# Anthropic: https://docs.anthropic.com/en/docs/about-claude/pricing
# Google: https://ai.google.dev/pricing
# Last updated: 2026-07-01
PRICING: dict[str, dict[str, dict[str, float]]] = {
"anthropic": {
"claude-opus-4-6": {"input": 5.00, "output": 25.00},
"claude-opus-4-5": {"input": 5.00, "output": 25.00},
"claude-opus-4-1": {"input": 15.00, "output": 75.00},
"claude-opus-4": {"input": 15.00, "output": 75.00},
"claude-sonnet-4-6": {"input": 3.00, "output": 15.00},
# Sonnet 5 standard rate (== Sonnet 4.6). Introductory pricing of
# $2/$10 runs through 2026-08-31; intentionally NOT tracked here —
# chosen set-and-forget so no dated bump is needed on 2026-09-01.
# (New tokenizer emits ~30% more tokens, so per-run cost still rises.)
"claude-sonnet-5": {"input": 3.00, "output": 15.00},
"claude-sonnet-4-5": {"input": 3.00, "output": 15.00},
"claude-sonnet-4": {"input": 3.00, "output": 15.00},
"claude-haiku-4-5-20251001": {"input": 1.00, "output": 5.00},
"claude-haiku-4-5": {"input": 1.00, "output": 5.00},
"claude-haiku-3-5": {"input": 0.80, "output": 4.00},
"default": {"input": 3.00, "output": 15.00},
},
"gemini": {
# Gemini 3 Flash pricing (per 1M tokens). Preview alias mirrors GA.
"gemini-3-flash-preview": {"input": 0.30, "output": 2.50},
"gemini-3-flash": {"input": 0.30, "output": 2.50},
"gemini-flash-latest": {"input": 0.30, "output": 2.50},
"gemini-2.5-flash": {"input": 0.30, "output": 2.50},
"gemini-2.5-pro": {"input": 1.25, "output": 10.00},
# Gemini 3.1 Pro Preview — standard tier, prompts ≤200k tokens.
# Above 200k Google charges $4.00/$18.00; we don't yet split by
# prompt size, so we use the smaller-tier rate. Almost every
# pipeline call here is well under 200k.
"gemini-3.1-pro-preview": {"input": 2.00, "output": 12.00},
"gemini-3-pro-preview": {"input": 2.00, "output": 12.00},
"default": {"input": 0.30, "output": 2.50},
},
}
# Per-provider cache token multipliers, applied on top of the input rate.
# create: cost when a cache is *written* (Anthropic charges 1.25× input;
# Gemini charges 1.0× input — caching writes are billed as a
# normal input pass)
# read: cost when a cached prefix is *reused* (much cheaper)
CACHE_RATES: dict[str, dict[str, float]] = {
"anthropic": {"create": 1.25, "read": 0.10},
"gemini": {"create": 1.00, "read": 0.25},
}
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"]
rates = table.get(entry.get("model", ""), table["default"])
cache_rates = CACHE_RATES.get(provider, CACHE_RATES["anthropic"])
input_rate = rates["input"]
output_rate = rates["output"]
return (
entry.get("input_tokens", 0) * input_rate
+ entry.get("cache_creation_input_tokens", 0) * input_rate * cache_rates["create"]
+ entry.get("cache_read_input_tokens", 0) * input_rate * cache_rates["read"]
+ entry.get("output_tokens", 0) * output_rate
) / 1_000_000
def total_cost(entries: list[dict]) -> float:
"""Sum USD across entries."""
return round(sum(cost_for_entry(e) for e in entries), 6)
+116
View File
@@ -0,0 +1,116 @@
"""Provider-agnostic message and completion types.
These dataclasses are the lingua franca between calling code and providers.
Each provider implementation translates these into its native shape on the
way out and back into these on the way in.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Literal
# ---------------------------------------------------------------------------
# Content blocks — what goes inside a Message
# ---------------------------------------------------------------------------
@dataclass
class TextBlock:
text: str
cacheable: bool = False
# Gemini 3 / thinking-mode: opaque bytes the model returns alongside text
# parts that came from internal reasoning. Must be replayed verbatim when
# this turn is fed back into the conversation, or the next call 400s.
# Anthropic: always None.
thought_signature: bytes | None = None
@dataclass
class PdfBlock:
"""Inline PDF document. Provider encodes as base64 (Anthropic) or
inline_data (Gemini) and applies caching policy if cacheable=True."""
path: Path
cacheable: bool = False
@dataclass
class ToolCall:
"""Assistant turn: model called a tool."""
id: str
name: str
input: dict[str, Any]
# 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
@dataclass
class ToolResultBlock:
"""User turn: result fed back from a tool the model invoked previously."""
tool_use_id: str
name: str
content: str
ContentBlock = TextBlock | PdfBlock | ToolCall | ToolResultBlock
@dataclass
class Message:
role: Literal["user", "assistant"]
content: list[ContentBlock]
# ---------------------------------------------------------------------------
# Tools
# ---------------------------------------------------------------------------
@dataclass
class ToolSchema:
"""JSON-schema tool definition. Both providers accept the same shape."""
name: str
description: str
input_schema: dict[str, Any]
# Tool choice: "auto" (model picks), "none" (no tools), or a forced name
ToolChoice = Literal["auto", "none"] | dict # {"name": "save_xyz"}
# ---------------------------------------------------------------------------
# Completion / usage
# ---------------------------------------------------------------------------
@dataclass
class Usage:
"""Token usage normalised across providers.
Anthropic exposes cache_creation_input_tokens (write) and
cache_read_input_tokens (hit). Gemini only exposes a cache hit count
(cached_content_token_count) — its cache writes don't bill as input.
For Gemini, ``cache_creation_tokens`` is always 0; ``cache_read_tokens``
holds the cached hit count when a cache was used.
"""
input_tokens: int = 0
output_tokens: int = 0
cache_creation_tokens: int = 0
cache_read_tokens: int = 0
@dataclass
class Completion:
"""Result of a single provider.complete() / session.complete() call."""
text: str # any text block(s) concatenated
tool_calls: list[ToolCall]
usage: Usage
stop_reason: str
raw_assistant_blocks: list[ContentBlock] = field(default_factory=list)
"""The full assistant message, in our normalised content-block form, so
callers can append it back to the conversation history when continuing
the loop."""