Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions packages/agent-providers/openrouter/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# @profullstack/sh1pt-agent-provider-openrouter

**OpenRouter agent provider for [sh1pt](https://sh1pt.com).**

🌐 Homepage: **https://sh1pt.com**
📦 Source: **https://github.com/profullstack/sh1pt**

Routes LLM requests through [OpenRouter](https://openrouter.ai), giving your sh1pt agents access to hundreds of models — GPT-4o, Claude, Llama, DeepSeek, Gemini, and more — through a single unified API.

## Install

```bash
pnpm add @profullstack/sh1pt-agent-provider-openrouter
```

## Configuration

Set the following environment variables:

| Variable | Required | Description |
|---|---|---|
| `OPENROUTER_API_KEY` | ✅ | Your OpenRouter API key (get one at https://openrouter.ai/keys) |
| `OPENROUTER_BASE_URL` | ❌ | Override the default API base URL (default: `https://openrouter.ai/api/v1`) |
| `OPENROUTER_MODEL` | ❌ | Default model to use (default: `openai/gpt-4o-mini`) |
| `OPENROUTER_HTTP_REFERER` | ❌ | Optional `HTTP-Referer` header for OpenRouter rankings |
| `OPENROUTER_X_TITLE` | ❌ | Optional `X-Title` header for OpenRouter rankings |

## Usage

```ts
import { openrouterProvider } from '@profullstack/sh1pt-agent-provider-openrouter';

// Validate environment
openrouterProvider.validateEnv(process.env);

// Chat completion
const response = await openrouterProvider.chat({
messages: [
{ role: 'system', content: 'You are a helpful assistant.' },
{ role: 'user', content: 'Hello!' },
],
});

console.log(response.content);
```

### With sh1pt agent framework

```ts
// In your sh1pt.config.ts
import { openrouterProvider } from '@profullstack/sh1pt-agent-provider-openrouter';

export default {
agents: {
openrouter: openrouterProvider,
},
};
```

## Capabilities

| Capability | Supported |
|---|---|
| Chat | ✅ |
| Streaming | ❌ |
| Tool use | ❌ |
| List models | ❌ (not yet implemented) |

## Error handling

- **`AgentProviderConfigError`** — thrown when `OPENROUTER_API_KEY` is missing
- **`Error`** — thrown when the OpenRouter API returns a non-OK status or an empty response

## Links

- sh1pt: https://sh1pt.com
- OpenRouter: https://openrouter.ai
- OpenRouter API docs: https://openrouter.ai/docs
- Source + issues: https://github.com/profullstack/sh1pt

## License

MIT
216 changes: 216 additions & 0 deletions packages/agent-providers/openrouter/src/provider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import { describe, expect, it, vi } from 'vitest';
import { openrouterProvider } from './provider.js';

describe('openrouter agent provider', () => {
it('declares correct id and displayName', () => {
expect(openrouterProvider.id).toBe('openrouter');
expect(openrouterProvider.displayName).toBe('OpenRouter');
});

it('declares chat capability', () => {
expect(openrouterProvider.capabilities.chat).toBe(true);
});

it('lists required env vars', () => {
const env = openrouterProvider.getRequiredEnv();
expect(env).toEqual(
expect.arrayContaining([
expect.objectContaining({ key: 'OPENROUTER_API_KEY', required: true }),
expect.objectContaining({ key: 'OPENROUTER_BASE_URL', required: false }),
expect.objectContaining({ key: 'OPENROUTER_HTTP_REFERER', required: false }),
expect.objectContaining({ key: 'OPENROUTER_X_TITLE', required: false }),
]),
);
});

it('validateEnv throws when OPENROUTER_API_KEY is missing', () => {
expect(() => openrouterProvider.validateEnv({})).toThrow('Missing OPENROUTER_API_KEY');
});

it('validateEnv passes when OPENROUTER_API_KEY is set', () => {
expect(() => openrouterProvider.validateEnv({ OPENROUTER_API_KEY: 'sk-test-key' })).not.toThrow();
});

it('chat throws AgentProviderConfigError without OPENROUTER_API_KEY', async () => {
const original = process.env.OPENROUTER_API_KEY;
delete process.env.OPENROUTER_API_KEY;

try {
await expect(
openrouterProvider.chat({ messages: [{ role: 'user', content: 'hello' }] }),
).rejects.toThrow('Missing OPENROUTER_API_KEY');
} finally {
if (original) process.env.OPENROUTER_API_KEY = original;
}
});

it('chat calls OpenRouter API and returns content', async () => {
process.env.OPENROUTER_API_KEY = 'sk-test-key';

const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
choices: [{ message: { content: 'Hello from OpenRouter!' } }],
}),
} as any);

try {
const result = await openrouterProvider.chat({
messages: [
{ role: 'system', content: 'You are helpful.' },
{ role: 'user', content: 'Say hi' },
],
});

expect(result.content).toBe('Hello from OpenRouter!');
expect(fetchMock).toHaveBeenCalledWith(
'https://openrouter.ai/api/v1/chat/completions',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: 'Bearer sk-test-key',
'Content-Type': 'application/json',
}),
}),
);

