Skip to content

Commit 188241d

Browse files
committed
feat: add OpenRouter adapter
Adds OpenRouter as a new AI provider, extending the OpenAI adapter with custom HTTP headers (HTTP-Referer, X-Title) and provider-prefixed model ID normalization. Includes a PHP sync script to generate model constants from the live OpenRouter API catalog.
1 parent 0522279 commit 188241d

8 files changed

Lines changed: 965 additions & 9 deletions

File tree

.env.example

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ LLM_KEY_OPENAI=sk-proj-1234567890
33
LLM_KEY_DEEPSEEK=sk-1234567890
44
LLM_KEY_XAI=xai-1234567890
55
LLM_KEY_PERPLEXITY=pplx-1234567890
6-
LLM_KEY_GEMINI=AI1234567890
6+
LLM_KEY_GEMINI=AI1234567890
7+
LLM_KEY_OPENROUTER=sk-or-v1-1234567890

README.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ Utopia Framework requires PHP 8.0 or later. We recommend using the latest PHP ve
2121

2222
## Features
2323

24-
- **Multiple AI Providers** - Support for OpenAI, Anthropic, Deepseek, Perplexity, and XAI APIs
24+
- **Multiple AI Providers** - Support for OpenAI, Anthropic, Deepseek, Perplexity, XAI, Gemini, and OpenRouter APIs
2525
- **Flexible Message Types** - Support for text and structured content in messages
2626
- **Conversation Management** - Easy-to-use conversation handling between agents and users
2727
- **Model Selection** - Choose from various AI models (GPT-4, Claude 3, Deepseek Chat, Sonar, Grok, etc.)
@@ -155,6 +155,28 @@ Available XAI Models:
155155
- `MODEL_GROK_3_MINI`: Mini version of Grok model
156156
- `MODEL_GROK_2_IMAGE`: Latest Grok model with image support
157157

158+
#### OpenRouter
159+
160+
```php
161+
use Utopia\Agents\Adapters\OpenRouter;
162+
use Utopia\Agents\Adapters\OpenRouter\Models as OpenRouterModels;
163+
164+
$openrouter = new OpenRouter(
165+
apiKey: 'your-api-key',
166+
model: OpenRouterModels::MODEL_OPENAI_GPT_4O,
167+
maxTokens: 2048,
168+
temperature: 0.7,
169+
httpReferer: 'https://your-app.example',
170+
xTitle: 'Your App Name'
171+
);
172+
```
173+
174+
- Named constants are provided for popular models from major providers (OpenAI, Anthropic, Google, Meta, DeepSeek, Mistral, xAI)
175+
- `Models::MODELS` contains the full model catalog; the adapter defaults to `openai/gpt-4o`
176+
- Arbitrary model IDs like `'openai/gpt-5-nano'` or `'anthropic/claude-sonnet-4'` are also accepted directly
177+
- `httpReferer` and `xTitle` are optional and enable OpenRouter app attribution headers
178+
- To re-sync constants from the live OpenRouter API, run `php scripts/sync-openrouter-models.php`
179+
158180
### Managing Conversations
159181

