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:
parent
4f4d365652
commit
8a1d03a967
5 changed files with 211 additions and 235 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue