Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
71.11% covered (warning)
71.11%
32 / 45
71.43% covered (warning)
71.43%
5 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
OpenRouterApiService
71.11% covered (warning)
71.11%
32 / 45
71.43% covered (warning)
71.43%
5 / 7
18.73
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getHeaders
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
2.00
 transformPayload
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
5
 sendRequest
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 parseResponse
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 isConfigured
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getProviderName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2declare(strict_types=1);
3
4namespace App\Service\Api\OpenRouter;
5
6use App\Service\Api\AbstractApiService;
7use App\Service\Api\AiProviderInterface;
8use App\Utility\SettingsManager;
9use Cake\Http\Client;
10use Cake\Http\Client\Response;
11
12/**
13 * OpenRouterApiService Class
14 *
15 * Provides integration with OpenRouter's API for accessing AI models.
16 * OpenRouter offers access to various AI models including Anthropic's Claude,
17 * OpenAI's GPT, Google's Gemini, Meta's Llama, and many more through a unified API.
18 *
19 * This service transforms Anthropic-format payloads to OpenRouter's OpenAI-compatible
20 * format and parses responses back to a consistent format.
21 */
22class OpenRouterApiService extends AbstractApiService implements AiProviderInterface
23{
24    /**
25     * The base URL for the OpenRouter API.
26     *
27     * @var string
28     */
29    private const API_URL = 'https://openrouter.ai/api/v1/chat/completions';
30
31    /**
32     * API version placeholder (not used by OpenRouter but required by parent).
33     *
34     * @var string
35     */
36    private const API_VERSION = '1.0';
37
38    /**
39     * OpenRouterApiService constructor.
40     *
41     * Initializes the service with the OpenRouter API key from settings.
42     */
43    public function __construct()
44    {
45        $apiKey = SettingsManager::read('Anthropic.openRouterApiKey', '');
46        parent::__construct(new Client(), $apiKey, self::API_URL, self::API_VERSION);
47    }
48
49    /**
50     * Gets the headers for the OpenRouter API request.
51     *
52     * @return array An associative array of headers.
53     */
54    protected function getHeaders(): array
55    {
56        $headers = [
57            'Authorization' => 'Bearer ' . $this->apiKey,
58            'Content-Type' => 'application/json',
59        ];
60
61        // Optional headers for OpenRouter ranking and rate limits
62        $siteUrl = SettingsManager::read('SEO.siteUrl', '');
63        $siteName = SettingsManager::read('SEO.siteName', 'Willow CMS');
64
65        if (!empty($siteUrl)) {
66            $headers['HTTP-Referer'] = $siteUrl;
67        }
68        $headers['X-Title'] = $siteName;
69
70        return $headers;
71    }
72
73    /**
74     * Transforms an Anthropic-format payload to OpenRouter/OpenAI format.
75     *
76     * Key differences:
77     * - System prompt moves from root 'system' field to a system role message
78     * - Model name is used directly (configured in aiprompts.openrouter_model)
79     *
80     * @param array $payload The Anthropic-format payload.
81     * @return array The OpenRouter/OpenAI-format payload.
82     */
83    private function transformPayload(array $payload): array
84    {
85        $messages = [];
86
87        // Convert system prompt to system message (OpenAI format)
88        if (!empty($payload['system'])) {
89            $messages[] = [
90                'role' => 'system',
91                'content' => $payload['system'],
92            ];
93        }
94
95        // Add user/assistant messages
96        if (isset($payload['messages']) && is_array($payload['messages'])) {
97            foreach ($payload['messages'] as $message) {
98                $messages[] = $message;
99            }
100        }
101
102        return [
103            'model' => $payload['model'] ?? 'anthropic/claude-3.5-sonnet',
104            'messages' => $messages,
105            'max_tokens' => $payload['max_tokens'] ?? 4096,
106            'temperature' => $payload['temperature'] ?? 0,
107        ];
108    }
109
110    /**
111     * Sends a request to the OpenRouter API.
112     *
113     * Overrides the parent method to transform the payload before sending.
114     *
115     * @param array $payload The Anthropic-format payload.
116     * @param int $timeout Request timeout in seconds.
117     * @return \Cake\Http\Client\Response The HTTP response from the API.
118     * @throws \Cake\Http\Exception\ServiceUnavailableException If the API request fails.
119     */
120    public function sendRequest(array $payload, int $timeout = 30): Response
121    {
122        $transformedPayload = $this->transformPayload($payload);
123
124        $response = $this->client->post(
125            $this->apiUrl,
126            json_encode($transformedPayload),
127            [
128                'headers' => $this->getHeaders(),
129                'timeout' => $timeout,
130            ],
131        );
132
133        if (!$response->isOk()) {
134            $this->handleApiError($response);
135        }
136
137        return $response;
138    }
139
140    /**
141     * Parses the OpenRouter API response.
142     *
143     * OpenRouter returns responses in OpenAI format:
144     * { "choices": [{ "message": { "content": "..." } }] }
145     *
146     * The content is expected to be JSON, which is then decoded.
147     *
148     * @param \Cake\Http\Client\Response $response The HTTP response from the API.
149     * @return array The parsed response data.
150     */
151    public function parseResponse(Response $response): array
152    {
153        $responseData = $response->getJson();
154
155        // Extract content from OpenAI-compatible format
156        $content = $responseData['choices'][0]['message']['content'] ?? '';
157
158        // The content should be JSON - decode it
159        $decoded = json_decode($content, true);
160
161        return is_array($decoded) ? $decoded : [];
162    }
163
164    /**
165     * Checks if the provider is properly configured.
166     *
167     * @return bool True if the OpenRouter API key is set and not a placeholder.
168     */
169    public function isConfigured(): bool
170    {
171        return !empty($this->apiKey);
172    }
173
174    /**
175     * Gets the provider name for logging purposes.
176     *
177     * @return string The provider identifier.
178     */
179    public function getProviderName(): string
180    {
181        return 'openrouter';
182    }
183}