refactor(meet-kevin): switch LLM back to native Anthropic SDK with OAuth bearer

Previous refactor (89f01ad) moved to OpenRouter because no sk-ant-api-* key
was found in Vault. Turns out claude-agent-service-spare-{1,2} hold
sk-ant-oat01-* OAuth tokens (108 chars, scope user:inference, 1-year TTL,
minted via 'claude setup-token' — see memory id=832).

These tokens work with the Anthropic SDK via the auth_token= constructor
argument (routes to Authorization: Bearer ... instead of x-api-key: ...).
They consume the Enterprise Claude subscription quota rather than
per-call billing, so the OpenRouter zero-credit problem goes away.

- llm_analyzer.py: revert OpenAI client to AsyncAnthropic; tool-use API
  + cache_control restored
- config.py: openrouter_api_key -> anthropic_oauth_token; model slug
  reverted from anthropic/claude-sonnet-4.5 -> claude-sonnet-4-5
- main.py: AsyncOpenAI -> AsyncAnthropic(auth_token=...), drop OpenRouter
  attribution headers
- pyproject: openai>=1.50 -> anthropic>=0.40 in meet_kevin extras
- tests: mocks ported back to messages.create + tool_use blocks
This commit is contained in:
Viktor Barzin 2026-05-22 19:24:40 +00:00
parent 4f4d365652
commit 8a1d03a967
5 changed files with 211 additions and 235 deletions

View file

@ -18,12 +18,12 @@ class MeetKevinWatcherConfig(BaseConfig):
# LLM analysis settings
meet_kevin_max_llm_retries: int = 3
meet_kevin_llm_model: str = "anthropic/claude-sonnet-4.5"
meet_kevin_llm_model: str = "claude-sonnet-4-5"
meet_kevin_prompt_version: str = "v1"
meet_kevin_daily_cost_cap_usd: float = 5.0
# API credentials
openrouter_api_key: str = ""
anthropic_oauth_token: str = ""
# Runtime settings
meet_kevin_workdir: str = "/tmp/meet_kevin_captions"

View file

