seedproject-web/api/app/LLM/Anthropic.php
Carlos Arias 1559ce017d chore: scaffold SeedProject base (Phase 1)
Clean-room copy of the reusable engines from comiida, with all
instance data, secrets, dependencies, and build output excluded:
- app/         Astro theme skeleton (no comiida blog posts; hero image -> placeholder)
- api/         SeedProject PHP framework (no vendor/.env/config.php)
- content-pipeline/  engine only (scripts/admin/prompts; empty runtime state)
- astroagent.config.json + app/.astroagent/skills

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYHWLHihq3v9nxNwoPCKSn
2026-07-04 22:53:10 +00:00

799 lines
No EOL
29 KiB
PHP

<?php
namespace App\LLM;
use Anthropic\Client;
use Anthropic\Messages\MessageParam;
use Exception;
class Anthropic
{
private Client $client;
private string $defaultModel = 'claude-sonnet-4-5-20250929';
private int $defaultMaxTokens = 4096;
private ?string $apiKey;
private string $templatePath;
public const MODELS = [
'opus' => 'claude-opus-4-20250514',
'sonnet' => 'claude-sonnet-4-5-20250929',
'haiku' => 'claude-haiku-4-5-20251001',
];
/**
* Constructor
*/
public function __construct(?string $apiKey = null, ?string $templatePath = null, int $timeout = 300) {
$this->apiKey = $apiKey ?? ANTHROPIC_API_KEY;
$this->templatePath = $templatePath ?? dirname(__DIR__, 2) . '/templates/prompts/';
if (empty($this->apiKey)) {
throw new Exception('Anthropic API key is required.');
}
if (!is_dir($this->templatePath)) {
throw new Exception("Template directory not found: {$this->templatePath}");
}
if (!is_readable($this->templatePath)) {
throw new Exception("Template directory is not readable: {$this->templatePath}");
}
set_time_limit($timeout);
ini_set('default_socket_timeout', (string) $timeout);
$this->client = new Client(apiKey: $this->apiKey);
}
// =========================================================================
// CONNECTION & HEALTH
// =========================================================================
public function testConnection(): array
{
$startTime = microtime(true);
try {
$response = $this->client->messages->create(
model: $this->defaultModel,
maxTokens: 10,
messages: [
MessageParam::with(role: 'user', content: 'Reply with only: OK')
]
);
$latency = round((microtime(true) - $startTime) * 1000);
return [
'success' => true,
'message' => 'Connected to Anthropic API',
'model' => $this->defaultModel,
'response' => trim($response->content[0]->text),
'latency_ms' => $latency,
'usage' => [
'input_tokens' => $response->usage->inputTokens,
'output_tokens' => $response->usage->outputTokens,
]
];
} catch (Exception $e) {
return [
'success' => false,
'message' => 'Connection failed',
'error' => $e->getMessage(),
'error_type' => get_class($e)
];
}
}
public function isConnected(): bool
{
return $this->testConnection()['success'];
}
public function getStatus(): array
{
$connection = $this->testConnection();
return [
'connected' => $connection['success'],
'model' => $this->defaultModel,
'max_tokens' => $this->defaultMaxTokens,
'api_key_preview' => substr($this->apiKey, 0, 10) . '...' . substr($this->apiKey, -4),
'template_path' => $this->templatePath,
'connection_details' => $connection
];
}
// =========================================================================
// TEMPLATE LOADING
// =========================================================================
public function loadTemplate(string $filename): string
{
$path = $this->templatePath . '/' . $filename;
if (!file_exists($path)) {
throw new Exception("Template not found: {$path}");
}
$content = file_get_contents($path);
if ($content === false) {
throw new Exception("Failed to read template: {$path}");
}
return $content;
}
public function setTemplatePath(string $path): self
{
$this->templatePath = rtrim($path, '/');
return $this;
}
public function getTemplatePath(): string
{
return $this->templatePath;
}
// =========================================================================
// BASIC MESSAGING
// =========================================================================
public function message(string $prompt, ?string $systemPrompt = null, array $options = []): string
{
$response = $this->messageWithMeta($prompt, $systemPrompt, $options);
return $response['content'];
}
public function messageWithMeta(string $prompt, ?string $systemPrompt = null, array $options = []): array
{
$startTime = microtime(true);
$messages = $this->buildMessages($prompt, $options['conversation'] ?? []);
$params = [
'model' => $options['model'] ?? $this->defaultModel,
'maxTokens' => $options['max_tokens'] ?? $this->defaultMaxTokens,
'messages' => $messages,
];
if ($systemPrompt) {
$params['system'] = $systemPrompt;
}
if (isset($options['temperature'])) {
$params['temperature'] = $options['temperature'];
}
if (isset($options['stop_sequences'])) {
$params['stopSequences'] = $options['stop_sequences'];
}
$response = $this->client->messages->create(...$params);
return [
'content' => $response->content[0]->text,
'model' => $response->model,
'stop_reason' => $response->stopReason,
'latency_ms' => round((microtime(true) - $startTime) * 1000),
'usage' => [
'input_tokens' => $response->usage->inputTokens,
'output_tokens' => $response->usage->outputTokens,
'total_tokens' => $response->usage->inputTokens + $response->usage->outputTokens,
]
];
}
public function conversation(array $messages, ?string $systemPrompt = null, array $options = []): array
{
$messageParams = [];
foreach ($messages as $msg) {
$messageParams[] = MessageParam::with(role: $msg['role'], content: $msg['content']);
}
$params = [
'model' => $options['model'] ?? $this->defaultModel,
'maxTokens' => $options['max_tokens'] ?? $this->defaultMaxTokens,
'messages' => $messageParams,
];
if ($systemPrompt) {
$params['system'] = $systemPrompt;
}
$response = $this->client->messages->create(...$params);
return [
'content' => $response->content[0]->text,
'usage' => [
'input_tokens' => $response->usage->inputTokens,
'output_tokens' => $response->usage->outputTokens,
]
];
}
// =========================================================================
// CACHED MESSAGING
// =========================================================================
/**
* Message with prompt caching - template is cached, variables are not
*/
public function messageWithCache(
string $prompt,
string $cachedContext,
?string $dynamicContext = null,
array $options = []
): array {
$startTime = microtime(true);
$systemBlocks = [
[
'type' => 'text',
'text' => $cachedContext,
'cache_control' => ['type' => 'ephemeral']
]
];
if ($dynamicContext) {
$systemBlocks[] = [
'type' => 'text',
'text' => $dynamicContext
];
}
$response = $this->client->messages->create(
model: $options['model'] ?? $this->defaultModel,
maxTokens: $options['max_tokens'] ?? $this->defaultMaxTokens,
system: $systemBlocks,
messages: [
MessageParam::with(role: 'user', content: $prompt)
]
);
$cacheCreation = $response->usage->cacheCreationInputTokens ?? 0;
$cacheRead = $response->usage->cacheReadInputTokens ?? 0;
return [
'content' => $response->content[0]->text,
'model' => $response->model,
'latency_ms' => round((microtime(true) - $startTime) * 1000),
'usage' => [
'input_tokens' => $response->usage->inputTokens,
'output_tokens' => $response->usage->outputTokens,
'cache_creation_tokens' => $cacheCreation,
'cache_read_tokens' => $cacheRead,
],
'cache_hit' => $cacheRead > 0,
'cache_status' => $this->getCacheStatus($cacheCreation, $cacheRead)
];
}
private function getCacheStatus(int $created, int $read): string
{
if ($read > 0) return 'hit';
if ($created > 0) return 'created';
return 'none';
}
// =========================================================================
// ARTICLE GENERATION
// =========================================================================
/**
* Build the dynamic variables block (topic, word count, etc.)
* This is NOT cached - changes per article
*/
public function buildArticleVariables(array $config): string
{
$lines = [];
if (!empty($config['topic'])) {
$lines[] = "TOPIC: {$config['topic']}";
}
if (!empty($config['domain'])) {
$lines[] = "DOMAIN: {$config['domain']}";
}
if (!empty($config['industry'])) {
$lines[] = "INDUSTRY: {$config['industry']}";
}
if (!empty($config['target_audience'])) {
$lines[] = "AUDIENCE: {$config['target_audience']}";
}
if (!empty($config['knowledge_level'])) {
$lines[] = "KNOWLEDGE_LEVEL: {$config['knowledge_level']}";
}
if (!empty($config['tone'])) {
$lines[] = "TONE: {$config['tone']}";
}
if (!empty($config['word_count'])) {
$lines[] = "WORD_COUNT: {$config['word_count']}";
}
if (!empty($config['location'])) {
$lines[] = "LOCATION: {$config['location']}";
}
$lines[] = "CURRENT_DATE: " . date('Y-m-d');
return implode("\n", $lines);
}
/**
* Generate article using template + variables (legacy single-file method)
* Template (with business profile) is cached
* Variables (topic, word count, etc.) are dynamic
*/
public function generateArticle(string $templateFile, array $config): array
{
// Load template - this includes the master prompt + business profile
// This part gets cached
$template = $this->loadTemplate($templateFile);
// Build dynamic variables - this changes per article
$variables = $this->buildArticleVariables($config);
return $this->messageWithCache(
prompt: $config['prompt'] ?? 'BEGIN GENERATION NOW.',
cachedContext: $template,
dynamicContext: $variables,
options: [
'max_tokens' => $config['max_tokens'] ?? 8192,
'model' => $config['model'] ?? $this->defaultModel
]
);
}
/**
* Generate article using 3-block structure with prompt caching
*
* Block 1: Business Profile (cached - client specific)
* Block 2: Article Instructions (cached - shared across all clients)
* Block 3: Config Variables (dynamic - not cached)
*
* Cache behavior:
* - Blocks 1 & 2 are cached together as a prefix
* - When processing multiple articles for same client, cache hits on blocks 1+2
* - Block 3 varies per article, never cached
*
* @param string $businessProfile Client-specific business profile content
* @param string $articleInstructions Shared writing instructions content
* @param array $config Article configuration (topic, word_count, etc.)
* - 'enable_web_search' => bool (default: false)
* - 'reference_urls' => array of URLs to research
* @return array Response with content, usage, and cache status
*/
// =============================================================================
// UPDATED Anthropic::generateArticleWithBlocks()
// =============================================================================
public function generateArticleWithBlocks(
string $businessProfile,
string $articleInstructions,
array $projectConfig,
string $referenceContent = '',
bool $useWebSearch = false
): array {
$startTime = microtime(true);
// Build article variables (Block 3)
$articleVariables = $this->buildArticleVariables($projectConfig);
// DEEPCRAWL MODE: Inject pre-fetched content into Block 3
if (!empty($referenceContent)) {
$articleVariables .= "\n\n" . $referenceContent;
}
// WEBSEARCH MODE: Add URL hints for Claude to search
if ($useWebSearch && !empty($projectConfig['reference_urls'])) {
$urlHints = "\n\n<search_guidance>\n";
$urlHints .= "Consider searching these sources for authoritative information:\n";
foreach ($projectConfig['reference_urls'] as $ref) {
$url = is_array($ref) ? $ref['url'] : $ref;
$notes = is_array($ref) ? ($ref['notes'] ?? '') : '';
$urlHints .= "- {$url}";
if ($notes) $urlHints .= " ({$notes})";
$urlHints .= "\n";
}
$urlHints .= "</search_guidance>";
$articleVariables .= $urlHints;
}
// =========================================================================
// BUILD THE FULL PROMPT (for logging)
// =========================================================================
$block1 = "<business_profile>\n{$businessProfile}\n</business_profile>";
$block2 = "<article_instructions>\n{$articleInstructions}\n</article_instructions>";
$block3 = "<article_request>\n{$articleVariables}\n</article_request>";
$fullPrompt = $block1 . "\n\n" . $block2 . "\n\n" . $block3;
// Build messages
$messages = [
[
'role' => 'user',
'content' => [
// Block 1: Business Profile (cached)
[
'type' => 'text',
'text' => $block1,
'cache_control' => ['type' => 'ephemeral']
],
// Block 2: Article Instructions (cached)
[
'type' => 'text',
'text' => $block2,
'cache_control' => ['type' => 'ephemeral']
],
// Block 3: Article Variables + References (dynamic)
[
'type' => 'text',
'text' => $block3
]
]
]
];
// Call API
if ($useWebSearch) {
$response = $this->client->messages->create(
model: 'claude-sonnet-4-5-20250929',
maxTokens: 8192,
messages: $messages,
tools: [
[
'type' => 'web_search_20250305',
'name' => 'web_search',
]
]
);
} else {
$response = $this->client->messages->create(
model: 'claude-sonnet-4-5-20250929',
maxTokens: 8192,
messages: $messages
);
}
// Parse response
$cacheCreation = $response->usage->cacheCreationInputTokens ?? 0;
$cacheRead = $response->usage->cacheReadInputTokens ?? 0;
return [
'content' => $response->content[0]->text,
'model' => $response->model,
'stop_reason' => $response->stopReason,
'latency_ms' => round((microtime(true) - $startTime) * 1000),
'usage' => [
'input_tokens' => $response->usage->inputTokens,
'output_tokens' => $response->usage->outputTokens,
'cache_creation_tokens' => $cacheCreation,
'cache_read_tokens' => $cacheRead,
],
'cache_status' => $this->getCacheStatus($cacheCreation, $cacheRead),
'prompt_sent' => $fullPrompt, // NEW: Full prompt for logging
];
}
/**
* Build instruction for reference URLs
*/
private function buildReferenceUrlsInstruction(array $urls): string
{
$instruction = "REFERENCE URLS FOR RESEARCH:\n";
$instruction .= "Search and reference the following URLs for accurate, current information:\n\n";
foreach ($urls as $index => $url) {
$num = $index + 1;
if (is_array($url)) {
// URL with description: ['url' => '...', 'description' => '...']
$instruction .= "{$num}. {$url['url']}\n";
if (!empty($url['description'])) {
$instruction .= " Purpose: {$url['description']}\n";
}
} else {
// Simple URL string
$instruction .= "{$num}. {$url}\n";
}
}
$instruction .= "\nUse web search to fetch current information from these sources. ";
$instruction .= "Cite specific facts, statistics, or requirements found on these pages. ";
$instruction .= "You may also search for additional supporting information as needed.";
return $instruction;
}
/**
* Extract text content from response blocks (handles mixed content with tool use)
*/
private function extractTextContent(array $contentBlocks): string
{
$textParts = [];
foreach ($contentBlocks as $block) {
if (isset($block->type) && $block->type === 'text') {
$textParts[] = $block->text;
}
}
return implode("\n", $textParts);
}
/**
* Generate article using multi-turn conversation style
*
* This approach chunks the prompt into conversational turns:
* - Turn 1: User sends business profile
* - Turn 2: Assistant acknowledges persona
* - Turn 3: User sends article instructions
* - Turn 4: Assistant confirms understanding
* - Turn 5: User sends config and triggers generation
*
* Benefits: May improve instruction following for complex prompts
* Tradeoff: Extra tokens for assistant acknowledgments
*
* @param string $businessProfile Client-specific business profile content
* @param string $articleInstructions Shared writing instructions content
* @param array $config Article configuration (topic, word_count, etc.)
* @return array Response with content, usage, and cache status
*/
public function generateArticleMultiTurn(
string $businessProfile,
string $articleInstructions,
array $config
): array {
$startTime = microtime(true);
// Build dynamic config variables
$configVariables = $this->buildArticleVariables($config);
$response = $this->client->messages->create(
model: $config['model'] ?? $this->defaultModel,
maxTokens: $config['max_tokens'] ?? 8192,
messages: [
// Turn 1: Business Profile
[
'role' => 'user',
'content' => [
[
'type' => 'text',
'text' => "Here is the business profile you will write as:\n\n" . $businessProfile,
'cache_control' => ['type' => 'ephemeral'],
],
],
],
// Turn 2: Assistant acknowledges persona
[
'role' => 'assistant',
'content' => [
[
'type' => 'text',
'text' => "I understand. I am now embodying this business identity and will write from their perspective, using their voice, expertise, and experience as defined in the profile.",
'cache_control' => ['type' => 'ephemeral'],
],
],
],
// Turn 3: Article Instructions
[
'role' => 'user',
'content' => [
[
'type' => 'text',
'text' => "Here are the article writing instructions:\n\n" . $articleInstructions,
'cache_control' => ['type' => 'ephemeral'],
],
],
],
// Turn 4: Assistant confirms understanding
[
'role' => 'assistant',
'content' => [
[
'type' => 'text',
'text' => "Ready. I will follow these writing rules: early answer with recommendation, 'This breaks down when...' statement, field notes from practitioner experience, no blacklisted phrases, boundary conditions section, and output valid JSON only.",
'cache_control' => ['type' => 'ephemeral'],
],
],
],
// Turn 5: Config Variables + Generate
[
'role' => 'user',
'content' => $configVariables . "\n\nBEGIN GENERATION.",
],
],
);
$cacheCreation = $response->usage->cacheCreationInputTokens ?? 0;
$cacheRead = $response->usage->cacheReadInputTokens ?? 0;
return [
'content' => $response->content[0]->text,
'model' => $response->model,
'stop_reason' => $response->stopReason,
'latency_ms' => round((microtime(true) - $startTime) * 1000),
'usage' => [
'input_tokens' => $response->usage->inputTokens,
'output_tokens' => $response->usage->outputTokens,
'cache_creation_tokens' => $cacheCreation,
'cache_read_tokens' => $cacheRead,
],
'cache_hit' => $cacheRead > 0,
'cache_status' => $this->getCacheStatus($cacheCreation, $cacheRead),
];
}
/**
* Generate multiple articles (same template = cache hits after first)
*/
public function generateArticleBatch(string $templateFile, array $articles): array
{
$results = [];
foreach ($articles as $index => $config) {
$results[] = [
'index' => $index,
'topic' => $config['topic'] ?? 'Unknown',
'result' => $this->generateArticle($templateFile, $config)
];
}
return $results;
}
// =========================================================================
// UTILITY METHODS
// =========================================================================
private function buildMessages(string $prompt, array $conversation = []): array
{
$messages = [];
foreach ($conversation as $msg) {
$messages[] = MessageParam::with(role: $msg['role'], content: $msg['content']);
}
$messages[] = MessageParam::with(role: 'user', content: $prompt);
return $messages;
}
public function estimateTokens(string $text): int
{
return (int) ceil(strlen($text) / 4);
}
/**
* Check if content meets minimum cache threshold (1024 tokens for Sonnet)
*/
public function meetsCacheMinimum(string $text): bool
{
return $this->estimateTokens($text) >= 1024;
}
public function estimateCost(int $inputTokens, int $outputTokens, ?string $model = null): array
{
$model = $model ?? $this->defaultModel;
$pricing = [
'claude-opus-4-20250514' => ['input' => 15.00, 'output' => 75.00],
'claude-sonnet-4-5-20250929' => ['input' => 3.00, 'output' => 15.00],
'claude-haiku-4-5-20251001' => ['input' => 0.80, 'output' => 4.00],
];
$rates = $pricing[$model] ?? $pricing['claude-sonnet-4-5-20250929'];
$inputCost = ($inputTokens / 1_000_000) * $rates['input'];
$outputCost = ($outputTokens / 1_000_000) * $rates['output'];
return [
'input_cost' => round($inputCost, 6),
'output_cost' => round($outputCost, 6),
'total_cost' => round($inputCost + $outputCost, 6),
'model' => $model
];
}
public function formatCost(array $usage): string {
$cost = $this->estimateCostWithCache($usage);
$output = "Cost Breakdown:\n";
$output .= " Input: $" . number_format($cost['input_cost'], 4) . "\n";
$output .= " Output: $" . number_format($cost['output_cost'], 4) . "\n";
if ($cost['cache_write_cost'] > 0) {
$output .= " Cache Write: $" . number_format($cost['cache_write_cost'], 4) . "\n";
}
if ($cost['cache_read_cost'] > 0) {
$output .= " Cache Read: $" . number_format($cost['cache_read_cost'], 4) . "\n";
}
$output .= " ─────────────\n";
$output .= " Total: $" . number_format($cost['total_cost'], 4) . "\n";
if ($cost['savings'] > 0) {
$output .= " Saved: $" . number_format($cost['savings'], 4) . " ({$cost['savings_percent']}%)\n";
}
return $output;
}
public function estimateCostWithCache(array $usage, ?string $model = null): array
{
$model = $model ?? $this->defaultModel;
$pricing = [
'claude-opus-4-20250514' => ['input' => 15.00, 'output' => 75.00, 'cache_write' => 18.75, 'cache_read' => 1.50],
'claude-sonnet-4-5-20250929' => ['input' => 3.00, 'output' => 15.00, 'cache_write' => 3.75, 'cache_read' => 0.30],
'claude-haiku-4-5-20251001' => ['input' => 0.80, 'output' => 4.00, 'cache_write' => 1.00, 'cache_read' => 0.08],
];
$rates = $pricing[$model] ?? $pricing['claude-sonnet-4-5-20250929'];
$inputCost = (($usage['input_tokens'] ?? 0) / 1_000_000) * $rates['input'];
$outputCost = (($usage['output_tokens'] ?? 0) / 1_000_000) * $rates['output'];
$cacheWriteCost = (($usage['cache_creation_tokens'] ?? 0) / 1_000_000) * $rates['cache_write'];
$cacheReadCost = (($usage['cache_read_tokens'] ?? 0) / 1_000_000) * $rates['cache_read'];
$totalCost = $inputCost + $outputCost + $cacheWriteCost + $cacheReadCost;
$cacheTokens = ($usage['cache_creation_tokens'] ?? 0) + ($usage['cache_read_tokens'] ?? 0);
$costWithoutCache = (($usage['input_tokens'] + $cacheTokens) / 1_000_000) * $rates['input'] + $outputCost;
$savings = $costWithoutCache - $totalCost;
return [
'input_cost' => round($inputCost, 6),
'output_cost' => round($outputCost, 6),
'cache_write_cost' => round($cacheWriteCost, 6),
'cache_read_cost' => round($cacheReadCost, 6),
'total_cost' => round($totalCost, 6),
'cost_without_cache' => round($costWithoutCache, 6),
'savings' => round($savings, 6),
'savings_percent' => $costWithoutCache > 0 ? round(($savings / $costWithoutCache) * 100, 2) : 0,
'model' => $model
];
}
// =========================================================================
// CONFIGURATION
// =========================================================================
public function setModel(string $model): self
{
$this->defaultModel = $model;
return $this;
}
public function useModel(string $preset): self
{
if (isset(self::MODELS[$preset])) {
$this->defaultModel = self::MODELS[$preset];
}
return $this;
}
public function setMaxTokens(int $tokens): self
{
$this->defaultMaxTokens = $tokens;
return $this;
}
public function getModel(): string
{
return $this->defaultModel;
}
public function getClient(): Client
{
return $this->client;
}
public function getAvailableModels(): array
{
return self::MODELS;
}
}