const body = JSON.parse(String((fetchMock.mock.calls[0]![1] as any).body));
expect(body.model).toBe('openai/gpt-4o-mini');
expect(body.messages).toHaveLength(2);
} finally {
delete process.env.OPENROUTER_API_KEY;
fetchMock.mockRestore();
}
});

it('chat respects OPENROUTER_MODEL env var', async () => {
process.env.OPENROUTER_API_KEY = 'sk-test-key';
process.env.OPENROUTER_MODEL = 'anthropic/claude-3.5-sonnet';

const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
choices: [{ message: { content: 'Claude response' } }],
}),
} as any);

try {
await openrouterProvider.chat({
messages: [{ role: 'user', content: 'test' }],
});

const body = JSON.parse(String((fetchMock.mock.calls[0]![1] as any).body));
expect(body.model).toBe('anthropic/claude-3.5-sonnet');
} finally {
delete process.env.OPENROUTER_API_KEY;
delete process.env.OPENROUTER_MODEL;
fetchMock.mockRestore();
}
});

it('chat sends optional HTTP-Referer and X-Title headers', async () => {
process.env.OPENROUTER_API_KEY = 'sk-test-key';
process.env.OPENROUTER_HTTP_REFERER = 'https://myapp.com';
process.env.OPENROUTER_X_TITLE = 'My App';

const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
choices: [{ message: { content: 'ok' } }],
}),
} as any);

try {
await openrouterProvider.chat({
messages: [{ role: 'user', content: 'test' }],
});

const init = fetchMock.mock.calls[0]![1] as any;
expect(init.headers['HTTP-Referer']).toBe('https://myapp.com');
expect(init.headers['X-Title']).toBe('My App');
} finally {
delete process.env.OPENROUTER_API_KEY;
delete process.env.OPENROUTER_HTTP_REFERER;
delete process.env.OPENROUTER_X_TITLE;
fetchMock.mockRestore();
}
});

it('chat throws on non-OK API response', async () => {
process.env.OPENROUTER_API_KEY = 'sk-test-key';

const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: false,
status: 429,
statusText: 'Too Many Requests',
} as any);

try {
await expect(
openrouterProvider.chat({ messages: [{ role: 'user', content: 'test' }] }),
).rejects.toThrow('OpenRouter chat 429');
} finally {
delete process.env.OPENROUTER_API_KEY;
fetchMock.mockRestore();
}
});

it('chat throws on empty response', async () => {
process.env.OPENROUTER_API_KEY = 'sk-test-key';

const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ choices: [{ message: { content: null } }] }),
} as any);

try {
await expect(
openrouterProvider.chat({ messages: [{ role: 'user', content: 'test' }] }),
).rejects.toThrow('OpenRouter empty response');
} finally {
delete process.env.OPENROUTER_API_KEY;
fetchMock.mockRestore();
}
});

it('chat respects OPENROUTER_BASE_URL env var', async () => {
process.env.OPENROUTER_API_KEY = 'sk-test-key';
process.env.OPENROUTER_BASE_URL = 'https://custom-gateway.example.com/v1';

const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
choices: [{ message: { content: 'custom gateway' } }],
}),
} as any);

try {
await openrouterProvider.chat({
messages: [{ role: 'user', content: 'test' }],
});

expect(fetchMock).toHaveBeenCalledWith(
'https://custom-gateway.example.com/v1/chat/completions',
expect.anything(),
);
} finally {
delete process.env.OPENROUTER_API_KEY;
delete process.env.OPENROUTER_BASE_URL;
fetchMock.mockRestore();
}
});

it('healthcheck returns ok when env is validated', async () => {
const result = await openrouterProvider.healthcheck();
expect(result.ok).toBe(true);
});

it('listModels throws not implemented', async () => {
await expect(openrouterProvider.listModels()).rejects.toThrow('not implemented');
});
});
Loading