@ -1,7 +1,7 @@
"""OpenRouter LLM analyzer for Meet Kevin video transcripts.
"""Anthropic SDK LLM analyzer for Meet Kevin video transcripts.
Calls Claude Sonnet (via OpenRouter) with function-calling forcing to extract
structured MeetKevinAnalysis from a video transcript.
Calls Claude Sonnet (via native Anthropic SDK with OAuth bearer token) with
tool-use forcing to extract structured MeetKevinAnalysis from a video transcript.
Public API:
SYSTEM_PROMPT module-level analyst instructions
@ -10,14 +10,13 @@ Public API:
LlmAnalyzer async class; .analyze() does the API call
"""
import json
import logging
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
from typing import Any
from openai import AsyncOpenAI
from anthropic import AsyncAnthropic
from shared.schemas.meet_kevin import MeetKevinAnalysis
@ -25,16 +24,16 @@ logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Pricing table (USD per 1 000 000 tokens: input, output)
# OpenRouter pass-through pricing (~3% markup over Anthropic list)
# Native Anthropic list pricing. With OAuth/Enterprise tokens real billing
# is via subscription quota, but we still compute notional USD for the
# daily-cap accounting logic.
# ---------------------------------------------------------------------------
_PRICING: dict[str, tuple[Decimal, Decimal]] = {
"claude-sonnet-4-6": (Decimal("3.10"), Decimal("15.50")),
"claude-sonnet-4-5": (Decimal("3"), Decimal("15")),
"claude-sonnet-4-6": (Decimal("3"), Decimal("15")),
"claude-opus-4-7": (Decimal("15"), Decimal("75")),
"claude-haiku-4-5-20251001": (Decimal("1"), Decimal("5")),
# OpenRouter model slugs
"anthropic/claude-sonnet-4.5": (Decimal("3.10"), Decimal("15.50")),
"anthropic/claude-sonnet-4.6": (Decimal("3.10"), Decimal("15.50")),
}
# ---------------------------------------------------------------------------
@ -141,99 +140,96 @@ Now read the transcript provided in the user message and call `submit_analysis`.
""".strip()
# ---------------------------------------------------------------------------
# Tool definition (OpenAI function-calling format)
# Tool definition (Anthropic tool-use format)
# ---------------------------------------------------------------------------
_ANALYSIS_TOOL_OPENAI: dict[str, Any] = {
"type": "function",
"function": {
"name": "submit_analysis",
"description": (
"Submit the structured analysis of one Meet Kevin video. Call this exactly once."
),
"parameters": {
"type": "object",
"required": [
"market_outlook_direction",
"market_outlook_reasoning",
"macro_themes",
"key_risks",
"summary",
"tickers",
],
"properties": {
"market_outlook_direction": {
"type": "string",
"enum": ["bullish", "neutral", "bearish", "mixed"],
"description": "Overall market sentiment direction",
},
"market_outlook_reasoning": {
"type": "string",
"description": "2-4 sentence explanation of the market outlook direction",
},
"macro_themes": {
"type": "array",
"items": {"type": "string"},
"description": "2-6 high-level macro economic themes discussed",
},
"key_risks": {
"type": "array",
"items": {"type": "string"},
"description": "2-5 principal downside risks Kevin mentions",
},
"summary": {
"type": "string",
"description": "~200-word plain-English investment thesis summary",
},
"tickers": {
"type": "array",
"description": "Per-ticker mentions with action and conviction",
"items": {
"type": "object",
"required": [
"symbol",
"action",
"conviction",
"time_horizon",
"rationale_quote",
"video_timestamp_seconds",
],
"properties": {
"symbol": {
"type": "string",
"description": "Uppercase ticker symbol (1-6 chars)",
},
"action": {
"type": "string",
"enum": ["buy", "sell", "hold", "watch", "avoid"],
"description": "Recommendation action",
},
"conviction": {
"type": "number",
"minimum": 0.0,
"maximum": 1.0,
"description": "Confidence in recommendation (0.0-1.0)",
},
"time_horizon": {
"type": "string",
"enum": [
"intraday",
"days",
"weeks",
"months",
"long_term",
"unspecified",
],
"description": "Time horizon for the recommendation",
},
"rationale_quote": {
"type": "string",
"description": "Short verbatim or paraphrased quote from video",
},
"video_timestamp_seconds": {
"type": ["integer", "null"],
"description": "Timestamp in seconds for deep-link target",
},
_ANALYSIS_TOOL: dict[str, Any] = {
"name": "submit_analysis",
"description": (
"Submit the structured analysis of one Meet Kevin video. Call this exactly once."
),
"input_schema": {
"type": "object",
"required": [
"market_outlook_direction",
"market_outlook_reasoning",
"macro_themes",
"key_risks",
"summary",
"tickers",
],
"properties": {
"market_outlook_direction": {
"type": "string",
"enum": ["bullish", "neutral", "bearish", "mixed"],
"description": "Overall market sentiment direction",
},
"market_outlook_reasoning": {
"type": "string",
"description": "2-4 sentence explanation of the market outlook direction",
},
"macro_themes": {
"type": "array",
"items": {"type": "string"},
"description": "2-6 high-level macro economic themes discussed",
},
"key_risks": {
"type": "array",
"items": {"type": "string"},
"description": "2-5 principal downside risks Kevin mentions",
},
"summary": {
"type": "string",
"description": "~200-word plain-English investment thesis summary",
},
"tickers": {
"type": "array",
"description": "Per-ticker mentions with action and conviction",
"items": {
"type": "object",
"required": [
"symbol",
"action",
"conviction",
"time_horizon",
"rationale_quote",
"video_timestamp_seconds",
],
"properties": {
"symbol": {
"type": "string",
"description": "Uppercase ticker symbol (1-6 chars)",
},
"action": {
"type": "string",
"enum": ["buy", "sell", "hold", "watch", "avoid"],
"description": "Recommendation action",
},
"conviction": {
"type": "number",
"minimum": 0.0,
"maximum": 1.0,
"description": "Confidence in recommendation (0.0-1.0)",
},
"time_horizon": {
"type": "string",
"enum": [
"intraday",
"days",
"weeks",
"months",
"long_term",
"unspecified",
],
"description": "Time horizon for the recommendation",
},
"rationale_quote": {
"type": "string",
"description": "Short verbatim or paraphrased quote from video",
},
"video_timestamp_seconds": {
"type": ["integer", "null"],
"description": "Timestamp in seconds for deep-link target",
},
},
},
@ -296,15 +292,15 @@ _MAX_SEGMENTS = 1000
class LlmAnalyzer:
"""Calls Claude (via OpenRouter) to extract structured analysis from a video transcript.
"""Calls Claude (via native Anthropic SDK) to extract structured analysis from a video transcript.
Args:
client: Configured AsyncOpenAI client pointed at OpenRouter.
model: Model identifier (e.g. "anthropic/claude-sonnet-4.5").
client: Configured AsyncAnthropic client with OAuth bearer token.
model: Model identifier (e.g. "claude-sonnet-4-5").
prompt_version: Prompt version string stored in kevin_analyses.
"""
def __init__(self, client: AsyncOpenAI, model: str, prompt_version: str) -> None:
def __init__(self, client: AsyncAnthropic, model: str, prompt_version: str) -> None:
self._client = client
self._model = model
self._prompt_version = prompt_version
@ -331,8 +327,8 @@ class LlmAnalyzer:
LlmCallResult with parsed MeetKevinAnalysis and token accounting.
Raises:
ValueError: If the response contains no tool_calls.
pydantic.ValidationError: If function arguments fail schema validation.
ValueError: If the response contains no tool_use block.
pydantic.ValidationError: If tool input fails schema validation.
"""
user_msg = self._build_user_message(
title=title,
@ -342,34 +338,39 @@ class LlmAnalyzer:
transcript_segments=transcript_segments,
)
response = await self._client.chat.completions.create(
response = await self._client.messages.create(
model=self._model,
max_tokens=4096,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_msg},
system=[
{"type": "text", "text": SYSTEM_PROMPT, "cache_control": {"type": "ephemeral"}}
],
tools=[_ANALYSIS_TOOL_OPENAI],
tool_choice={"type": "function", "function": {"name": "submit_analysis"}},
tools=[_ANALYSIS_TOOL],
tool_choice={"type": "tool", "name": "submit_analysis"},
messages=[{"role": "user", "content": user_msg}],
)
message = response.choices[0].message
if not message.tool_calls:
# Find the first tool_use block in the response
tool_use_block = None
for block in response.content:
if block.type == "tool_use":
tool_use_block = block
break
if tool_use_block is None:
raise ValueError(
"LLM response contained no tool_calls (expected submit_analysis function call)"
"LLM response contained no tool_use block (expected submit_analysis call)"
)
tool_call = message.tool_calls[0]
tool_input = json.loads(tool_call.function.arguments)
tool_input: dict = tool_use_block.input
analysis = MeetKevinAnalysis.model_validate(tool_input)
prompt_tokens: int = response.usage.prompt_tokens
completion_tokens: int = response.usage.completion_tokens
prompt_tokens: int = response.usage.input_tokens
completion_tokens: int = response.usage.output_tokens
cost_usd = compute_cost_usd(self._model, prompt_tokens, completion_tokens)
raw_response: dict = {
"finish_reason": response.choices[0].finish_reason,
"tool_name": tool_call.function.name,
"stop_reason": response.stop_reason,
"tool_name": tool_use_block.name,
"tool_input": tool_input,
"usage": {
"input_tokens": prompt_tokens,

View file

@ -16,7 +16,7 @@ from datetime import timezone
from decimal import Decimal
import httpx
from openai import AsyncOpenAI
from anthropic import AsyncAnthropic
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert
@ -179,14 +179,9 @@ async def run() -> None:
# Database
engine, session_factory = create_db(config)
# OpenRouter client + LLM analyzer
client = AsyncOpenAI(
api_key=config.openrouter_api_key,
base_url="https://openrouter.ai/api/v1",
default_headers={
"HTTP-Referer": "https://trading.viktorbarzin.me",
"X-Title": "trading-bot meet-kevin",
},
# Anthropic client + LLM analyzer (OAuth bearer token)
client = AsyncAnthropic(
auth_token=config.anthropic_oauth_token,
)
analyzer = LlmAnalyzer(
client=client,