160182
```php

docker-compose.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ services:
1515
- LLM_KEY_XAI=${LLM_KEY_XAI:-}
1616
- LLM_KEY_PERPLEXITY=${LLM_KEY_PERPLEXITY:-}
1717
- LLM_KEY_GEMINI=${LLM_KEY_GEMINI:-}
18+
- LLM_KEY_OPENROUTER=${LLM_KEY_OPENROUTER:-}
1819
depends_on:
1920
- ollama
2021
networks:

scripts/sync-openrouter-models.php

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
#!/usr/bin/env php
2+
<?php
3+
4+
/**
5+
* Fetches the OpenRouter model catalog and generates Constants.php
6+
*
7+
* Usage: php scripts/sync-openrouter-models.php [output-path]
8+
*
9+
* Only models from curated providers get named constants.
10+
* The full catalog is available via the MODELS array.
11+
*/
12+
$endpoint = getenv('OPENROUTER_MODELS_ENDPOINT') ?: 'https://openrouter.ai/api/v1/models';
13+
$defaultOutput = __DIR__.'/../src/Agents/Adapters/OpenRouter/Models.php';
14+
$outputPath = $argv[1] ?? $defaultOutput;
15+
16+
// Providers whose models get named class constants
17+
$curatedProviders = [
18+
'anthropic',
19+
'openai',
20+
'google',
21+
'meta-llama',
22+
'deepseek',
23+
'mistralai',
24+
'x-ai',
25+
];
26+
27+
// Skip model IDs matching these patterns (old/niche variants)
28+
$skipPatterns = [
29+
'/:extended$/', // extended-context variants
30+
'/:free$/', // free-tier duplicates
31+
'/:beta$/', // beta tags
32+
'/-\d{4}-\d{2}-\d{2}/', // date-pinned snapshots (e.g. gpt-4o-2024-08-06)
33+
'/-\d{4}$/', // short date pins (e.g. gpt-4-0314)
34+
'/-\d{4}-preview/', // date preview variants (e.g. gpt-4-1106-preview)
35+
'/gpt-3\.5/', // legacy GPT-3.5 models
36+
'/gpt-4-/', // legacy GPT-4 variants (gpt-4-turbo etc)
37+
'/gpt-4-turbo/', // legacy GPT-4 turbo
38+
'/-preview$/', // generic preview suffixes
39+
];
40+
41+
$headers = ['Accept: application/json'];
42+
43+
$apiKey = getenv('OPENROUTER_API_KEY') ?: getenv('LLM_KEY_OPENROUTER');
44+
if ($apiKey) {
45+
$headers[] = "Authorization: Bearer {$apiKey}";
46+
}
47+
48+
$context = stream_context_create([
49+
'http' => [
50+
'header' => implode("\r\n", $headers),
51+
'timeout' => 30,
52+
],
53+
]);
54+
55+
$response = file_get_contents($endpoint, false, $context);
56+
if ($response === false) {
57+
fwrite(STDERR, "Failed to fetch OpenRouter models from {$endpoint}\n");
58+
exit(1);
59+
}
60+
61+
$payload = json_decode($response, true);
62+
if (! is_array($payload) || ! isset($payload['data']) || ! is_array($payload['data'])) {
63+
fwrite(STDERR, "OpenRouter models response did not include a data array\n");
64+
exit(1);
65+
}
66+
67+
$models = array_filter($payload['data'], fn ($m) => is_array($m) && isset($m['id']) && is_string($m['id']) && $m['id'] !== '');
68+
$models = array_values($models);
69+
usort($models, fn ($a, $b) => strcmp($a['id'], $b['id']));
70+
71+
if (count($models) === 0) {
72+
fwrite(STDERR, "OpenRouter models response was empty\n");
73+
exit(1);
74+
}
75+
76+
$modelIds = array_map(fn ($m) => $m['id'], $models);
77+
78+
/**
79+
* Convert a model ID to a PHP constant name (MODEL_PROVIDER_NAME).
80+
*/
81+
function toConstantName(string $id): string
82+
{
83+
$name = strtoupper($id);
84+
$name = preg_replace('/[^A-Z0-9]+/', '_', $name);
85+
$name = preg_replace('/_+/', '_', $name);
86+
$name = trim($name, '_');
87+
88+
if ($name === '' || preg_match('/^[0-9]/', $name)) {
89+
$name = 'MODEL_'.$name;
90+
}
91+
92+
return "MODEL_{$name}";
93+
}
94+
95+
// Build curated constants (named) and the full ID list
96+
$curatedConstants = []; // name => id
97+
$usedNames = [];
98+
99+
foreach ($modelIds as $id) {
100+
$provider = explode('/', $id, 2)[0];
101+
102+
if (! in_array($provider, $curatedProviders, true)) {
103+
continue;
104+
}
105+
106+
// Skip date-pinned snapshots, free/beta/extended variants
107+
$dominated = false;
108+
foreach ($skipPatterns as $pattern) {
109+
if (preg_match($pattern, $id)) {
110+
$dominated = true;
111+
break;
112+
}
113+
}
114+
if ($dominated) {
115+
continue;
116+
}
117+
118+
$name = toConstantName($id);
119+
120+
if (isset($usedNames[$name])) {
121+
$name .= '_'.strtoupper(substr(sha1($id), 0, 8));
122+
}
123+
124+
$usedNames[$name] = true;
125+
$curatedConstants[$name] = $id;
126+
}
127+
128+
// Generate PHP
129+
$now = gmdate('Y-m-d\TH:i:s\Z');
130+
$totalCount = count($modelIds);
131+
$curatedCount = count($curatedConstants);
132+
133+
$lines = [];
134+
$lines[] = '<?php';
135+
$lines[] = '';
136+
$lines[] = 'namespace Utopia\Agents\Adapters\OpenRouter;';
137+
$lines[] = '';
138+
$lines[] = '/**';
139+
$lines[] = ' * Generated by scripts/sync-openrouter-models.php — do not edit by hand.';
140+
$lines[] = " * Source: {$endpoint}";
141+
$lines[] = " * Synced at: {$now}";
142+
$lines[] = " * Named constants: {$curatedCount} (curated providers)";
143+
$lines[] = " * Total models: {$totalCount}";
144+
$lines[] = ' */';
145+
$lines[] = 'final class Models';
146+
$lines[] = '{';
147+
148+
// Named constants grouped by provider
149+
$currentProvider = '';
150+
foreach ($curatedConstants as $name => $id) {
151+
$provider = explode('/', $id, 2)[0];
152+
if ($provider !== $currentProvider) {
153+
if ($currentProvider !== '') {
154+
$lines[] = '';
155+
}
156+
$lines[] = " // {$provider}";
157+
$currentProvider = $provider;
158+
}
159+
$lines[] = " public const {$name} = '{$id}';";
160+
}
161+
162+
$lines[] = '';
163+
// No DEFAULT_MODEL — the default is set in OpenRouter::__construct()
164+
165+
// Full MODELS array as plain strings
166+
$lines[] = '';
167+
$lines[] = ' /**';
168+
$lines[] = ' * Full model catalog. Use model IDs directly or via named constants above.';
169+
$lines[] = ' *';
170+
$lines[] = ' * @var list<string>';
171+
$lines[] = ' */';
172+
$lines[] = ' public const MODELS = [';
173+
foreach ($modelIds as $id) {
174+
$lines[] = " '{$id}',";
175+
}
176+
$lines[] = ' ];';
177+
$lines[] = '}';
178+
$lines[] = '';
179+
180+
$dir = dirname($outputPath);
181+
if (! is_dir($dir)) {
182+
mkdir($dir, 0755, true);
183+
}
184+
185+
file_put_contents($outputPath, implode("\n", $lines));
186+
187+
echo "Wrote {$curatedCount} named constants + {$totalCount} model IDs to {$outputPath}\n";

src/Agents/Adapters/OpenAI.php

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -109,11 +109,7 @@ public function send(array $messages, ?callable $listener = null): Message
109109
throw new \Exception('Agent not set');
110110
}
111111

112-
$client = new Client();
113-
$client
114-
->setTimeout($this->timeout)
115-
->addHeader('authorization', 'Bearer '.$this->apiKey)
116-
->addHeader('content-type', Client::CONTENT_TYPE_APPLICATION_JSON);
112+
$client = $this->createClient();
117113

118114
$formattedMessages = [];
119115
foreach ($messages as $message) {
@@ -304,7 +300,7 @@ public function getModels(): array
304300
*/
305301
protected function usesMaxCompletionTokens(): bool
306302
{
307-
return in_array($this->model, [
303+
return in_array($this->normalizeModelForCompatibilityChecks(), [
308304
self::MODEL_GPT_5_NANO,
309305
self::MODEL_O4_MINI,
310306
self::MODEL_O3,
@@ -317,7 +313,7 @@ protected function usesMaxCompletionTokens(): bool
317313
*/
318314
protected function usesDefaultTemperatureOnly(): bool
319315
{
320-
$usesDefaultTemperatureOnly = in_array($this->model, [
316+
$usesDefaultTemperatureOnly = in_array($this->normalizeModelForCompatibilityChecks(), [
321317
self::MODEL_GPT_5_NANO,
322318
], true);
323319

@@ -333,6 +329,28 @@ protected function usesDefaultTemperatureOnly(): bool
333329
return $usesDefaultTemperatureOnly;
334330
}
335331

332+
/**
333+
* Create a configured HTTP client for API requests.
334+
*/
335+
protected function createClient(): Client
336+
{
337+
$client = new Client();
338+
$client
339+
->setTimeout($this->timeout)
340+
->addHeader('authorization', 'Bearer '.$this->apiKey)
341+
->addHeader('content-type', Client::CONTENT_TYPE_APPLICATION_JSON);
342+
343+
return $client;
344+
}
345+
346+
/**
347+
* Normalize the current model name for provider compatibility checks.
348+
*/
349+
protected function normalizeModelForCompatibilityChecks(): string
350+
{
351+
return $this->model;
352+
}
353+
336354
/**
337355
* Get current model
338356
*/

src/Agents/Adapters/OpenRouter.php

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
<?php
2+
3+
namespace Utopia\Agents\Adapters;
4+
5+
use Utopia\Agents\Adapters\OpenRouter\Models as OpenRouterModels;
6+
use Utopia\Fetch\Client;
7+
8+
class OpenRouter extends OpenAI
9+
{
10+
/**
11+
* Default OpenRouter API endpoint
12+
*/
13+
protected const ENDPOINT = 'https://openrouter.ai/api/v1/chat/completions';
14+
15+
protected ?string $httpReferer;
16+
17+
protected ?string $xTitle;
18+
19+
/**
20+
* Create a new OpenRouter adapter
21+
*
22+
* @throws \Exception
23+
*/
24+
public function __construct(
25+
string $apiKey,
26+
string $model = OpenRouterModels::MODEL_OPENAI_GPT_4O,
27+
int $maxTokens = 1024,
28+
float $temperature = 1.0,
29+
?string $endpoint = null,
30+
int $timeout = 90000,
31+
?string $httpReferer = null,
32+
?string $xTitle = null
33+
) {
34+
$this->httpReferer = $httpReferer;
35+
$this->xTitle = $xTitle;
36+
37+
parent::__construct(
38+
$apiKey,
39+
$model,
40+
$maxTokens,
41+
$temperature,
42+
$endpoint ?? self::ENDPOINT,
43+
$timeout
44+
);
45+
}
46+
47+
/**
48+
* Get available models
49+
*
50+
* @return array<string>
51+
*/
52+
public function getModels(): array
53+
{
54+
return OpenRouterModels::MODELS;
55+
}
56+
57+
/**
58+
* Get the adapter name
59+
*/
60+
public function getName(): string
61+
{
62+
return 'openrouter';
63+
}
64+
65+
/**
66+
* Create a configured HTTP client for OpenRouter requests.
67+
*/
68+
protected function createClient(): Client
69+
{
70+
$client = parent::createClient();
71+
72+
if (! empty($this->httpReferer)) {
73+
$client->addHeader('HTTP-Referer', $this->httpReferer);
74+
}
75+
76+
if (! empty($this->xTitle)) {
77+
$client->addHeader('X-Title', $this->xTitle);
78+
}
79+
80+
return $client;
81+
}
82+
83+
/**
84+
* Strip provider prefixes from routed model IDs before OpenAI-specific checks.
85+
*/
86+
protected function normalizeModelForCompatibilityChecks(): string
87+
{
88+
$parts = explode('/', $this->model, 2);
89+
90+
return $parts[1] ?? $this->model;
91+
}
92+
}

0 commit comments

Comments
 (0)