apiKey() === null) { return null; } $prediction = $this->predictWithWebContext($prices); return $prediction ?? $this->predictBasic($prices); } protected function apiKey(): ?string { return config('services.anthropic.api_key'); } /** {@inheritDoc} */ protected function callProvider(string $apiKey, string $priceList): ?array { return null; } /** * Multi-turn web search phase, then a forced submit_prediction call. * Phase 1: let the model search for recent oil/geopolitical news. * Phase 2: force submit_prediction with the full conversation context. */ private function predictWithWebContext(Collection $prices): ?PricePrediction { $messages = [['role' => 'user', 'content' => $this->contextPrompt($this->buildPriceList($prices))]]; $url = 'https://api.anthropic.com/v1/messages'; try { for ($i = 0, $response = null; $i < 5; $i++) { $response = $this->apiLogger->send('anthropic', 'POST', $url, fn () => Http::timeout(30) ->withHeaders($this->headers()) ->post($url, [ 'model' => config('services.anthropic.model', 'claude-sonnet-4-6'), 'max_tokens' => 1024, 'tools' => [['type' => 'web_search_20250305', 'name' => 'web_search']], 'messages' => $messages, ])); if (! $response->successful()) { Log::error(self::class.': context search request failed', ['status' => $response->status()]); return null; } if ($response->json('stop_reason') !== 'pause_turn') { break; } $messages[] = ['role' => 'assistant', 'content' => $response->json('content')]; } $messages[] = ['role' => 'assistant', 'content' => $response->json('content')]; $messages[] = ['role' => 'user', 'content' => 'Now submit your prediction using the submit_prediction tool.']; $submitResponse = $this->apiLogger->send('anthropic', 'POST', $url, fn () => Http::timeout(15) ->withHeaders($this->headers()) ->post($url, [ 'model' => config('services.anthropic.model', 'claude-sonnet-4-6'), 'max_tokens' => 256, 'tools' => [$this->submitPredictionTool()], 'tool_choice' => ['type' => 'tool', 'name' => 'submit_prediction'], 'messages' => $messages, ])); if (! $submitResponse->successful()) { Log::error(self::class.': context submit request failed', ['status' => $submitResponse->status()]); return null; } $input = $this->extractToolInput($submitResponse->json('content') ?? []); return $input === null ? null : $this->buildPrediction($input, PredictionSource::LlmWithContext); } catch (Throwable $e) { Log::error(self::class.': predictWithWebContext failed', ['error' => $e->getMessage()]); return null; } } /** * Single-turn prediction using a forced submit_prediction tool call. * Guarantees structured output — no JSON parsing needed. */ private function predictBasic(Collection $prices): ?PricePrediction { $chronological = $prices->sortBy('date'); $ewma3 = Ewma::compute($chronological->take(-3)->pluck('price_usd')->values()->all()); $ewma7 = Ewma::compute($chronological->take(-7)->pluck('price_usd')->values()->all()); $ewma14 = Ewma::compute($chronological->pluck('price_usd')->values()->all()); $url = 'https://api.anthropic.com/v1/messages'; try { $response = $this->apiLogger->send('anthropic', 'POST', $url, fn () => Http::timeout(15) ->withHeaders($this->headers()) ->post($url, [ 'model' => config('services.anthropic.model', 'claude-haiku-4-5-20251001'), 'max_tokens' => 256, 'tools' => [$this->submitPredictionTool()], 'tool_choice' => ['type' => 'tool', 'name' => 'submit_prediction'], 'messages' => [[ 'role' => 'user', 'content' => $this->basicPrompt($this->buildPriceList($prices), $ewma3, $ewma7, $ewma14), ]], ])); if (! $response->successful()) { Log::error(self::class.': basic request failed', ['status' => $response->status()]); return null; } $input = $this->extractToolInput($response->json('content') ?? []); return $input === null ? null : $this->buildPrediction($input); } catch (Throwable $e) { Log::error(self::class.': predictBasic failed', ['error' => $e->getMessage()]); return null; } } private function contextPrompt(string $priceList): string { return << */ private function headers(): array { return [ 'x-api-key' => $this->apiKey(), 'anthropic-version' => '2023-06-01', ]; } /** @return array{name: string, description: string, input_schema: array} */ private function submitPredictionTool(): array { return [ 'name' => 'submit_prediction', 'description' => 'Submit the final oil price direction prediction.', 'input_schema' => [ 'type' => 'object', 'properties' => [ 'direction' => [ 'type' => 'string', 'enum' => ['rising', 'falling', 'flat'], ], 'confidence' => [ 'type' => 'integer', 'minimum' => 0, 'maximum' => self::LLM_MAX_CONFIDENCE, ], 'reasoning' => [ 'type' => 'string', 'description' => 'One sentence explaining the prediction.', ], ], 'required' => ['direction', 'confidence', 'reasoning'], ], ]; } /** @param array $content */ private function extractToolInput(array $content): ?array { $block = collect($content)->firstWhere('type', 'tool_use'); return $block['input'] ?? null; } }