From 974c38731aff42bc4c335047111fe005e1a32d3a Mon Sep 17 00:00:00 2001 From: Mayank Saini Date: Mon, 10 Aug 2026 03:47:55 +0530 Subject: [PATCH 1/8] feat(openrouter): add OpenRouter plugin with 14 endpoints --- packages/corsair/core/constants.ts | 3 + packages/openrouter/README.md | 77 ++ packages/openrouter/api.test.ts | 799 ++++++++++++++++++ packages/openrouter/client.ts | 70 ++ .../openrouter/endpoints/chat-completions.ts | 43 + packages/openrouter/endpoints/credits.ts | 48 ++ packages/openrouter/endpoints/embeddings.ts | 25 + packages/openrouter/endpoints/generations.ts | 19 + packages/openrouter/endpoints/index.ts | 61 ++ packages/openrouter/endpoints/key.ts | 9 + packages/openrouter/endpoints/messages.ts | 29 + .../openrouter/endpoints/model-endpoints.ts | 13 + packages/openrouter/endpoints/models.ts | 60 ++ packages/openrouter/endpoints/providers.ts | 15 + packages/openrouter/endpoints/types.ts | 562 ++++++++++++ packages/openrouter/endpoints/zdr.ts | 17 + packages/openrouter/error-handlers.ts | 75 ++ packages/openrouter/index.ts | 330 ++++++++ packages/openrouter/jest.config.cjs | 55 ++ packages/openrouter/package.json | 44 + packages/openrouter/schema.test.ts | 20 + packages/openrouter/schema/index.ts | 4 + packages/openrouter/tsconfig.json | 20 + packages/openrouter/tsup.config.ts | 15 + pnpm-lock.yaml | 55 +- 25 files changed, 2440 insertions(+), 28 deletions(-) create mode 100644 packages/openrouter/README.md create mode 100644 packages/openrouter/api.test.ts create mode 100644 packages/openrouter/client.ts create mode 100644 packages/openrouter/endpoints/chat-completions.ts create mode 100644 packages/openrouter/endpoints/credits.ts create mode 100644 packages/openrouter/endpoints/embeddings.ts create mode 100644 packages/openrouter/endpoints/generations.ts create mode 100644 packages/openrouter/endpoints/index.ts create mode 100644 packages/openrouter/endpoints/key.ts create mode 100644 packages/openrouter/endpoints/messages.ts create mode 100644 packages/openrouter/endpoints/model-endpoints.ts create mode 100644 packages/openrouter/endpoints/models.ts create mode 100644 packages/openrouter/endpoints/providers.ts create mode 100644 packages/openrouter/endpoints/types.ts create mode 100644 packages/openrouter/endpoints/zdr.ts create mode 100644 packages/openrouter/error-handlers.ts create mode 100644 packages/openrouter/index.ts create mode 100644 packages/openrouter/jest.config.cjs create mode 100644 packages/openrouter/package.json create mode 100644 packages/openrouter/schema.test.ts create mode 100644 packages/openrouter/schema/index.ts create mode 100644 packages/openrouter/tsconfig.json create mode 100644 packages/openrouter/tsup.config.ts diff --git a/packages/corsair/core/constants.ts b/packages/corsair/core/constants.ts index e33ec2ca8..b656c74eb 100644 --- a/packages/corsair/core/constants.ts +++ b/packages/corsair/core/constants.ts @@ -78,6 +78,7 @@ export const BaseProviders = [ 'onedrive', 'onepassword', 'openai', + 'openrouter', 'openweathermap', 'oura', 'outlook', @@ -182,6 +183,7 @@ export const ProviderDisplayNames = { onedrive: 'OneDrive', onepassword: '1Password', openai: 'OpenAI', + openrouter: 'Openrouter', openweathermap: 'OpenWeatherMap', oura: 'Oura', outlook: 'Outlook', @@ -293,6 +295,7 @@ export type AllProviders = | 'onedrive' | 'onepassword' | 'openai' + | 'openrouter' | 'openweathermap' | 'oura' | 'outlook' diff --git a/packages/openrouter/README.md b/packages/openrouter/README.md new file mode 100644 index 000000000..a5b159a57 --- /dev/null +++ b/packages/openrouter/README.md @@ -0,0 +1,77 @@ +# @corsair-dev/openrouter + +Corsair plugin for the [OpenRouter API](https://openrouter.ai/docs). + +## Auth setup + +API-key only. + +1. Create a key at [openrouter.ai/keys](https://openrouter.ai/keys) +2. Set `OPENROUTER_API_KEY` in your environment, or pass the key via Corsair credentials + +Credentials are sent as `Authorization: Bearer `. + +Missing credentials throw `AuthMissingError` (never an empty string). + +## Endpoint overview + +| Operation | OpenRouter path | Description | +|-----------|-----------------|-------------| +| `chatCompletions.create` | `POST /chat/completions` | Chat completions with multi-provider routing, fallbacks, tool calling, and structured output | +| `messages.create` | `POST /messages` | Anthropic Messages API — chat with system prompts and multi-part content | +| `models.list` | `GET /models` | List all models with pricing, context length, and supported parameters | +| `models.count` | `GET /models/count` | Total count of models available on OpenRouter | +| `models.listEmbeddings` | `GET /embeddings/models` | List all embedding models | +| `models.listUser` | `GET /models/user` | List models created by the authenticated user | +| `embeddings.create` | `POST /embeddings` | Generate vector embeddings | +| `modelEndpoints.list` | `GET /models/{author}/{slug}/endpoints` | Per-provider endpoints for a model (pricing, latency, throughput) | +| `providers.list` | `GET /providers` | List providers with privacy policies and data-center regions | +| `zdr.list` | `GET /endpoints/zdr` | Zero-Data Residency (ZDR) endpoint specification for the account | +| `generations.get` | `GET /generation?id={id}` | Request & usage metadata for a previous generation | +| `credits.list` | `GET /credits` | Credit balance & usage; optional Zero-Data Residency (ZDR) report filters | +| `credits.createCoinbaseCharge` | `POST /credits/coinbase` | Create a Coinbase Commerce on-chain charge to top up credits | +| `key.get` | `GET /key` | API key metadata (usage, limits, rate limits) | + +No webhooks (OpenRouter's API surface is token-only; there are no signed +inbound events to subscribe to). + +## Quirks & caveats + +- **Streaming is off by default.** Completion and message calls send + `stream: false` so responses are a single JSON body (not SSE events). +- **Routing happens automatically.** OpenRouter picks the provider unless you + pass `provider.order` / `provider.ignore` or pin `models` / `route`. +- **Model availability varies by key.** Free-tier keys only reach a subset of + providers; `models.list` returns what the key can access. +- **Chat calls cost credits.** Listing models/providers/credits/key works even + at `$0` balance; chat, message, and embedding calls return HTTP 402 if the + account has insufficient credits. +- **HTTP 529 (overloaded) is retried** like other 5xx errors with exponential + backoff (up to 3 attempts). +- **Balances are `data`-wrapped.** `/credits` and `/key` return their payload + under a `data` key (e.g. `{ data: { total_credits, total_usage } }`). + +## Tests + +```bash +pnpm --filter @corsair-dev/openrouter test +``` + +- Offline schema + mocked-client handler tests always run (no API key). +- Live client tests run only when `OPENROUTER_API_KEY` is set. + +## Live demo + +```bash +# PowerShell +$env:OPENROUTER_API_KEY = "sk-or-..." +pnpm --filter @corsair-dev/openrouter demo + +# bash +export OPENROUTER_API_KEY=sk-or-... +pnpm --filter @corsair-dev/openrouter demo +``` + +The demo (when added) hits key operations against +`https://openrouter.ai/api/v1`. Chat steps need non-zero credits on the +OpenRouter account. \ No newline at end of file diff --git a/packages/openrouter/api.test.ts b/packages/openrouter/api.test.ts new file mode 100644 index 000000000..303a1fdcf --- /dev/null +++ b/packages/openrouter/api.test.ts @@ -0,0 +1,799 @@ +import { makeOpenRouterRequest } from './client'; +import { + ChatCompletions, + Credits, + Embeddings, + Generations, + Key, + Messages, + ModelEndpoints, + Models, + Providers, + Zdr, +} from './endpoints'; +import type { + CreateAnthropicMessageResponse, + CreateChatCompletionResponse, + CreateCoinbaseChargeResponse, + CreateEmbeddingOutput, + GetKeyResponse, + ListCreditsResponse, + ListEmbeddingModelsResponse, + ListModelEndpointsResponse, + ListModelsCountResponse, + ListModelsResponse, + ListProvidersResponse, + ListUserModelsResponse, + ListZdrEndpointsResponse, +} from './endpoints/types'; +import { + OpenRouterEndpointInputSchemas, + OpenRouterEndpointOutputSchemas, +} from './endpoints/types'; +import type { OpenrouterContext } from './index'; + +// Handler tests mock the client; live tests (gated on OPENROUTER_API_KEY) +// fall through to the real implementation via jest.requireActual. +jest.mock('./client', () => ({ + makeOpenRouterRequest: jest.fn().mockImplementation((...args: unknown[]) => { + const actual = jest.requireActual('./client'); + return actual.makeOpenRouterRequest( + args[0] as string, + args[1] as string, + args[2] as + | { + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + body?: Record; + query?: Record; + } + | undefined, + ); + }), +})); + +const mockRequest = makeOpenRouterRequest as jest.MockedFunction< + typeof makeOpenRouterRequest +>; + +/** Minimal plugin context for live endpoint-handler tests. */ +function testCtx(key: string): OpenrouterContext { + return { key } as OpenrouterContext; +} + +describe('OpenRouter schemas', () => { + it('parses chatCompletions.create input and response', () => { + const input = + OpenRouterEndpointInputSchemas.chatCompletionsCreate.safeParse({ + model: 'openai/gpt-4o-mini', + messages: [{ role: 'user', content: 'Hello' }], + provider: { order: ['OpenAI'], allow_fallbacks: false }, + }); + expect(input.success).toBe(true); + + const output = + OpenRouterEndpointOutputSchemas.chatCompletionsCreate.safeParse({ + id: 'gen-1', + object: 'chat.completion', + created: 1700000000, + model: 'openai/gpt-4o-mini', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Hi there' }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + provider: 'OpenAI', + }); + expect(output.success).toBe(true); + }); + + it('parses messages.create input and response', () => { + const input = OpenRouterEndpointInputSchemas.messagesCreate.safeParse({ + model: 'openai/gpt-4o-mini', + maxTokens: 1024, + messages: [{ role: 'user', content: 'Hello' }], + system: 'Be brief', + }); + expect(input.success).toBe(true); + + const output = OpenRouterEndpointOutputSchemas.messagesCreate.safeParse({ + id: 'gen-1', + type: 'message', + role: 'assistant', + model: 'openai/gpt-4o-mini', + stop_reason: 'end_turn', + content: [{ type: 'text', text: 'Hi there' }], + usage: { input_tokens: 13, output_tokens: 2 }, + provider: 'OpenAI', + }); + expect(output.success).toBe(true); + }); + + it('parses models.list input and response', () => { + const input = OpenRouterEndpointInputSchemas.modelsList.safeParse({}); + expect(input.success).toBe(true); + + const output = OpenRouterEndpointOutputSchemas.modelsList.safeParse({ + data: [ + { + id: 'openai/gpt-4o-mini', + name: 'OpenAI: GPT-4o mini', + created: 1710000000, + context_length: 128000, + per_request_limits: null, + pricing: { prompt: '0.00000015', completion: '0.0000006' }, + }, + ], + }); + expect(output.success).toBe(true); + }); + + it('parses embeddings.create input and response', () => { + const input = OpenRouterEndpointInputSchemas.embeddingsCreate.safeParse({ + model: 'openai/text-embedding-3-small', + input: 'Hello world', + }); + expect(input.success).toBe(true); + + const output = OpenRouterEndpointOutputSchemas.embeddingsCreate.safeParse({ + id: 'emb-1', + object: 'list', + data: [{ object: 'embedding', embedding: [0.1, 0.2, 0.3] }], + model: 'openai/text-embedding-3-small', + usage: { prompt_tokens: 2, completion_tokens: 0, total_tokens: 2 }, + }); + expect(output.success).toBe(true); + }); + + it('parses modelEndpoints.list input and response', () => { + const input = OpenRouterEndpointInputSchemas.modelsEndpointsList.safeParse({ + author: 'openai', + slug: 'gpt-4o-mini', + }); + expect(input.success).toBe(true); + + const output = + OpenRouterEndpointOutputSchemas.modelsEndpointsList.safeParse({ + data: { + id: 'openai/gpt-4o-mini', + name: 'OpenAI: GPT-4o mini', + created: 1721260800, + endpoints: [ + { + name: 'OpenAI | openai/gpt-4o-mini', + provider_name: 'OpenAI', + context_length: 128000, + pricing: { prompt: '0.00000015', completion: '0.0000006' }, + supported_parameters: ['temperature', 'tools'], + max_completion_tokens: 16384, + }, + ], + }, + }); + expect(output.success).toBe(true); + }); + + it('parses providers.list input and response', () => { + const input = OpenRouterEndpointInputSchemas.providersList.safeParse({}); + expect(input.success).toBe(true); + + const output = OpenRouterEndpointOutputSchemas.providersList.safeParse({ + data: [ + { name: 'OpenAI', slug: 'openai' }, + { name: 'Moonshot AI', slug: 'moonshotai', datacenters: ['SG'] }, + ], + }); + expect(output.success).toBe(true); + }); + + it('parses generations.get input and response', () => { + const input = OpenRouterEndpointInputSchemas.generationsGet.safeParse({ + id: 'gen-1', + }); + expect(input.success).toBe(true); + + const output = OpenRouterEndpointOutputSchemas.generationsGet.safeParse({ + data: { + id: 'gen-1', + model: 'openai/gpt-4o-mini', + provider: 'OpenAI', + created_at: '2024-01-01T00:00:00Z', + }, + }); + expect(output.success).toBe(true); + }); + + it('parses credits.list input and response', () => { + const input = OpenRouterEndpointInputSchemas.creditsList.safeParse({ + query: '2024-01-01', + }); + expect(input.success).toBe(true); + + const output = OpenRouterEndpointOutputSchemas.creditsList.safeParse({ + data: { total_credits: 10, total_usage: 3.5 }, + }); + expect(output.success).toBe(true); + }); + + it('parses key.get input and response', () => { + const input = OpenRouterEndpointInputSchemas.keyGet.safeParse({}); + expect(input.success).toBe(true); + + const output = OpenRouterEndpointOutputSchemas.keyGet.safeParse({ + data: { + label: 'test-key', + usage: 0.0000075, + limit: null, + is_free_tier: true, + }, + }); + expect(output.success).toBe(true); + }); + + it('parses models.count input and response', () => { + const input = OpenRouterEndpointInputSchemas.modelsCount.safeParse({}); + expect(input.success).toBe(true); + + const output = OpenRouterEndpointOutputSchemas.modelsCount.safeParse({ + data: { count: 400 }, + }); + expect(output.success).toBe(true); + }); + + it('parses models.listEmbeddings input and response', () => { + const input = OpenRouterEndpointInputSchemas.modelsEmbeddingsList.safeParse( + { offset: 0, limit: 10 }, + ); + expect(input.success).toBe(true); + + const output = + OpenRouterEndpointOutputSchemas.modelsEmbeddingsList.safeParse({ + data: [ + { + id: 'openai/text-embedding-3-small', + name: 'OpenAI: text-embedding-3-small', + created: 1700000000, + context_length: 8191, + per_request_limits: null, + pricing: { prompt: '0.00000002' }, + }, + ], + }); + expect(output.success).toBe(true); + }); + + it('parses models.listUser input and response', () => { + const input = OpenRouterEndpointInputSchemas.modelsUserList.safeParse({}); + expect(input.success).toBe(true); + + const output = OpenRouterEndpointOutputSchemas.modelsUserList.safeParse({ + data: [ + { + id: 'myorg/custom-model', + name: 'Custom model', + created: 1700000000, + }, + ], + }); + expect(output.success).toBe(true); + }); + + it('parses zdr.list input and response', () => { + const input = OpenRouterEndpointInputSchemas.zdrEndpointsList.safeParse({}); + expect(input.success).toBe(true); + + const output = OpenRouterEndpointOutputSchemas.zdrEndpointsList.safeParse({ + data: [ + { + name: 'ZDR region | openai/gpt-4o-mini', + provider_name: 'OpenAI', + context_length: 128000, + }, + ], + }); + expect(output.success).toBe(true); + }); + + it('parses credits.createCoinbaseCharge input and response', () => { + const input = + OpenRouterEndpointInputSchemas.creditsCoinbaseCreate.safeParse({ + amount: 50.25, + sender: '0x1234567890123456789012345678901234567890', + chainId: 8453, + }); + expect(input.success).toBe(true); + + const invalid = + OpenRouterEndpointInputSchemas.creditsCoinbaseCreate.safeParse({ + amount: 10, + sender: '0x1234', + chainId: 999, + }); + expect(invalid.success).toBe(false); + + const output = + OpenRouterEndpointOutputSchemas.creditsCoinbaseCreate.safeParse({ + data: { + id: 'charge-id', + chain_id: 8453, + sender: '0x1234567890123456789012345678901234567890', + addresses: { + '8453:0xcharge123': '0xcharge123', + }, + calldata: { + '8453:0xcharge123': '0xdeadbeef', + }, + created_at: '2026-01-01T00:00:00Z', + expires_at: '2026-01-08T00:00:00Z', + }, + }); + expect(output.success).toBe(true); + }); + + it('rejects invalid chatCompletions.create input', () => { + const invalid = + OpenRouterEndpointInputSchemas.chatCompletionsCreate.safeParse({ + model: 'openai/gpt-4o-mini', + // messages required — empty object should fail + }); + expect(invalid.success).toBe(false); + }); +}); + +describe('OpenRouter endpoint handlers (mocked client)', () => { + beforeEach(() => { + mockRequest.mockClear(); + }); + + it('ChatCompletions.createChatCompletion POSTs to chat/completions', async () => { + const response = { + id: 'gen-1', + object: 'chat.completion', + created: 1700000000, + model: 'openai/gpt-4o-mini', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Hi' }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + } as CreateChatCompletionResponse; + mockRequest.mockResolvedValueOnce(response); + + const result = await ChatCompletions.createChatCompletion(testCtx('k'), { + model: 'openai/gpt-4o-mini', + messages: [{ role: 'user', content: 'Hi' }], + temperature: 0.5, + provider: { order: ['OpenAI'] }, + }); + + expect(mockRequest).toHaveBeenCalledWith( + 'chat/completions', + 'k', + expect.objectContaining({ + method: 'POST', + body: expect.objectContaining({ + model: 'openai/gpt-4o-mini', + stream: false, + temperature: 0.5, + provider: { order: ['OpenAI'] }, + }), + }), + ); + const parsed = + OpenRouterEndpointOutputSchemas.chatCompletionsCreate.safeParse(result); + expect(parsed.success).toBe(true); + }); + + it('Messages.createAnthropicMessage POSTs to messages', async () => { + const response = { + id: 'gen-2', + type: 'message', + role: 'assistant', + model: 'openai/gpt-4o-mini', + stop_reason: 'end_turn', + content: [{ type: 'text', text: 'Hi' }], + usage: { input_tokens: 13, output_tokens: 2 }, + }; + mockRequest.mockResolvedValueOnce(response); + + const result = await Messages.createAnthropicMessage(testCtx('k'), { + model: 'openai/gpt-4o-mini', + maxTokens: 64, + messages: [{ role: 'user', content: 'Hi' }], + system: 'Be brief', + }); + + expect(mockRequest).toHaveBeenCalledWith( + 'messages', + 'k', + expect.objectContaining({ + method: 'POST', + body: expect.objectContaining({ + model: 'openai/gpt-4o-mini', + max_tokens: 64, + stream: false, + system: 'Be brief', + }), + }), + ); + expect(result.content[0]?.text).toBe('Hi'); + }); + + it('Models.listModels GETs models', async () => { + const response = { + data: [{ id: 'openai/gpt-4o-mini' }], + } as ListModelsResponse; + mockRequest.mockResolvedValueOnce(response); + + const result = await Models.listModels(testCtx('k'), {}); + + expect(mockRequest).toHaveBeenCalledWith('models', 'k'); + expect(result.data[0]?.id).toBe('openai/gpt-4o-mini'); + }); + + it('Embeddings.createEmbedding POSTs to embeddings', async () => { + const response = { + object: 'list', + data: [{ object: 'embedding', embedding: [0.1, 0.2] }], + model: 'openai/text-embedding-3-small', + usage: { prompt_tokens: 2, completion_tokens: 0, total_tokens: 2 }, + }; + mockRequest.mockResolvedValueOnce(response); + + const result = await Embeddings.createEmbedding(testCtx('k'), { + model: 'openai/text-embedding-3-small', + input: ['a', 'b'], + encodingFormat: 'float', + }); + + expect(mockRequest).toHaveBeenCalledWith( + 'embeddings', + 'k', + expect.objectContaining({ + method: 'POST', + body: expect.objectContaining({ + model: 'openai/text-embedding-3-small', + input: ['a', 'b'], + encoding_format: 'float', + }), + }), + ); + expect(result.data[0]?.embedding).toEqual([0.1, 0.2]); + }); + + it('ModelEndpoints.listModelEndpoints GETs models/:author/:slug/endpoints', async () => { + const response = { + data: { id: 'openai/gpt-4o-mini', name: 'x', endpoints: [] }, + }; + mockRequest.mockResolvedValueOnce(response); + + await ModelEndpoints.listModelEndpoints(testCtx('k'), { + author: 'openai', + slug: 'gpt-4o-mini', + }); + + expect(mockRequest).toHaveBeenCalledWith( + 'models/openai/gpt-4o-mini/endpoints', + 'k', + ); + }); + + it('Models.listModelsCount GETs models/count', async () => { + const response = { data: { count: 400 } } as ListModelsCountResponse; + mockRequest.mockResolvedValueOnce(response); + + const result = await Models.listModelsCount(testCtx('k'), {}); + + expect(mockRequest).toHaveBeenCalledWith('models/count', 'k'); + expect(result.data.count).toBe(400); + }); + + it('Models.listEmbeddingModels GETs embeddings/models with pagination', async () => { + const response = { + data: [{ id: 'openai/text-embedding-3-small' }], + } as ListEmbeddingModelsResponse; + mockRequest.mockResolvedValueOnce(response); + + const result = await Models.listEmbeddingModels(testCtx('k'), { + offset: 0, + limit: 10, + }); + + expect(mockRequest).toHaveBeenCalledWith( + 'embeddings/models', + 'k', + expect.objectContaining({ query: { offset: 0, limit: 10 } }), + ); + expect(result.data[0]?.id).toBe('openai/text-embedding-3-small'); + }); + + it('Models.listUserModels GETs models/user', async () => { + const response = { + data: [{ id: 'myorg/custom-model' }], + } as ListUserModelsResponse; + mockRequest.mockResolvedValueOnce(response); + + const result = await Models.listUserModels(testCtx('k'), {}); + + expect(mockRequest).toHaveBeenCalledWith('models/user', 'k'); + expect(result.data[0]?.id).toBe('myorg/custom-model'); + }); + + it('Zdr.listZdrEndpoints GETs endpoints/zdr', async () => { + const response = { + data: [{ name: 'ZDR region', provider_name: 'OpenAI' }], + } as ListZdrEndpointsResponse; + mockRequest.mockResolvedValueOnce(response); + + const result = await Zdr.listZdrEndpoints(testCtx('k'), {}); + + expect(mockRequest).toHaveBeenCalledWith('endpoints/zdr', 'k'); + expect(result.data[0]?.provider_name).toBe('OpenAI'); + }); + + it('Credits.createCoinbaseCharge POSTs to credits/coinbase', async () => { + const response = { + data: { + id: 'charge-id', + chain_id: 8453, + sender: '0x1234567890123456789012345678901234567890', + }, + } as CreateCoinbaseChargeResponse; + mockRequest.mockResolvedValueOnce(response); + + const result = await Credits.createCoinbaseCharge(testCtx('k'), { + amount: 50.25, + sender: '0x1234567890123456789012345678901234567890', + chainId: 8453, + }); + + expect(mockRequest).toHaveBeenCalledWith( + 'credits/coinbase', + 'k', + expect.objectContaining({ + method: 'POST', + body: { + amount: 50.25, + sender: '0x1234567890123456789012345678901234567890', + chain_id: 8453, + }, + }), + ); + expect(result.data.id).toBe('charge-id'); + }); + + it('Providers.listProviders GETs providers', async () => { + const response = { + data: [{ name: 'OpenAI', slug: 'openai' }], + } as ListProvidersResponse; + mockRequest.mockResolvedValueOnce(response); + + const result = await Providers.listProviders(testCtx('k'), {}); + + expect(mockRequest).toHaveBeenCalledWith('providers', 'k'); + expect(result.data[0]?.slug).toBe('openai'); + }); + + it('Generations.getGeneration GETs generation with id query', async () => { + const response = { data: { id: 'gen-1' } }; + mockRequest.mockResolvedValueOnce(response); + + const result = await Generations.getGeneration(testCtx('k'), { + id: 'gen-1', + }); + + expect(mockRequest).toHaveBeenCalledWith( + 'generation', + 'k', + expect.objectContaining({ query: { id: 'gen-1' } }), + ); + expect(result.data.id).toBe('gen-1'); + }); + + it('Credits.listCredits GETs credits with optional ZDR params', async () => { + const response = { + data: { total_credits: 10, total_usage: 2 }, + } as ListCreditsResponse; + mockRequest.mockResolvedValueOnce(response); + + const result = await Credits.listCredits(testCtx('k'), {}); + + expect(mockRequest).toHaveBeenCalledWith('credits', 'k', { + query: { + query: undefined, + cursor: undefined, + per_page: undefined, + max_age: undefined, + }, + }); + expect(result.data.total_credits).toBe(10); + }); + + it('Key.getKey GETs key', async () => { + const response = { + data: { usage: 0.1, is_free_tier: false }, + } as GetKeyResponse; + mockRequest.mockResolvedValueOnce(response); + + const result = await Key.getKey(testCtx('k'), {}); + + expect(mockRequest).toHaveBeenCalledWith('key', 'k'); + expect(result.data.usage).toBe(0.1); + }); +}); + +const TEST_API_KEY = process.env.OPENROUTER_API_KEY; +const describeIfApiKey = TEST_API_KEY ? describe : describe.skip; + +describeIfApiKey('OpenRouter API type tests (live)', () => { + it('chat completion returns the expected shape', async () => { + const response = await makeOpenRouterRequest( + 'chat/completions', + TEST_API_KEY!, + { + method: 'POST', + body: { + model: 'openai/gpt-4o-mini', + messages: [{ role: 'user', content: 'Say hello in one word.' }], + stream: false, + max_tokens: 16, + }, + }, + ); + + const parsed = + OpenRouterEndpointOutputSchemas.chatCompletionsCreate.safeParse(response); + expect(parsed.success).toBe(true); + }); + + it('anthropic messages returns the expected shape', async () => { + const response = + await makeOpenRouterRequest( + 'messages', + TEST_API_KEY!, + { + method: 'POST', + body: { + model: 'openai/gpt-4o-mini', + max_tokens: 32, + messages: [{ role: 'user', content: 'Say hello in one word.' }], + stream: false, + }, + }, + ); + + const parsed = + OpenRouterEndpointOutputSchemas.messagesCreate.safeParse(response); + expect(parsed.success).toBe(true); + }); + + it('models list returns the expected shape', async () => { + const response = await makeOpenRouterRequest( + 'models', + TEST_API_KEY!, + { method: 'GET' }, + ); + + const parsed = + OpenRouterEndpointOutputSchemas.modelsList.safeParse(response); + expect(parsed.success).toBe(true); + }); + + it('models count returns the expected shape', async () => { + const response = await makeOpenRouterRequest( + 'models/count', + TEST_API_KEY!, + { method: 'GET' }, + ); + + const parsed = + OpenRouterEndpointOutputSchemas.modelsCount.safeParse(response); + expect(parsed.success).toBe(true); + }); + + it('embedding models list returns the expected shape', async () => { + const response = await makeOpenRouterRequest( + 'embeddings/models', + TEST_API_KEY!, + { method: 'GET' }, + ); + + const parsed = + OpenRouterEndpointOutputSchemas.modelsEmbeddingsList.safeParse(response); + expect(parsed.success).toBe(true); + }); + + it('user models list returns the expected shape', async () => { + const response = await makeOpenRouterRequest( + 'models/user', + TEST_API_KEY!, + { method: 'GET' }, + ); + + const parsed = + OpenRouterEndpointOutputSchemas.modelsUserList.safeParse(response); + expect(parsed.success).toBe(true); + }); + + it('zdr endpoints returns the expected shape', async () => { + const response = await makeOpenRouterRequest( + 'endpoints/zdr', + TEST_API_KEY!, + { method: 'GET' }, + ); + + const parsed = + OpenRouterEndpointOutputSchemas.zdrEndpointsList.safeParse(response); + expect(parsed.success).toBe(true); + }); + + it('model endpoints returns the expected shape', async () => { + const response = await makeOpenRouterRequest( + 'models/openai/gpt-4o-mini/endpoints', + TEST_API_KEY!, + { method: 'GET' }, + ); + + const parsed = + OpenRouterEndpointOutputSchemas.modelsEndpointsList.safeParse(response); + expect(parsed.success).toBe(true); + }); + + it('embeddings returns the expected shape', async () => { + const response = await makeOpenRouterRequest( + 'embeddings', + TEST_API_KEY!, + { + method: 'POST', + body: { + model: 'openai/text-embedding-3-small', + input: 'hello world', + }, + }, + ); + + const parsed = + OpenRouterEndpointOutputSchemas.embeddingsCreate.safeParse(response); + expect(parsed.success).toBe(true); + }); + + it('providers returns the expected shape', async () => { + const response = await makeOpenRouterRequest( + 'providers', + TEST_API_KEY!, + { method: 'GET' }, + ); + + const parsed = + OpenRouterEndpointOutputSchemas.providersList.safeParse(response); + expect(parsed.success).toBe(true); + }); + + it('credits returns the expected shape', async () => { + const response = await makeOpenRouterRequest( + 'credits', + TEST_API_KEY!, + { method: 'GET' }, + ); + + const parsed = + OpenRouterEndpointOutputSchemas.creditsList.safeParse(response); + expect(parsed.success).toBe(true); + }); + + it('key returns the expected shape', async () => { + const response = await makeOpenRouterRequest( + 'key', + TEST_API_KEY!, + { method: 'GET' }, + ); + + const parsed = OpenRouterEndpointOutputSchemas.keyGet.safeParse(response); + expect(parsed.success).toBe(true); + }); +}); diff --git a/packages/openrouter/client.ts b/packages/openrouter/client.ts new file mode 100644 index 000000000..12fda7080 --- /dev/null +++ b/packages/openrouter/client.ts @@ -0,0 +1,70 @@ +import type { ApiRequestOptions, OpenAPIConfig } from 'corsair/http'; +import { ApiError, request } from 'corsair/http'; + +export class OpenRouterAPIError extends Error { + constructor( + message: string, + public readonly code?: string, + ) { + super(message); + this.name = 'OpenRouterAPIError'; + } +} + +const OPENROUTER_API_BASE = 'https://openrouter.ai/api/v1'; + +/** + * Performs a request against the OpenRouter API. + * Auth: API key via Bearer token (the only supported auth type). + * Query parameters are forwarded unconditionally regardless of HTTP method. + */ +export async function makeOpenRouterRequest( + endpoint: string, + apiKey: string, + options: { + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + // body shape varies per endpoint and is validated by callers via typed Zod input schemas before being passed here + body?: Record; + query?: Record; + } = {}, +): Promise { + const { method = 'GET', body, query } = options; + + const config: OpenAPIConfig = { + BASE: OPENROUTER_API_BASE, + VERSION: '1.0.0', + WITH_CREDENTIALS: false, + CREDENTIALS: 'omit', + TOKEN: apiKey, + HEADERS: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + }; + + const requestOptions: ApiRequestOptions = { + method, + url: endpoint, + body: + method === 'POST' || method === 'PUT' || method === 'PATCH' + ? body + : undefined, + mediaType: 'application/json; charset=utf-8', + query, + }; + + try { + return await request(config, requestOptions); + } catch (error) { + // Re-thrown as-is: ApiError already carries the HTTP status code and + // Retry-After info that error-handlers.ts inspects. Wrapping it here + // would hide those fields behind a message string. + if (error instanceof ApiError) { + throw error; + } + if (error instanceof Error) { + throw new OpenRouterAPIError(error.message); + } + throw new OpenRouterAPIError('Unknown error'); + } +} diff --git a/packages/openrouter/endpoints/chat-completions.ts b/packages/openrouter/endpoints/chat-completions.ts new file mode 100644 index 000000000..64bd80962 --- /dev/null +++ b/packages/openrouter/endpoints/chat-completions.ts @@ -0,0 +1,43 @@ +import type { OpenRouterEndpoints } from './..'; +import { makeOpenRouterRequest } from '../client'; +import type { CreateChatCompletionResponse } from './types'; + +// Streaming is not exposed here: this endpoint always returns a single typed +// JSON response, and corsair/http's request() helper does not parse +// text/event-stream bodies, so the API is always called with stream: false. +export const createChatCompletion: OpenRouterEndpoints['chatCompletionsCreate'] = + async (ctx, input) => { + const result = await makeOpenRouterRequest( + 'chat/completions', + ctx.key, + { + method: 'POST', + body: { + model: input.model, + messages: input.messages, + stream: false, + temperature: input.temperature, + top_p: input.topP, + max_tokens: input.maxTokens, + max_completion_tokens: input.maxCompletionTokens, + n: input.n, + stop: input.stop, + presence_penalty: input.presencePenalty, + frequency_penalty: input.frequencyPenalty, + logit_bias: input.logitBias, + user: input.user, + response_format: input.responseFormat, + tools: input.tools, + tool_choice: input.toolChoice, + reasoning: input.reasoning, + transforms: input.transforms, + models: input.models, + route: input.route, + provider: input.provider, + plugins: input.plugins, + }, + }, + ); + + return result; + }; diff --git a/packages/openrouter/endpoints/credits.ts b/packages/openrouter/endpoints/credits.ts new file mode 100644 index 000000000..bf5e096b3 --- /dev/null +++ b/packages/openrouter/endpoints/credits.ts @@ -0,0 +1,48 @@ +import type { OpenRouterEndpoints } from './..'; +import { makeOpenRouterRequest } from '../client'; +import type { + CreateCoinbaseChargeResponse, + ListCreditsResponse, +} from './types'; + +// GET /credits returns the account credit balance, and optionally a +// Zero-Data Residency (ZDR) report when filter params are supplied. +export const listCredits: OpenRouterEndpoints['creditsList'] = async ( + ctx, + input, +) => { + const result = await makeOpenRouterRequest( + 'credits', + ctx.key, + { + query: { + query: input.query, + cursor: input.cursor, + per_page: input.perPage, + max_age: input.maxAge, + }, + }, + ); + + return result; +}; + +// POST /credits/coinbase creates a Coinbase Commerce on-chain charge +// to top up the account with credits. +export const createCoinbaseCharge: OpenRouterEndpoints['creditsCoinbaseCreate'] = + async (ctx, input) => { + const result = await makeOpenRouterRequest( + 'credits/coinbase', + ctx.key, + { + method: 'POST', + body: { + amount: input.amount, + sender: input.sender, + chain_id: input.chainId, + }, + }, + ); + + return result; + }; diff --git a/packages/openrouter/endpoints/embeddings.ts b/packages/openrouter/endpoints/embeddings.ts new file mode 100644 index 000000000..cefcb4cc0 --- /dev/null +++ b/packages/openrouter/endpoints/embeddings.ts @@ -0,0 +1,25 @@ +import type { OpenRouterEndpoints } from './..'; +import { makeOpenRouterRequest } from '../client'; +import type { CreateEmbeddingOutput } from './types'; + +export const createEmbedding: OpenRouterEndpoints['embeddingsCreate'] = async ( + ctx, + input, +) => { + const result = await makeOpenRouterRequest( + 'embeddings', + ctx.key, + { + method: 'POST', + body: { + model: input.model, + input: input.input, + encoding_format: input.encodingFormat, + dimensions: input.dimensions, + user: input.user, + }, + }, + ); + + return result; +}; diff --git a/packages/openrouter/endpoints/generations.ts b/packages/openrouter/endpoints/generations.ts new file mode 100644 index 000000000..fcfa09686 --- /dev/null +++ b/packages/openrouter/endpoints/generations.ts @@ -0,0 +1,19 @@ +import type { OpenRouterEndpoints } from './..'; +import { makeOpenRouterRequest } from '../client'; +import type { GetGenerationInput, GetGenerationResponse } from './types'; + +// GET /generation returns request & usage metadata for a previous generation. +export const getGeneration: OpenRouterEndpoints['generationsGet'] = async ( + ctx, + input: GetGenerationInput, +) => { + const result = await makeOpenRouterRequest( + 'generation', + ctx.key, + { + query: { id: input.id }, + }, + ); + + return result; +}; diff --git a/packages/openrouter/endpoints/index.ts b/packages/openrouter/endpoints/index.ts new file mode 100644 index 000000000..394bcb759 --- /dev/null +++ b/packages/openrouter/endpoints/index.ts @@ -0,0 +1,61 @@ +import { createChatCompletion } from './chat-completions'; +import { createCoinbaseCharge, listCredits } from './credits'; +import { createEmbedding } from './embeddings'; +import { getGeneration } from './generations'; +import { getKey } from './key'; +import { createAnthropicMessage } from './messages'; +import { listModelEndpoints } from './model-endpoints'; +import { + listEmbeddingModels, + listModels, + listModelsCount, + listUserModels, +} from './models'; +import { listProviders } from './providers'; +import { listZdrEndpoints } from './zdr'; + +export const ChatCompletions = { + createChatCompletion, +}; + +export const Messages = { + createAnthropicMessage, +}; + +export const Models = { + listModels, + listModelsCount, + listEmbeddingModels, + listUserModels, +}; + +export const Embeddings = { + createEmbedding, +}; + +export const ModelEndpoints = { + listModelEndpoints, +}; + +export const Providers = { + listProviders, +}; + +export const Generations = { + getGeneration, +}; + +export const Credits = { + listCredits, + createCoinbaseCharge, +}; + +export const Key = { + getKey, +}; + +export const Zdr = { + listZdrEndpoints, +}; + +export * from './types'; diff --git a/packages/openrouter/endpoints/key.ts b/packages/openrouter/endpoints/key.ts new file mode 100644 index 000000000..097112262 --- /dev/null +++ b/packages/openrouter/endpoints/key.ts @@ -0,0 +1,9 @@ +import type { OpenRouterEndpoints } from './..'; +import { makeOpenRouterRequest } from '../client'; +import type { GetKeyResponse } from './types'; + +export const getKey: OpenRouterEndpoints['keyGet'] = async (ctx, _input) => { + const result = await makeOpenRouterRequest('key', ctx.key); + + return result; +}; diff --git a/packages/openrouter/endpoints/messages.ts b/packages/openrouter/endpoints/messages.ts new file mode 100644 index 000000000..c6b6f1f09 --- /dev/null +++ b/packages/openrouter/endpoints/messages.ts @@ -0,0 +1,29 @@ +import type { OpenRouterEndpoints } from './..'; +import { makeOpenRouterRequest } from '../client'; +import type { CreateAnthropicMessageResponse } from './types'; + +// Streaming is not exposed here: this endpoint always returns a single typed +// JSON response, and corsair/http's request() helper does not parse +// text/event-stream bodies, so the API is always called with stream: false. +export const createAnthropicMessage: OpenRouterEndpoints['messagesCreate'] = + async (ctx, input) => { + const result = await makeOpenRouterRequest( + 'messages', + ctx.key, + { + method: 'POST', + body: { + model: input.model, + max_tokens: input.maxTokens, + messages: input.messages, + system: input.system, + temperature: input.temperature, + top_p: input.topP, + stop_sequences: input.stopSequences, + stream: false, + }, + }, + ); + + return result; + }; diff --git a/packages/openrouter/endpoints/model-endpoints.ts b/packages/openrouter/endpoints/model-endpoints.ts new file mode 100644 index 000000000..04e1cee8d --- /dev/null +++ b/packages/openrouter/endpoints/model-endpoints.ts @@ -0,0 +1,13 @@ +import type { OpenRouterEndpoints } from './..'; +import { makeOpenRouterRequest } from '../client'; +import type { ListModelEndpointsResponse } from './types'; + +export const listModelEndpoints: OpenRouterEndpoints['modelsEndpointsList'] = + async (ctx, input) => { + const result = await makeOpenRouterRequest( + `models/${input.author}/${input.slug}/endpoints`, + ctx.key, + ); + + return result; + }; diff --git a/packages/openrouter/endpoints/models.ts b/packages/openrouter/endpoints/models.ts new file mode 100644 index 000000000..5a84dc065 --- /dev/null +++ b/packages/openrouter/endpoints/models.ts @@ -0,0 +1,60 @@ +import type { OpenRouterEndpoints } from './..'; +import { makeOpenRouterRequest } from '../client'; +import type { + ListEmbeddingModelsResponse, + ListModelsCountResponse, + ListModelsResponse, + ListUserModelsResponse, +} from './types'; + +export const listModels: OpenRouterEndpoints['modelsList'] = async ( + ctx, + _input, +) => { + const result = await makeOpenRouterRequest( + 'models', + ctx.key, + ); + + return result; +}; + +export const listModelsCount: OpenRouterEndpoints['modelsCount'] = async ( + ctx, + _input, +) => { + const result = await makeOpenRouterRequest( + 'models/count', + ctx.key, + ); + + return result; +}; + +export const listEmbeddingModels: OpenRouterEndpoints['modelsEmbeddingsList'] = + async (ctx, input) => { + const result = await makeOpenRouterRequest( + 'embeddings/models', + ctx.key, + { + query: { + offset: input.offset, + limit: input.limit, + }, + }, + ); + + return result; + }; + +export const listUserModels: OpenRouterEndpoints['modelsUserList'] = async ( + ctx, + _input, +) => { + const result = await makeOpenRouterRequest( + 'models/user', + ctx.key, + ); + + return result; +}; diff --git a/packages/openrouter/endpoints/providers.ts b/packages/openrouter/endpoints/providers.ts new file mode 100644 index 000000000..7749716e2 --- /dev/null +++ b/packages/openrouter/endpoints/providers.ts @@ -0,0 +1,15 @@ +import type { OpenRouterEndpoints } from './..'; +import { makeOpenRouterRequest } from '../client'; +import type { ListProvidersResponse } from './types'; + +export const listProviders: OpenRouterEndpoints['providersList'] = async ( + ctx, + _input, +) => { + const result = await makeOpenRouterRequest( + 'providers', + ctx.key, + ); + + return result; +}; diff --git a/packages/openrouter/endpoints/types.ts b/packages/openrouter/endpoints/types.ts new file mode 100644 index 000000000..a1775d812 --- /dev/null +++ b/packages/openrouter/endpoints/types.ts @@ -0,0 +1,562 @@ +import { z } from 'zod'; + +const MessagePartSchema = z.union([ + z.object({ type: z.literal('text'), text: z.string() }), + z.object({ + type: z.literal('image_url'), + image_url: z.object({ url: z.string(), detail: z.string().optional() }), + }), + z.object({ + type: z.literal('input_audio'), + input_audio: z.object({ + data: z.string(), + format: z.string(), + }), + }), +]); + +export const ChatMessageSchema = z.union([ + z.object({ + role: z.literal('system'), + content: z.string(), + }), + z.object({ + role: z.literal('assistant'), + content: z.union([z.string(), z.array(MessagePartSchema)]).optional(), + }), + z.object({ + role: z.literal('user'), + content: z.union([z.string(), z.array(MessagePartSchema)]), + }), + z.object({ + role: z.literal('tool'), + tool_call_id: z.string(), + content: z.string(), + }), +]); + +const ToolCallSchema = z.object({ + id: z.string(), + type: z.literal('function'), + function: z.object({ + name: z.string(), + arguments: z.string(), + }), +}); + +const ToolSchema = z.object({ + type: z.literal('function'), + function: z.object({ + name: z.string(), + description: z.string().optional(), + parameters: z.record(z.string(), z.unknown()).optional(), + }), +}); + +const ResponseFormatSchema = z.object({ + type: z.enum(['text', 'json_object', 'json_schema']), + json_schema: z + .object({ + name: z.string(), + strict: z.boolean().optional(), + schema: z.record(z.string(), z.unknown()).optional(), + }) + .optional(), +}); + +export const CreateChatCompletionInputSchema = z.object({ + model: z.string(), + messages: z.array(ChatMessageSchema).min(1), + stream: z.boolean().optional(), + temperature: z.number().optional(), + topP: z.number().optional(), + maxTokens: z.number().optional(), + maxCompletionTokens: z.number().optional(), + n: z.number().optional(), + stop: z.union([z.string(), z.array(z.string())]).optional(), + presencePenalty: z.number().optional(), + frequencyPenalty: z.number().optional(), + logitBias: z.record(z.string(), z.number()).optional(), + user: z.string().optional(), + responseFormat: ResponseFormatSchema.optional(), + tools: z.array(ToolSchema).optional(), + toolChoice: z + .union([ + z.string(), + z.object({ + type: z.literal('function'), + function: z.object({ name: z.string() }), + }), + ]) + .optional(), + reasoning: z + .object({ effort: z.enum(['low', 'medium', 'high']).optional() }) + .optional(), + transforms: z.array(z.string()).optional(), + models: z.array(z.string()).optional(), + route: z.string().optional(), + provider: z + .object({ + order: z.array(z.string()).optional(), + allow_fallbacks: z.boolean().optional(), + ignore: z.array(z.string()).optional(), + require_parameters: z.boolean().optional(), + data_collection: z.string().optional(), + }) + .optional(), + plugins: z + .array( + z.object({ + name: z.string(), + max_tokens: z.number().optional(), + num_images_per_prompt: z.number().optional(), + image_format: z.string().optional(), + num_prompts: z.number().optional(), + }), + ) + .optional(), +}); + +export const CompletionUsageSchema = z.object({ + prompt_tokens: z.number(), + completion_tokens: z.number(), + total_tokens: z.number(), +}); + +export const CreateChatCompletionOutputSchema = z.object({ + id: z.string(), + object: z.literal('chat.completion'), + created: z.number(), + model: z.string(), + choices: z.array( + z.object({ + index: z.number(), + message: z.object({ + role: z.literal('assistant'), + content: z.string().nullable(), + tool_calls: z.array(ToolCallSchema).optional(), + }), + finish_reason: z.string().nullable(), + }), + ), + usage: CompletionUsageSchema, + provider: z.string().optional(), + models: z.array(z.string()).optional(), + native_tool_calls: z.array(z.unknown()).optional(), +}); + +export const CreateAnthropicMessageInputSchema = z.object({ + model: z.string(), + maxTokens: z.number(), + messages: z + .array( + z.object({ + role: z.enum(['user', 'assistant']), + content: z.union([z.string(), z.array(MessagePartSchema)]), + }), + ) + .min(1), + system: z.string().optional(), + temperature: z.number().optional(), + topP: z.number().optional(), + stopSequences: z.array(z.string()).optional(), +}); + +export const CreateAnthropicMessageOutputSchema = z.object({ + id: z.string(), + type: z.literal('message'), + role: z.literal('assistant'), + model: z.string(), + stop_reason: z.string().nullable(), + content: z.array( + z.object({ + type: z.literal('text'), + text: z.string(), + citations: z.array(z.unknown()).optional(), + }), + ), + usage: z + .object({ + input_tokens: z.number(), + output_tokens: z.number(), + cache_read_input_tokens: z.number().nullable().optional(), + cache_creation_input_tokens: z.number().nullable().optional(), + output_tokens_details: z + .object({ thinking_tokens: z.number().optional() }) + .optional(), + }) + .catchall(z.unknown()), + provider: z.string().optional(), +}); + +export const ListModelsInputSchema = z.object({}); + +export const ModelSchema = z.object({ + id: z.string(), + name: z.string().optional(), + created: z.number().optional(), + description: z.string().optional(), + context_length: z.number().optional(), + pricing: z.record(z.string(), z.unknown()).optional(), + architecture: z + .object({ + modality: z.string().optional(), + input_modalities: z.array(z.string()).optional(), + output_modalities: z.array(z.string()).optional(), + tokenizer: z.string().optional(), + instruct_type: z.string().nullable().optional(), + }) + .optional(), + top_provider: z + .object({ + context_length: z.number().nullable().optional(), + max_completion_tokens: z.number().nullable().optional(), + is_moderated: z.boolean().optional(), + }) + .optional(), + per_request_limits: z + .object({ + prompt_tokens: z.string().nullable().optional(), + completion_tokens: z.string().nullable().optional(), + }) + .nullable() + .optional(), + supported_parameters: z.array(z.string()).optional(), +}); + +export const ListModelsOutputSchema = z.object({ + data: z.array(ModelSchema), +}); + +export const ListModelsCountInputSchema = z.object({}); + +export const ListModelsCountOutputSchema = z.object({ + data: z.object({ + count: z.number(), + }), +}); + +export const ListEmbeddingModelsInputSchema = z.object({ + offset: z.number().optional(), + limit: z.number().optional(), +}); + +export const ListEmbeddingModelsOutputSchema = z.object({ + data: z.array(ModelSchema), +}); + +export const ListUserModelsInputSchema = z.object({}); + +export const ListUserModelsOutputSchema = z.object({ + data: z.array(ModelSchema), +}); + +export const ListModelEndpointsInputSchema = z.object({ + author: z.string(), + slug: z.string(), +}); + +const p95LatencySchema = z.object({ + p50: z.number().optional(), + p75: z.number().optional(), + p90: z.number().optional(), + p99: z.number().optional(), +}); + +export const ModelEndpointSchema = z + .object({ + name: z.string(), + model_id: z.string().optional(), + model_name: z.string().optional(), + provider_name: z.string(), + tag: z.string().optional(), + context_length: z.number().optional(), + max_completion_tokens: z.number().nullable().optional(), + max_prompt_tokens: z.number().nullable().optional(), + quantization: z.string().nullable().optional(), + pricing: z.record(z.string(), z.unknown()).optional(), + supported_parameters: z.array(z.string()).optional(), + status: z.number().optional(), + uptime_last_30m: z.number().nullable().optional(), + uptime_last_5m: z.number().nullable().optional(), + uptime_last_1d: z.number().nullable().optional(), + supports_implicit_caching: z.boolean().optional(), + supports_voice_cloning: z.boolean().optional(), + latency_last_30m: p95LatencySchema.nullable().optional(), + throughput_last_30m: p95LatencySchema.nullable().optional(), + }) + .catchall(z.unknown()); + +export const ListModelEndpointsOutputSchema = z.object({ + data: z.object({ + id: z.string(), + name: z.string(), + created: z.number().optional(), + description: z.string().optional(), + architecture: z.record(z.string(), z.unknown()).optional(), + endpoints: z.array(ModelEndpointSchema), + }), +}); + +export const ListZdrEndpointsInputSchema = z.object({}); + +export const ListZdrEndpointsOutputSchema = z.object({ + data: z.array(ModelEndpointSchema), +}); + +export const CreateCoinbaseChargeInputSchema = z.object({ + amount: z.number(), + sender: z.string(), + chainId: z.union([z.literal(1), z.literal(137), z.literal(8453)]), +}); + +export const CreateCoinbaseChargeOutputSchema = z.object({ + data: z + .object({ + id: z.string().optional(), + chain_id: z.number().optional(), + sender: z.string().optional(), + addresses: z.record(z.string(), z.string()).optional(), + calldata: z.record(z.string(), z.string()).optional(), + created_at: z.string().optional(), + expires_at: z.string().optional(), + }) + .catchall(z.unknown()), +}); + +export const CreateEmbeddingInputSchema = z.object({ + model: z.string(), + input: z.union([z.string(), z.array(z.string())]), + encodingFormat: z.enum(['float', 'base64']).optional(), + dimensions: z.number().optional(), + user: z.string().optional(), +}); + +const EmbeddingUsageSchema = z + .object({ + prompt_tokens: z.number(), + total_tokens: z.number(), + }) + .catchall(z.unknown()); + +export const CreateEmbeddingOutputSchema = z.object({ + id: z.string().optional(), + object: z.literal('list'), + data: z.array( + z.object({ + index: z.number().optional(), + object: z.literal('embedding'), + embedding: z.union([z.array(z.number()), z.string()]), + }), + ), + model: z.string(), + usage: EmbeddingUsageSchema, +}); + +export const ListProvidersInputSchema = z.object({}); + +export const ListProvidersOutputSchema = z.object({ + data: z.array( + z.object({ + name: z.string(), + slug: z.string(), + privacy_policy_url: z.string().nullable().optional(), + terms_of_service_url: z.string().nullable().optional(), + status_page_url: z.string().nullable().optional(), + headquarters: z.string().nullable().optional(), + datacenters: z.array(z.string()).nullable().optional(), + }), + ), +}); + +export const GetGenerationInputSchema = z.object({ + id: z.string(), +}); + +export const GetGenerationOutputSchema = z.object({ + data: z + .object({ + id: z.string(), + model: z.string().optional(), + provider: z.string().optional(), + api_type: z.string().nullable().optional(), + created_at: z.string().optional(), + streamed: z.boolean().optional(), + finish_reason: z.string().nullable().optional(), + total_cost: z.number().nullable().optional(), + prompt_tokens: z.number().optional(), + completion_tokens: z.number().optional(), + total_tokens: z.number().optional(), + usage: z.record(z.string(), z.unknown()).optional(), + provider_response: z.record(z.string(), z.unknown()).optional(), + }) + .catchall(z.unknown()), +}); + +export const GetCreditsInputSchema = z.object({ + query: z.string().optional(), + cursor: z.string().optional(), + perPage: z.number().optional(), + maxAge: z.number().optional(), +}); + +export const ListCreditsInputSchema = GetCreditsInputSchema; + +export const ListCreditsOutputSchema = z.object({ + data: z + .object({ + total_credits: z.number(), + total_usage: z.number().optional(), + limit_reached: z.boolean().optional(), + prepaid: z.number().optional(), + billed_prepaid: z.number().optional(), + soft_limit: z.number().optional(), + pending_balance: z.number().optional(), + }) + .catchall(z.unknown()), +}); + +export const GetKeyInputSchema = z.object({}); + +export const GetKeyOutputSchema = z.object({ + data: z + .object({ + label: z.string().optional(), + usage: z.number(), + limit: z.number().nullable().optional(), + limit_remaining: z.number().nullable().optional(), + is_free_tier: z.boolean().optional(), + is_management_key: z.boolean().optional(), + is_provisioning_key: z.boolean().optional(), + rate_limit: z + .object({ + requests: z.number(), + interval: z.string(), + }) + .optional(), + expires_at: z.string().nullable().optional(), + created_at: z.string().optional(), + }) + .catchall(z.unknown()), +}); + +export type CreateChatCompletionInput = z.infer< + typeof CreateChatCompletionInputSchema +>; +export type CreateChatCompletionResponse = z.infer< + typeof CreateChatCompletionOutputSchema +>; +export type CreateAnthropicMessageInput = z.infer< + typeof CreateAnthropicMessageInputSchema +>; +export type CreateAnthropicMessageResponse = z.infer< + typeof CreateAnthropicMessageOutputSchema +>; +export type ListModelsInput = z.infer; +export type ListModelsResponse = z.infer; +export type ListModelsCountInput = z.infer; +export type ListModelsCountResponse = z.infer< + typeof ListModelsCountOutputSchema +>; +export type ListEmbeddingModelsInput = z.infer< + typeof ListEmbeddingModelsInputSchema +>; +export type ListEmbeddingModelsResponse = z.infer< + typeof ListEmbeddingModelsOutputSchema +>; +export type ListUserModelsInput = z.infer; +export type ListUserModelsResponse = z.infer; +export type CreateEmbeddingInput = z.infer; +export type CreateEmbeddingOutput = z.infer; +export type ListModelEndpointsInput = z.infer< + typeof ListModelEndpointsInputSchema +>; +export type ListModelEndpointsResponse = z.infer< + typeof ListModelEndpointsOutputSchema +>; +export type ListProvidersInput = z.infer; +export type ListProvidersResponse = z.infer; +export type ListZdrEndpointsInput = z.infer; +export type ListZdrEndpointsResponse = z.infer< + typeof ListZdrEndpointsOutputSchema +>; +export type CreateCoinbaseChargeInput = z.infer< + typeof CreateCoinbaseChargeInputSchema +>; +export type CreateCoinbaseChargeResponse = z.infer< + typeof CreateCoinbaseChargeOutputSchema +>; +export type GetGenerationInput = z.infer; +export type GetGenerationResponse = z.infer; +export type ListCreditsInput = z.infer; +export type ListCreditsResponse = z.infer; +export type GetKeyInput = z.infer; +export type GetKeyResponse = z.infer; + +export type OpenRouterEndpointInputs = { + chatCompletionsCreate: CreateChatCompletionInput; + messagesCreate: CreateAnthropicMessageInput; + modelsList: ListModelsInput; + modelsCount: ListModelsCountInput; + modelsEmbeddingsList: ListEmbeddingModelsInput; + modelsUserList: ListUserModelsInput; + embeddingsCreate: CreateEmbeddingInput; + modelsEndpointsList: ListModelEndpointsInput; + providersList: ListProvidersInput; + zdrEndpointsList: ListZdrEndpointsInput; + creditsCoinbaseCreate: CreateCoinbaseChargeInput; + generationsGet: GetGenerationInput; + creditsList: ListCreditsInput; + keyGet: GetKeyInput; +}; + +export type OpenRouterEndpointOutputs = { + chatCompletionsCreate: CreateChatCompletionResponse; + messagesCreate: CreateAnthropicMessageResponse; + modelsList: ListModelsResponse; + modelsCount: ListModelsCountResponse; + modelsEmbeddingsList: ListEmbeddingModelsResponse; + modelsUserList: ListUserModelsResponse; + embeddingsCreate: CreateEmbeddingOutput; + modelsEndpointsList: ListModelEndpointsResponse; + providersList: ListProvidersResponse; + zdrEndpointsList: ListZdrEndpointsResponse; + creditsCoinbaseCreate: CreateCoinbaseChargeResponse; + generationsGet: GetGenerationResponse; + creditsList: ListCreditsResponse; + keyGet: GetKeyResponse; +}; + +export const OpenRouterEndpointInputSchemas = { + chatCompletionsCreate: CreateChatCompletionInputSchema, + messagesCreate: CreateAnthropicMessageInputSchema, + modelsList: ListModelsInputSchema, + modelsCount: ListModelsCountInputSchema, + modelsEmbeddingsList: ListEmbeddingModelsInputSchema, + modelsUserList: ListUserModelsInputSchema, + embeddingsCreate: CreateEmbeddingInputSchema, + modelsEndpointsList: ListModelEndpointsInputSchema, + providersList: ListProvidersInputSchema, + zdrEndpointsList: ListZdrEndpointsInputSchema, + creditsCoinbaseCreate: CreateCoinbaseChargeInputSchema, + generationsGet: GetGenerationInputSchema, + creditsList: ListCreditsInputSchema, + keyGet: GetKeyInputSchema, +} as const; + +export const OpenRouterEndpointOutputSchemas = { + chatCompletionsCreate: CreateChatCompletionOutputSchema, + messagesCreate: CreateAnthropicMessageOutputSchema, + modelsList: ListModelsOutputSchema, + modelsCount: ListModelsCountOutputSchema, + modelsEmbeddingsList: ListEmbeddingModelsOutputSchema, + modelsUserList: ListUserModelsOutputSchema, + embeddingsCreate: CreateEmbeddingOutputSchema, + modelsEndpointsList: ListModelEndpointsOutputSchema, + providersList: ListProvidersOutputSchema, + zdrEndpointsList: ListZdrEndpointsOutputSchema, + creditsCoinbaseCreate: CreateCoinbaseChargeOutputSchema, + generationsGet: GetGenerationOutputSchema, + creditsList: ListCreditsOutputSchema, + keyGet: GetKeyOutputSchema, +} as const; diff --git a/packages/openrouter/endpoints/zdr.ts b/packages/openrouter/endpoints/zdr.ts new file mode 100644 index 000000000..78fc82121 --- /dev/null +++ b/packages/openrouter/endpoints/zdr.ts @@ -0,0 +1,17 @@ +import type { OpenRouterEndpoints } from './..'; +import { makeOpenRouterRequest } from '../client'; +import type { ListZdrEndpointsResponse } from './types'; + +// GET /endpoints/zdr returns the Zero-Data Residency (ZDR) endpoint +// specification for the account's ZDR frontend. +export const listZdrEndpoints: OpenRouterEndpoints['zdrEndpointsList'] = async ( + ctx, + _input, +) => { + const result = await makeOpenRouterRequest( + 'endpoints/zdr', + ctx.key, + ); + + return result; +}; diff --git a/packages/openrouter/error-handlers.ts b/packages/openrouter/error-handlers.ts new file mode 100644 index 000000000..0a91bafc6 --- /dev/null +++ b/packages/openrouter/error-handlers.ts @@ -0,0 +1,75 @@ +import type { CorsairErrorHandler } from 'corsair/core'; +import { ApiError } from 'corsair/http'; + +/** + * Error handlers for the OpenRouter plugin. + * + * OpenRouter error codes: https://openrouter.ai/docs/errors + * - 401: Authentication fails (invalid API key) + * - 402: Quota exceeded / insufficient credits + * - 403: Access denied (e.g. model restricted for this key) + * - 408: Request timeout + * - 422: Invalid request body / parameters + * - 429: Rate limit reached + * - 5xx: Server errors; includes 529, which OpenRouter uses for overloaded + * servers. Models without max completion tokens may also 5xx. + */ +export const errorHandlers = { + RATE_LIMIT_ERROR: { + match: (error: Error) => { + if (error instanceof ApiError && error.status === 429) return true; + const msg = error.message.toLowerCase(); + return msg.includes('rate_limit') || msg.includes('429'); + }, + handler: async (error: Error) => { + let retryAfterMs: number | undefined; + if (error instanceof ApiError && error.retryAfter !== undefined) { + retryAfterMs = error.retryAfter; + } + return { maxRetries: 5, headersRetryAfterMs: retryAfterMs }; + }, + }, + AUTH_ERROR: { + match: (error: Error) => { + if (error instanceof ApiError && error.status === 401) return true; + const msg = error.message.toLowerCase(); + return msg.includes('unauthorized') || msg.includes('invalid_api_key'); + }, + handler: async () => ({ maxRetries: 0 }), + }, + INSUFFICIENT_CREDITS_ERROR: { + match: (error: Error) => { + if (error instanceof ApiError && error.status === 402) return true; + const msg = error.message.toLowerCase(); + return msg.includes('insufficient') || msg.includes('quota'); + }, + handler: async () => ({ maxRetries: 0 }), + }, + INVALID_REQUEST_ERROR: { + match: (error: Error) => { + if (error instanceof ApiError && error.status === 422) return true; + const msg = error.message.toLowerCase(); + return msg.includes('invalid') || msg.includes('validation'); + }, + handler: async () => ({ maxRetries: 0 }), + }, + SERVER_ERROR: { + match: (error: Error) => { + if (error instanceof ApiError) { + return ( + (error.status >= 500 && error.status < 600) || error.status === 529 + ); + } + const msg = error.message.toLowerCase(); + return msg.includes('server error') || msg.includes('overloaded'); + }, + handler: async () => ({ + maxRetries: 3, + retryStrategy: 'exponential_backoff' as const, + }), + }, + DEFAULT: { + match: () => true, + handler: async () => ({ maxRetries: 0 }), + }, +} satisfies CorsairErrorHandler; diff --git a/packages/openrouter/index.ts b/packages/openrouter/index.ts new file mode 100644 index 000000000..6f51da212 --- /dev/null +++ b/packages/openrouter/index.ts @@ -0,0 +1,330 @@ +import type { + AuthTypes, + BindEndpoints, + CorsairEndpoint, + CorsairErrorHandler, + CorsairPlugin, + CorsairPluginContext, + KeyBuilderContext, + PickAuth, + PluginPermissionsConfig, + RequiredPluginEndpointMeta, + RequiredPluginEndpointSchemas, +} from 'corsair/core'; +import { AuthMissingError } from 'corsair/core'; +import { + ChatCompletions, + Credits, + Embeddings, + Generations, + Key, + Messages, + ModelEndpoints, + Models, + Providers, + Zdr, +} from './endpoints'; +import type { + OpenRouterEndpointInputs, + OpenRouterEndpointOutputs, +} from './endpoints/types'; +import { + OpenRouterEndpointInputSchemas, + OpenRouterEndpointOutputSchemas, +} from './endpoints/types'; +import { errorHandlers } from './error-handlers'; +import { OpenrouterSchema } from './schema'; + +export type OpenRouterPluginOptions = { + authType?: PickAuth<'api_key'>; + key?: string; + hooks?: InternalOpenrouterPlugin['hooks']; + errorHandlers?: CorsairErrorHandler; + permissions?: PluginPermissionsConfig; +}; + +export type OpenrouterContext = CorsairPluginContext< + typeof OpenrouterSchema, + OpenRouterPluginOptions +>; + +export type OpenrouterKeyBuilderContext = + KeyBuilderContext; + +export type OpenrouterBoundEndpoints = BindEndpoints< + typeof openrouterEndpointsNested +>; + +type OpenrouterEndpoint = + CorsairEndpoint< + OpenrouterContext, + OpenRouterEndpointInputs[K], + OpenRouterEndpointOutputs[K] + >; + +export type OpenRouterEndpoints = { + chatCompletionsCreate: OpenrouterEndpoint<'chatCompletionsCreate'>; + messagesCreate: OpenrouterEndpoint<'messagesCreate'>; + modelsList: OpenrouterEndpoint<'modelsList'>; + modelsCount: OpenrouterEndpoint<'modelsCount'>; + modelsEmbeddingsList: OpenrouterEndpoint<'modelsEmbeddingsList'>; + modelsUserList: OpenrouterEndpoint<'modelsUserList'>; + embeddingsCreate: OpenrouterEndpoint<'embeddingsCreate'>; + modelsEndpointsList: OpenrouterEndpoint<'modelsEndpointsList'>; + providersList: OpenrouterEndpoint<'providersList'>; + zdrEndpointsList: OpenrouterEndpoint<'zdrEndpointsList'>; + creditsCoinbaseCreate: OpenrouterEndpoint<'creditsCoinbaseCreate'>; + generationsGet: OpenrouterEndpoint<'generationsGet'>; + creditsList: OpenrouterEndpoint<'creditsList'>; + keyGet: OpenrouterEndpoint<'keyGet'>; +}; + +const openrouterEndpointsNested = { + chatCompletions: { + create: ChatCompletions.createChatCompletion, + }, + messages: { + create: Messages.createAnthropicMessage, + }, + models: { + list: Models.listModels, + count: Models.listModelsCount, + listEmbeddings: Models.listEmbeddingModels, + listUser: Models.listUserModels, + }, + embeddings: { + create: Embeddings.createEmbedding, + }, + modelEndpoints: { + list: ModelEndpoints.listModelEndpoints, + }, + providers: { + list: Providers.listProviders, + }, + generations: { + get: Generations.getGeneration, + }, + credits: { + list: Credits.listCredits, + createCoinbaseCharge: Credits.createCoinbaseCharge, + }, + key: { + get: Key.getKey, + }, + zdr: { + list: Zdr.listZdrEndpoints, + }, +} as const; + +export const openrouterEndpointSchemas = { + 'chatCompletions.create': { + input: OpenRouterEndpointInputSchemas.chatCompletionsCreate, + output: OpenRouterEndpointOutputSchemas.chatCompletionsCreate, + }, + 'messages.create': { + input: OpenRouterEndpointInputSchemas.messagesCreate, + output: OpenRouterEndpointOutputSchemas.messagesCreate, + }, + 'models.list': { + input: OpenRouterEndpointInputSchemas.modelsList, + output: OpenRouterEndpointOutputSchemas.modelsList, + }, + 'models.count': { + input: OpenRouterEndpointInputSchemas.modelsCount, + output: OpenRouterEndpointOutputSchemas.modelsCount, + }, + 'models.listEmbeddings': { + input: OpenRouterEndpointInputSchemas.modelsEmbeddingsList, + output: OpenRouterEndpointOutputSchemas.modelsEmbeddingsList, + }, + 'models.listUser': { + input: OpenRouterEndpointInputSchemas.modelsUserList, + output: OpenRouterEndpointOutputSchemas.modelsUserList, + }, + 'embeddings.create': { + input: OpenRouterEndpointInputSchemas.embeddingsCreate, + output: OpenRouterEndpointOutputSchemas.embeddingsCreate, + }, + 'modelEndpoints.list': { + input: OpenRouterEndpointInputSchemas.modelsEndpointsList, + output: OpenRouterEndpointOutputSchemas.modelsEndpointsList, + }, + 'providers.list': { + input: OpenRouterEndpointInputSchemas.providersList, + output: OpenRouterEndpointOutputSchemas.providersList, + }, + 'generations.get': { + input: OpenRouterEndpointInputSchemas.generationsGet, + output: OpenRouterEndpointOutputSchemas.generationsGet, + }, + 'credits.list': { + input: OpenRouterEndpointInputSchemas.creditsList, + output: OpenRouterEndpointOutputSchemas.creditsList, + }, + 'credits.createCoinbaseCharge': { + input: OpenRouterEndpointInputSchemas.creditsCoinbaseCreate, + output: OpenRouterEndpointOutputSchemas.creditsCoinbaseCreate, + }, + 'key.get': { + input: OpenRouterEndpointInputSchemas.keyGet, + output: OpenRouterEndpointOutputSchemas.keyGet, + }, + 'zdr.list': { + input: OpenRouterEndpointInputSchemas.zdrEndpointsList, + output: OpenRouterEndpointOutputSchemas.zdrEndpointsList, + }, +} as const satisfies RequiredPluginEndpointSchemas< + typeof openrouterEndpointsNested +>; + +const defaultAuthType: AuthTypes = 'api_key' as const; + +const openrouterEndpointMeta = { + 'chatCompletions.create': { + riskLevel: 'write', + description: + 'Generate an AI response via OpenRouter with automatic multi-provider routing, retries, and fallbacks; supports tool calling, structured output, reasoning, and provider/route overrides', + }, + 'messages.create': { + riskLevel: 'write', + description: + "Create a message via OpenRouter's Anthropic Messages API, with support for system prompts and multi-part content", + }, + 'models.list': { + riskLevel: 'read', + description: + 'List all models available on OpenRouter, including pricing, context length, and supported parameters', + }, + 'models.count': { + riskLevel: 'read', + description: 'Get the total count of models available on OpenRouter', + }, + 'models.listEmbeddings': { + riskLevel: 'read', + description: 'List all embedding models available on OpenRouter', + }, + 'models.listUser': { + riskLevel: 'read', + description: + 'List the models that have been created by the authenticated user', + }, + 'embeddings.create': { + riskLevel: 'write', + description: + 'Generate vector embeddings for one or more input strings using a supported embedding model', + }, + 'modelEndpoints.list': { + riskLevel: 'read', + description: + 'List the individual endpoints serving a model, with per-provider pricing, latency, and throughput', + }, + 'providers.list': { + riskLevel: 'read', + description: + 'List the providers available on OpenRouter with their privacy policies and data-center regions', + }, + 'generations.get': { + riskLevel: 'read', + description: + 'Fetch request and usage metadata for a previous generation by its ID', + }, + 'credits.list': { + riskLevel: 'read', + description: + 'Get the account credit balance and usage, optionally with a Zero-Data Residency (ZDR) report when filter params are provided', + }, + 'credits.createCoinbaseCharge': { + riskLevel: 'write', + description: + 'Create a Coinbase Commerce on-chain charge to top up the account with credits', + }, + 'key.get': { + riskLevel: 'read', + description: + 'Get metadata about the current API key, including usage, limits, and rate limits', + }, + 'zdr.list': { + riskLevel: 'read', + description: + 'List the Zero-Data Residency (ZDR) endpoint specification for the account', + }, +} as const satisfies RequiredPluginEndpointMeta< + typeof openrouterEndpointsNested +>; + +export type BaseOpenrouterPlugin = + CorsairPlugin< + 'openrouter', + typeof OpenrouterSchema, + typeof openrouterEndpointsNested, + {}, + T, + typeof defaultAuthType + >; + +export type InternalOpenrouterPlugin = + BaseOpenrouterPlugin; + +export type ExternalOpenrouterPlugin = + BaseOpenrouterPlugin; + +// The assertion is safe: OpenRouterPluginOptions has no required fields (all +// are optional), so an empty object satisfies the constraint at runtime even +// though TypeScript cannot verify it without the assertion. +export function openrouter( + incomingOptions: OpenRouterPluginOptions & T = {} as OpenRouterPluginOptions & + T, +): ExternalOpenrouterPlugin { + const options = { + ...incomingOptions, + authType: incomingOptions.authType ?? defaultAuthType, + }; + + return { + id: 'openrouter', + schema: OpenrouterSchema, + options, + hooks: options.hooks, + endpoints: openrouterEndpointsNested, + webhooks: {}, + endpointMeta: openrouterEndpointMeta, + endpointSchemas: openrouterEndpointSchemas, + pluginWebhookMatcher: () => false, + errorHandlers: (() => { + // DEFAULT matches everything (`() => true`), so it must always be last. + const { DEFAULT: defaultHandler, ...specificDefaults } = errorHandlers; + return { + ...specificDefaults, + ...(options.errorHandlers || {}), + DEFAULT: options.errorHandlers?.DEFAULT || defaultHandler, + }; + })(), + keyBuilder: async (ctx: OpenrouterKeyBuilderContext, source) => { + if (source === 'endpoint' && options.key) { + return options.key; + } + + if (source === 'endpoint' && ctx.authType === 'api_key') { + const key = await ctx.keys.get_api_key(); + + if (!key) { + throw new AuthMissingError('openrouter', 'api_key'); + } + + return key; + } + + throw new AuthMissingError('openrouter', 'api_key'); + }, + } satisfies InternalOpenrouterPlugin; +} + +export type { + OpenRouterEndpointInputs, + OpenRouterEndpointOutputs, +} from './endpoints/types'; + +export { + OpenRouterEndpointInputSchemas, + OpenRouterEndpointOutputSchemas, +} from './endpoints/types'; diff --git a/packages/openrouter/jest.config.cjs b/packages/openrouter/jest.config.cjs new file mode 100644 index 000000000..8c6218f64 --- /dev/null +++ b/packages/openrouter/jest.config.cjs @@ -0,0 +1,55 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: [''], + testMatch: [ + '**/*.test.ts', + '**/tests/**/*.test.ts', + '**/plugins/**/*.test.ts', + '**/setup/**/*.test.ts', + ], + collectCoverageFrom: [ + '**/*.ts', + '!**/*.d.ts', + '!**/node_modules/**', + '!**/dist/**', + '!jest.config.ts', + '!tests/**', + ], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], + transform: { + '^.+\\.yaml$': '/../corsair/jest-yaml-transform.cjs', + '^.+\\.ts$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + verbatimModuleSyntax: false, + module: 'ESNext', + moduleResolution: 'Bundler', + }, + }, + ], + '.*\\.js$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + }, + }, + ], + }, + moduleNameMapper: { + '^corsair/core$': '/../corsair/core.ts', + '^corsair/http$': '/../corsair/http.ts', + '^(\\.\\.?/.*)\\.js$': '$1', + }, + transformIgnorePatterns: ['node_modules/(?!.*uuid.*)'], + extensionsToTreatAsEsm: ['.ts'], + testTimeout: 30000, + verbose: true, +}; diff --git a/packages/openrouter/package.json b/packages/openrouter/package.json new file mode 100644 index 000000000..356f8b77c --- /dev/null +++ b/packages/openrouter/package.json @@ -0,0 +1,44 @@ +{ + "name": "@corsair-dev/openrouter", + "version": "0.1.0", + "description": "OpenRouter plugin for Corsair — chat completions, generations, credits, models, and more", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "dev-source": "./index.ts", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "rm -rf dist && tsc --build --force && tsup", + "typecheck": "tsc --noEmit", + "test": "jest" + }, + "peerDependencies": { + "corsair": ">=0.1.0", + "zod": "^4.1.13" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "corsair": "workspace:*", + "jest": "^29.7.0", + "ts-jest": "^29.4.9", + "tsup": "^8.0.1", + "typescript": "catalog:", + "zod": "^4.1.13" + }, + "keywords": [ + "corsair", + "openrouter", + "plugin" + ], + "author": "", + "license": "Apache-2.0", + "files": [ + "dist" + ] +} diff --git a/packages/openrouter/schema.test.ts b/packages/openrouter/schema.test.ts new file mode 100644 index 000000000..1d8eae534 --- /dev/null +++ b/packages/openrouter/schema.test.ts @@ -0,0 +1,20 @@ +import { OpenrouterSchema } from './schema'; + +describe('Openrouter schema', () => { + it('declares a semver version', () => { + expect(OpenrouterSchema.version).toBeDefined(); + expect(OpenrouterSchema.version).toMatch(/^\d+\.\d+\.\d+$/); + }); + + it('declares an entities map', () => { + expect(typeof OpenrouterSchema.entities).toBe('object'); + expect(OpenrouterSchema.entities).not.toBeNull(); + expect(Array.isArray(Object.keys(OpenrouterSchema.entities))).toBe(true); + for (const entity of Object.values(OpenrouterSchema.entities)) { + expect(entity).toBeDefined(); + } + }); +}); + +// Per .github/PLUGIN_PR_RULES.md (R2), every implemented endpoint +// needs a corresponding test. diff --git a/packages/openrouter/schema/index.ts b/packages/openrouter/schema/index.ts new file mode 100644 index 000000000..eee3fc448 --- /dev/null +++ b/packages/openrouter/schema/index.ts @@ -0,0 +1,4 @@ +export const OpenrouterSchema = { + version: '1.0.0', + entities: {}, +} as const; diff --git a/packages/openrouter/tsconfig.json b/packages/openrouter/tsconfig.json new file mode 100644 index 000000000..15e507a13 --- /dev/null +++ b/packages/openrouter/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["esnext"], + "types": ["node", "jest"], + "module": "ESNext", + "moduleResolution": "Bundler", + "outDir": "./dist", + "rootDir": "./", + "composite": true, + "incremental": true, + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "skipLibCheck": true + }, + "include": ["./**/*"], + "exclude": ["dist", "node_modules"], + "references": [] +} diff --git a/packages/openrouter/tsup.config.ts b/packages/openrouter/tsup.config.ts new file mode 100644 index 000000000..3ec221e23 --- /dev/null +++ b/packages/openrouter/tsup.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + clean: false, + dts: false, + format: ['esm'], + target: 'esnext', + platform: 'node', + bundle: true, + splitting: true, + minify: true, + outDir: 'dist', + external: ['corsair', 'zod'], + entry: ['index.ts'], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 91d5ded73..9e5379752 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -130,7 +130,7 @@ importers: version: link:../../packages/slack corsair: specifier: ^0.1.4 - version: 0.1.107(postgres@3.4.7)(react@19.2.7) + version: link:../../packages/corsair dotenv: specifier: ^17.4.2 version: 17.4.2 @@ -2118,6 +2118,30 @@ importers: specifier: 4.4.3 version: 4.4.3 + packages/openrouter: + devDependencies: + '@types/jest': + specifier: ^29.5.14 + version: 29.5.14 + corsair: + specifier: workspace:* + version: link:../corsair + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) + ts-jest: + specifier: ^29.4.9 + version: 29.4.9(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.27.0)(jest-util@30.4.1)(jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(typescript@5.9.3) + tsup: + specifier: ^8.0.1 + version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.4)(typescript@5.9.3)(yaml@2.9.0) + typescript: + specifier: 'catalog:' + version: 5.9.3 + zod: + specifier: 4.4.3 + version: 4.4.3 + packages/openweathermap: devDependencies: '@types/jest': @@ -4581,11 +4605,11 @@ packages: '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} - deprecated: 'Merged into tsx: https://tsx.hirok.io' + deprecated: 'Merged into tsx: https://tsx.is' '@esbuild-kit/esm-loader@2.6.5': resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} - deprecated: 'Merged into tsx: https://tsx.hirok.io' + deprecated: 'Merged into tsx: https://tsx.is' '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} @@ -9497,14 +9521,6 @@ packages: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} - corsair@0.1.107: - resolution: {integrity: sha512-fh+iU4agP8j74vd2Uw2DJVLzBJ7hHvsm8bRyuanmpxkWxnXs0NxIsphv7fltSnCwg2l/lG9kEoUUckhv/nbPCg==} - peerDependencies: - react: '>=18.0.0' - peerDependenciesMeta: - react: - optional: true - cosmiconfig@9.0.1: resolution: {integrity: sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==} engines: {node: '>=14'} @@ -21362,17 +21378,6 @@ snapshots: object-assign: 4.1.1 vary: 1.1.2 - corsair@0.1.107(postgres@3.4.7)(react@19.2.7): - dependencies: - kysely: 0.28.17 - kysely-postgres-js: 3.0.0(kysely@0.28.17)(postgres@3.4.7) - uuid: 13.0.0 - zod: 4.4.3 - optionalDependencies: - react: 19.2.7 - transitivePeerDependencies: - - postgres - cosmiconfig@9.0.1(typescript@5.9.3): dependencies: env-paths: 2.2.1 @@ -23490,12 +23495,6 @@ snapshots: kleur@4.1.5: {} - kysely-postgres-js@3.0.0(kysely@0.28.17)(postgres@3.4.7): - dependencies: - kysely: 0.28.17 - optionalDependencies: - postgres: 3.4.7 - kysely-postgres-js@3.0.0(kysely@0.28.9)(postgres@3.4.7): dependencies: kysely: 0.28.9 From 61098907b28569d6566d065eb5cb97434ace82f0 Mon Sep 17 00:00:00 2001 From: Mayank Saini Date: Mon, 10 Aug 2026 13:33:38 +0530 Subject: [PATCH 2/8] fix(openrouter): document and tighten public zod schemas --- packages/openrouter/endpoints/types.ts | 53 +++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/packages/openrouter/endpoints/types.ts b/packages/openrouter/endpoints/types.ts index a1775d812..7c701c24f 100644 --- a/packages/openrouter/endpoints/types.ts +++ b/packages/openrouter/endpoints/types.ts @@ -49,6 +49,9 @@ const ToolSchema = z.object({ function: z.object({ name: z.string(), description: z.string().optional(), + // Parameters are a provider-defined JSON Schema; treating them as a + // generic record is safe (passed through verbatim) and a better type + // is not practical since each tool declares its own schema. parameters: z.record(z.string(), z.unknown()).optional(), }), }); @@ -59,6 +62,8 @@ const ResponseFormatSchema = z.object({ .object({ name: z.string(), strict: z.boolean().optional(), + // Arbitrary JSON Schema per the user's structured-output request; + // passed through verbatim, so no tighter type is practical. schema: z.record(z.string(), z.unknown()).optional(), }) .optional(), @@ -142,6 +147,8 @@ export const CreateChatCompletionOutputSchema = z.object({ usage: CompletionUsageSchema, provider: z.string().optional(), models: z.array(z.string()).optional(), + // Anthropic-style native tool calls vary by model and tool definitions; + // kept generic to stay forward-compatible with new tool shapes. native_tool_calls: z.array(z.unknown()).optional(), }); @@ -172,6 +179,8 @@ export const CreateAnthropicMessageOutputSchema = z.object({ z.object({ type: z.literal('text'), text: z.string(), + // Citation objects differ per provider/source and are evolving; + // kept generic rather than pinning a shape that will drift. citations: z.array(z.unknown()).optional(), }), ), @@ -185,6 +194,8 @@ export const CreateAnthropicMessageOutputSchema = z.object({ .object({ thinking_tokens: z.number().optional() }) .optional(), }) + // Anthropic adds usage fields as models evolve; unknown keys are + // tolerated so newer responses still validate. .catchall(z.unknown()), provider: z.string().optional(), }); @@ -197,7 +208,21 @@ export const ModelSchema = z.object({ created: z.number().optional(), description: z.string().optional(), context_length: z.number().optional(), - pricing: z.record(z.string(), z.unknown()).optional(), + // Pricing keys vary per model (prompt/completion/input_cache_read/ + // discount/overrides...) and values are per-1k-token strings, numeric + // multipliers, or nested override arrays/records; the union covers the + // observed forms. + pricing: z + .record( + z.string(), + z.union([ + z.string(), + z.number(), + z.array(z.unknown()), + z.record(z.string(), z.unknown()), + ]), + ) + .optional(), architecture: z .object({ modality: z.string().optional(), @@ -274,7 +299,18 @@ export const ModelEndpointSchema = z max_completion_tokens: z.number().nullable().optional(), max_prompt_tokens: z.number().nullable().optional(), quantization: z.string().nullable().optional(), - pricing: z.record(z.string(), z.unknown()).optional(), + // Same pricing record shape as ModelSchema (string/number/array/record) + pricing: z + .record( + z.string(), + z.union([ + z.string(), + z.number(), + z.array(z.unknown()), + z.record(z.string(), z.unknown()), + ]), + ) + .optional(), supported_parameters: z.array(z.string()).optional(), status: z.number().optional(), uptime_last_30m: z.number().nullable().optional(), @@ -285,6 +321,8 @@ export const ModelEndpointSchema = z latency_last_30m: p95LatencySchema.nullable().optional(), throughput_last_30m: p95LatencySchema.nullable().optional(), }) + // Providers can add endpoint-level fields (e.g. new uptime metrics); + // unknown keys are tolerated so newer responses still validate. .catchall(z.unknown()); export const ListModelEndpointsOutputSchema = z.object({ @@ -293,6 +331,8 @@ export const ListModelEndpointsOutputSchema = z.object({ name: z.string(), created: z.number().optional(), description: z.string().optional(), + // The model's architecture blob differs across model families; + // kept generic for forward compatibility. architecture: z.record(z.string(), z.unknown()).optional(), endpoints: z.array(ModelEndpointSchema), }), @@ -321,6 +361,8 @@ export const CreateCoinbaseChargeOutputSchema = z.object({ created_at: z.string().optional(), expires_at: z.string().optional(), }) + // The Coinbase charge payload evolves (web3_data etc.); unknown keys + // are tolerated so newer responses still validate. .catchall(z.unknown()), }); @@ -337,6 +379,7 @@ const EmbeddingUsageSchema = z prompt_tokens: z.number(), total_tokens: z.number(), }) + // OpenRouter may append usage fields for new embedding models. .catchall(z.unknown()); export const CreateEmbeddingOutputSchema = z.object({ @@ -387,9 +430,12 @@ export const GetGenerationOutputSchema = z.object({ prompt_tokens: z.number().optional(), completion_tokens: z.number().optional(), total_tokens: z.number().optional(), + // Usage breakdown and the raw provider payload are provider-defined; + // kept generic rather than pinning shapes that vary per provider. usage: z.record(z.string(), z.unknown()).optional(), provider_response: z.record(z.string(), z.unknown()).optional(), }) + // Generation records gain fields over time; unknown keys tolerated. .catchall(z.unknown()), }); @@ -413,6 +459,7 @@ export const ListCreditsOutputSchema = z.object({ soft_limit: z.number().optional(), pending_balance: z.number().optional(), }) + // Credits payload gains ZDR fields as OpenRouter expands it. .catchall(z.unknown()), }); @@ -437,6 +484,8 @@ export const GetKeyOutputSchema = z.object({ expires_at: z.string().nullable().optional(), created_at: z.string().optional(), }) + // Key metadata gains fields as OpenRouter expands it; unknown keys + // are tolerated so newer responses still validate. .catchall(z.unknown()), }); From ad11fbcf51829ab4d9770bad8f6845f28f95b57a Mon Sep 17 00:00:00 2001 From: ambikeesshh Date: Mon, 10 Aug 2026 20:36:28 +0530 Subject: [PATCH 3/8] fix(openrouter): align supported API contracts --- packages/openrouter/api.test.ts | 465 ++++++++++++++---- packages/openrouter/endpoints/credits.ts | 38 +- packages/openrouter/endpoints/embeddings.ts | 2 + packages/openrouter/endpoints/index.ts | 3 +- packages/openrouter/endpoints/messages.ts | 3 + .../openrouter/endpoints/model-endpoints.ts | 2 +- packages/openrouter/endpoints/models.ts | 6 +- packages/openrouter/endpoints/types.ts | 306 +++++++++--- packages/openrouter/error-handlers.ts | 39 +- packages/openrouter/index.ts | 15 +- 10 files changed, 637 insertions(+), 242 deletions(-) diff --git a/packages/openrouter/api.test.ts b/packages/openrouter/api.test.ts index 303a1fdcf..805abd0ec 100644 --- a/packages/openrouter/api.test.ts +++ b/packages/openrouter/api.test.ts @@ -1,3 +1,4 @@ +import type { CorsairErrorHandler } from 'corsair/core'; import { makeOpenRouterRequest } from './client'; import { ChatCompletions, @@ -14,7 +15,6 @@ import { import type { CreateAnthropicMessageResponse, CreateChatCompletionResponse, - CreateCoinbaseChargeResponse, CreateEmbeddingOutput, GetKeyResponse, ListCreditsResponse, @@ -27,28 +27,17 @@ import type { ListZdrEndpointsResponse, } from './endpoints/types'; import { + ChatMessageSchema, OpenRouterEndpointInputSchemas, OpenRouterEndpointOutputSchemas, } from './endpoints/types'; +import { errorHandlers } from './error-handlers'; import type { OpenrouterContext } from './index'; -// Handler tests mock the client; live tests (gated on OPENROUTER_API_KEY) -// fall through to the real implementation via jest.requireActual. +const typedErrorHandlers: CorsairErrorHandler = errorHandlers; + jest.mock('./client', () => ({ - makeOpenRouterRequest: jest.fn().mockImplementation((...args: unknown[]) => { - const actual = jest.requireActual('./client'); - return actual.makeOpenRouterRequest( - args[0] as string, - args[1] as string, - args[2] as - | { - method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; - body?: Record; - query?: Record; - } - | undefined, - ); - }), + makeOpenRouterRequest: jest.fn(), })); const mockRequest = makeOpenRouterRequest as jest.MockedFunction< @@ -111,9 +100,179 @@ describe('OpenRouter schemas', () => { expect(output.success).toBe(true); }); + it('supports multi-turn chat tool calls', () => { + const parsed = ChatMessageSchema.safeParse({ + role: 'assistant', + content: null, + tool_calls: [ + { + id: 'call-1', + type: 'function', + function: { name: 'lookup', arguments: '{"id":"1"}' }, + }, + ], + }); + + expect(parsed.success).toBe(true); + }); + + it('rejects unsupported streaming requests', () => { + const parsed = + OpenRouterEndpointInputSchemas.chatCompletionsCreate.safeParse({ + model: 'openai/gpt-4o-mini', + messages: [{ role: 'user', content: 'Hello' }], + stream: true, + }); + + expect(parsed.success).toBe(false); + }); + + it('supports Anthropic image, tool, and thinking blocks', () => { + const input = OpenRouterEndpointInputSchemas.messagesCreate.safeParse({ + model: 'anthropic/claude-sonnet-4', + maxTokens: 1024, + messages: [ + { + role: 'user', + content: [ + { + type: 'image', + source: { + type: 'base64', + media_type: 'image/png', + data: 'aW1hZ2U=', + }, + }, + { + type: 'document', + source: { + type: 'text', + media_type: 'text/plain', + data: 'Document text', + }, + }, + { + type: 'document', + source: { + type: 'file', + file_id: 'file-1', + }, + }, + { + type: 'document', + title: null, + context: null, + source: { + type: 'content', + content: [{ type: 'text', text: 'Nested text' }], + }, + }, + { + type: 'tool_result', + tool_use_id: 'tool-1', + content: [{ type: 'text', text: 'done' }], + }, + ], + }, + ], + tools: [ + { + name: 'lookup', + description: 'Look up a record', + input_schema: { type: 'object' }, + }, + ], + thinking: { type: 'enabled', budget_tokens: 1024 }, + }); + expect(input.success).toBe(true); + + const invalidToolChoice = + OpenRouterEndpointInputSchemas.messagesCreate.safeParse({ + model: 'anthropic/claude-sonnet-4', + maxTokens: 1024, + messages: [{ role: 'user', content: 'Hello' }], + toolChoice: { type: 'tool' }, + }); + expect(invalidToolChoice.success).toBe(false); + + const output = OpenRouterEndpointOutputSchemas.messagesCreate.safeParse({ + id: 'msg-1', + type: 'message', + role: 'assistant', + model: 'anthropic/claude-sonnet-4', + stop_reason: 'tool_use', + content: [ + { type: 'thinking', thinking: 'Need a lookup', signature: 'sig' }, + { + type: 'tool_use', + id: 'tool-1', + name: 'lookup', + input: { id: '1' }, + }, + ], + usage: { + input_tokens: 10, + output_tokens: 20, + output_tokens_details: null, + }, + }); + expect(output.success).toBe(true); + + const nullableTextOutput = + OpenRouterEndpointOutputSchemas.messagesCreate.safeParse({ + id: 'msg-2', + type: 'message', + role: 'assistant', + model: 'anthropic/claude-sonnet-4', + stop_reason: 'end_turn', + content: [{ type: 'text', text: 'Hi', citations: null }], + usage: { input_tokens: 1, output_tokens: 1 }, + }); + expect(nullableTextOutput.success).toBe(true); + }); + + it('supports reasoning output and per-request ZDR', () => { + const input = + OpenRouterEndpointInputSchemas.chatCompletionsCreate.safeParse({ + model: 'openai/o4-mini', + messages: [{ role: 'user', content: 'Reason about this' }], + reasoning: { effort: 'xhigh', summary: 'detailed' }, + provider: { zdr: true }, + }); + expect(input.success).toBe(true); + + const output = + OpenRouterEndpointOutputSchemas.chatCompletionsCreate.safeParse({ + id: 'gen-1', + object: 'chat.completion', + created: 1700000000, + model: 'openai/o4-mini', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'Answer', + reasoning: 'Reasoning text', + reasoning_details: [{ type: 'reasoning.text', text: 'detail' }], + }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + }); + expect(output.success).toBe(true); + }); + it('parses models.list input and response', () => { - const input = OpenRouterEndpointInputSchemas.modelsList.safeParse({}); + const input = OpenRouterEndpointInputSchemas.modelsList.safeParse({ + offset: 10, + limit: 25, + }); expect(input.success).toBe(true); + if (input.success) { + expect(input.data).toMatchObject({ offset: 10, limit: 25 }); + } const output = OpenRouterEndpointOutputSchemas.modelsList.safeParse({ data: [ @@ -130,6 +289,22 @@ describe('OpenRouter schemas', () => { expect(output.success).toBe(true); }); + it('preserves model pagination metadata', () => { + const output = OpenRouterEndpointOutputSchemas.modelsList.safeParse({ + data: [], + links: { next: 'https://openrouter.ai/api/v1/models?cursor=next' }, + total_count: 42, + }); + + expect(output.success).toBe(true); + if (output.success) { + expect(output.data).toMatchObject({ + links: { next: 'https://openrouter.ai/api/v1/models?cursor=next' }, + total_count: 42, + }); + } + }); + it('parses embeddings.create input and response', () => { const input = OpenRouterEndpointInputSchemas.embeddingsCreate.safeParse({ model: 'openai/text-embedding-3-small', @@ -147,6 +322,21 @@ describe('OpenRouter schemas', () => { expect(output.success).toBe(true); }); + it('accepts token embedding input and responses without usage', () => { + const input = OpenRouterEndpointInputSchemas.embeddingsCreate.safeParse({ + model: 'openai/text-embedding-3-small', + input: [12, 34, 56], + }); + expect(input.success).toBe(true); + + const output = OpenRouterEndpointOutputSchemas.embeddingsCreate.safeParse({ + object: 'list', + data: [{ object: 'embedding', embedding: [0.1, 0.2] }], + model: 'openai/text-embedding-3-small', + }); + expect(output.success).toBe(true); + }); + it('parses modelEndpoints.list input and response', () => { const input = OpenRouterEndpointInputSchemas.modelsEndpointsList.safeParse({ author: 'openai', @@ -205,16 +395,41 @@ describe('OpenRouter schemas', () => { expect(output.success).toBe(true); }); - it('parses credits.list input and response', () => { - const input = OpenRouterEndpointInputSchemas.creditsList.safeParse({ - query: '2024-01-01', + it('parses the official numeric generation usage field', () => { + const output = OpenRouterEndpointOutputSchemas.generationsGet.safeParse({ + data: { + id: 'gen-1', + provider_name: null, + usage: 0.0025, + streamed: null, + tokens_prompt: null, + tokens_completion: null, + provider_responses: null, + }, }); + + expect(output.success).toBe(true); + }); + + it('parses credits.list input and response', () => { + const input = OpenRouterEndpointInputSchemas.creditsList.safeParse({}); expect(input.success).toBe(true); + const unsupportedFilters = + OpenRouterEndpointInputSchemas.creditsList.safeParse({ + query: '2024-01-01', + }); + expect(unsupportedFilters.success).toBe(false); + const output = OpenRouterEndpointOutputSchemas.creditsList.safeParse({ data: { total_credits: 10, total_usage: 3.5 }, }); expect(output.success).toBe(true); + + const missingUsage = OpenRouterEndpointOutputSchemas.creditsList.safeParse({ + data: { total_credits: 10 }, + }); + expect(missingUsage.success).toBe(false); }); it('parses key.get input and response', () => { @@ -225,11 +440,20 @@ describe('OpenRouter schemas', () => { data: { label: 'test-key', usage: 0.0000075, + usage_daily: 0.000001, + byok_usage: 0, limit: null, + limit_reset: null, + include_byok_in_limit: false, + creator_user_id: 'user-1', is_free_tier: true, }, }); expect(output.success).toBe(true); + if (output.success) { + expect(output.data.data.usage_daily).toBe(0.000001); + expect(output.data.data.include_byok_in_limit).toBe(false); + } }); it('parses models.count input and response', () => { @@ -265,8 +489,14 @@ describe('OpenRouter schemas', () => { }); it('parses models.listUser input and response', () => { - const input = OpenRouterEndpointInputSchemas.modelsUserList.safeParse({}); + const input = OpenRouterEndpointInputSchemas.modelsUserList.safeParse({ + offset: 5, + limit: 50, + }); expect(input.success).toBe(true); + if (input.success) { + expect(input.data).toMatchObject({ offset: 5, limit: 50 }); + } const output = OpenRouterEndpointOutputSchemas.modelsUserList.safeParse({ data: [ @@ -296,40 +526,12 @@ describe('OpenRouter schemas', () => { expect(output.success).toBe(true); }); - it('parses credits.createCoinbaseCharge input and response', () => { - const input = - OpenRouterEndpointInputSchemas.creditsCoinbaseCreate.safeParse({ - amount: 50.25, - sender: '0x1234567890123456789012345678901234567890', - chainId: 8453, - }); - expect(input.success).toBe(true); - - const invalid = - OpenRouterEndpointInputSchemas.creditsCoinbaseCreate.safeParse({ - amount: 10, - sender: '0x1234', - chainId: 999, - }); - expect(invalid.success).toBe(false); - - const output = - OpenRouterEndpointOutputSchemas.creditsCoinbaseCreate.safeParse({ - data: { - id: 'charge-id', - chain_id: 8453, - sender: '0x1234567890123456789012345678901234567890', - addresses: { - '8453:0xcharge123': '0xcharge123', - }, - calldata: { - '8453:0xcharge123': '0xdeadbeef', - }, - created_at: '2026-01-01T00:00:00Z', - expires_at: '2026-01-08T00:00:00Z', - }, - }); - expect(output.success).toBe(true); + it('exposes exactly 13 supported operations', () => { + expect(Object.keys(OpenRouterEndpointInputSchemas)).toHaveLength(13); + expect('creditsCoinbaseCreate' in OpenRouterEndpointInputSchemas).toBe( + false, + ); + expect('embeddingsCreate' in OpenRouterEndpointInputSchemas).toBe(true); }); it('rejects invalid chatCompletions.create input', () => { @@ -344,7 +546,16 @@ describe('OpenRouter schemas', () => { describe('OpenRouter endpoint handlers (mocked client)', () => { beforeEach(() => { - mockRequest.mockClear(); + mockRequest.mockReset(); + mockRequest.mockRejectedValue( + new Error('Unexpected unmocked OpenRouter request in offline test'), + ); + }); + + it('does not fall through to the live API', async () => { + await expect(Models.listModels(testCtx('k'), {})).rejects.toThrow( + 'Unexpected unmocked OpenRouter request', + ); }); it('ChatCompletions.createChatCompletion POSTs to chat/completions', async () => { @@ -406,6 +617,8 @@ describe('OpenRouter endpoint handlers (mocked client)', () => { maxTokens: 64, messages: [{ role: 'user', content: 'Hi' }], system: 'Be brief', + tools: [{ name: 'lookup', input_schema: { type: 'object' } }], + thinking: { type: 'enabled', budget_tokens: 1024 }, }); expect(mockRequest).toHaveBeenCalledWith( @@ -418,10 +631,12 @@ describe('OpenRouter endpoint handlers (mocked client)', () => { max_tokens: 64, stream: false, system: 'Be brief', + tools: [{ name: 'lookup', input_schema: { type: 'object' } }], + thinking: { type: 'enabled', budget_tokens: 1024 }, }), }), ); - expect(result.content[0]?.text).toBe('Hi'); + expect(result.content[0]).toMatchObject({ type: 'text', text: 'Hi' }); }); it('Models.listModels GETs models', async () => { @@ -430,9 +645,14 @@ describe('OpenRouter endpoint handlers (mocked client)', () => { } as ListModelsResponse; mockRequest.mockResolvedValueOnce(response); - const result = await Models.listModels(testCtx('k'), {}); + const result = await Models.listModels(testCtx('k'), { + offset: 10, + limit: 25, + }); - expect(mockRequest).toHaveBeenCalledWith('models', 'k'); + expect(mockRequest).toHaveBeenCalledWith('models', 'k', { + query: { offset: 10, limit: 25 }, + }); expect(result.data[0]?.id).toBe('openai/gpt-4o-mini'); }); @@ -483,6 +703,22 @@ describe('OpenRouter endpoint handlers (mocked client)', () => { ); }); + it('encodes model endpoint path segments', async () => { + mockRequest.mockResolvedValueOnce({ + data: { id: 'author/model', name: 'Model', endpoints: [] }, + }); + + await ModelEndpoints.listModelEndpoints(testCtx('k'), { + author: 'author/name', + slug: 'model?variant=free', + }); + + expect(mockRequest).toHaveBeenCalledWith( + 'models/author%2Fname/model%3Fvariant%3Dfree/endpoints', + 'k', + ); + }); + it('Models.listModelsCount GETs models/count', async () => { const response = { data: { count: 400 } } as ListModelsCountResponse; mockRequest.mockResolvedValueOnce(response); @@ -518,9 +754,14 @@ describe('OpenRouter endpoint handlers (mocked client)', () => { } as ListUserModelsResponse; mockRequest.mockResolvedValueOnce(response); - const result = await Models.listUserModels(testCtx('k'), {}); + const result = await Models.listUserModels(testCtx('k'), { + offset: 5, + limit: 50, + }); - expect(mockRequest).toHaveBeenCalledWith('models/user', 'k'); + expect(mockRequest).toHaveBeenCalledWith('models/user', 'k', { + query: { offset: 5, limit: 50 }, + }); expect(result.data[0]?.id).toBe('myorg/custom-model'); }); @@ -536,37 +777,6 @@ describe('OpenRouter endpoint handlers (mocked client)', () => { expect(result.data[0]?.provider_name).toBe('OpenAI'); }); - it('Credits.createCoinbaseCharge POSTs to credits/coinbase', async () => { - const response = { - data: { - id: 'charge-id', - chain_id: 8453, - sender: '0x1234567890123456789012345678901234567890', - }, - } as CreateCoinbaseChargeResponse; - mockRequest.mockResolvedValueOnce(response); - - const result = await Credits.createCoinbaseCharge(testCtx('k'), { - amount: 50.25, - sender: '0x1234567890123456789012345678901234567890', - chainId: 8453, - }); - - expect(mockRequest).toHaveBeenCalledWith( - 'credits/coinbase', - 'k', - expect.objectContaining({ - method: 'POST', - body: { - amount: 50.25, - sender: '0x1234567890123456789012345678901234567890', - chain_id: 8453, - }, - }), - ); - expect(result.data.id).toBe('charge-id'); - }); - it('Providers.listProviders GETs providers', async () => { const response = { data: [{ name: 'OpenAI', slug: 'openai' }], @@ -595,7 +805,7 @@ describe('OpenRouter endpoint handlers (mocked client)', () => { expect(result.data.id).toBe('gen-1'); }); - it('Credits.listCredits GETs credits with optional ZDR params', async () => { + it('Credits.listCredits GETs credits', async () => { const response = { data: { total_credits: 10, total_usage: 2 }, } as ListCreditsResponse; @@ -603,14 +813,7 @@ describe('OpenRouter endpoint handlers (mocked client)', () => { const result = await Credits.listCredits(testCtx('k'), {}); - expect(mockRequest).toHaveBeenCalledWith('credits', 'k', { - query: { - query: undefined, - cursor: undefined, - per_page: undefined, - max_age: undefined, - }, - }); + expect(mockRequest).toHaveBeenCalledWith('credits', 'k'); expect(result.data.total_credits).toBe(10); }); @@ -627,10 +830,68 @@ describe('OpenRouter endpoint handlers (mocked client)', () => { }); }); +describe('OpenRouter error handlers', () => { + const writeContext = { + pluginId: 'openrouter', + operation: 'chatCompletions.create', + input: {}, + originalError: new Error('server error'), + }; + const readContext = { + ...writeContext, + operation: 'models.list', + }; + + it('does not retry paid write operations on server errors', async () => { + const strategy = await typedErrorHandlers.SERVER_ERROR!.handler( + new Error('server error'), + writeContext, + ); + expect(strategy.maxRetries).toBe(0); + }); + + it('retries read operations on server errors', async () => { + const strategy = await typedErrorHandlers.SERVER_ERROR!.handler( + new Error('server error'), + readContext, + ); + expect(strategy.maxRetries).toBe(3); + }); + + it('does not retry paid writes after timeouts', async () => { + expect( + typedErrorHandlers.TIMEOUT_ERROR!.match( + new Error('request timed out'), + writeContext, + ), + ).toBe(true); + + const strategy = await typedErrorHandlers.TIMEOUT_ERROR!.handler( + new Error('request timed out'), + writeContext, + ); + expect(strategy.maxRetries).toBe(0); + }); + + it('does not classify unrelated numeric messages as rate limits', () => { + expect( + typedErrorHandlers.RATE_LIMIT_ERROR!.match( + new Error('model-4290 completed in 4290ms'), + readContext, + ), + ).toBe(false); + }); +}); + const TEST_API_KEY = process.env.OPENROUTER_API_KEY; const describeIfApiKey = TEST_API_KEY ? describe : describe.skip; describeIfApiKey('OpenRouter API type tests (live)', () => { + const makeOpenRouterRequest = + jest.requireActual( + './client', + ).makeOpenRouterRequest; + it('chat completion returns the expected shape', async () => { const response = await makeOpenRouterRequest( 'chat/completions', diff --git a/packages/openrouter/endpoints/credits.ts b/packages/openrouter/endpoints/credits.ts index bf5e096b3..fe54dc4bb 100644 --- a/packages/openrouter/endpoints/credits.ts +++ b/packages/openrouter/endpoints/credits.ts @@ -1,48 +1,16 @@ import type { OpenRouterEndpoints } from './..'; import { makeOpenRouterRequest } from '../client'; -import type { - CreateCoinbaseChargeResponse, - ListCreditsResponse, -} from './types'; +import type { ListCreditsResponse } from './types'; -// GET /credits returns the account credit balance, and optionally a -// Zero-Data Residency (ZDR) report when filter params are supplied. +// GET /credits returns the account credit balance for a management key. export const listCredits: OpenRouterEndpoints['creditsList'] = async ( ctx, - input, + _input, ) => { const result = await makeOpenRouterRequest( 'credits', ctx.key, - { - query: { - query: input.query, - cursor: input.cursor, - per_page: input.perPage, - max_age: input.maxAge, - }, - }, ); return result; }; - -// POST /credits/coinbase creates a Coinbase Commerce on-chain charge -// to top up the account with credits. -export const createCoinbaseCharge: OpenRouterEndpoints['creditsCoinbaseCreate'] = - async (ctx, input) => { - const result = await makeOpenRouterRequest( - 'credits/coinbase', - ctx.key, - { - method: 'POST', - body: { - amount: input.amount, - sender: input.sender, - chain_id: input.chainId, - }, - }, - ); - - return result; - }; diff --git a/packages/openrouter/endpoints/embeddings.ts b/packages/openrouter/endpoints/embeddings.ts index cefcb4cc0..0f16136fc 100644 --- a/packages/openrouter/endpoints/embeddings.ts +++ b/packages/openrouter/endpoints/embeddings.ts @@ -17,6 +17,8 @@ export const createEmbedding: OpenRouterEndpoints['embeddingsCreate'] = async ( encoding_format: input.encodingFormat, dimensions: input.dimensions, user: input.user, + input_type: input.inputType, + provider: input.provider, }, }, ); diff --git a/packages/openrouter/endpoints/index.ts b/packages/openrouter/endpoints/index.ts index 394bcb759..4b5c87c5e 100644 --- a/packages/openrouter/endpoints/index.ts +++ b/packages/openrouter/endpoints/index.ts @@ -1,5 +1,5 @@ import { createChatCompletion } from './chat-completions'; -import { createCoinbaseCharge, listCredits } from './credits'; +import { listCredits } from './credits'; import { createEmbedding } from './embeddings'; import { getGeneration } from './generations'; import { getKey } from './key'; @@ -47,7 +47,6 @@ export const Generations = { export const Credits = { listCredits, - createCoinbaseCharge, }; export const Key = { diff --git a/packages/openrouter/endpoints/messages.ts b/packages/openrouter/endpoints/messages.ts index c6b6f1f09..262c950f3 100644 --- a/packages/openrouter/endpoints/messages.ts +++ b/packages/openrouter/endpoints/messages.ts @@ -21,6 +21,9 @@ export const createAnthropicMessage: OpenRouterEndpoints['messagesCreate'] = top_p: input.topP, stop_sequences: input.stopSequences, stream: false, + tools: input.tools, + tool_choice: input.toolChoice, + thinking: input.thinking, }, }, ); diff --git a/packages/openrouter/endpoints/model-endpoints.ts b/packages/openrouter/endpoints/model-endpoints.ts index 04e1cee8d..1e25b106f 100644 --- a/packages/openrouter/endpoints/model-endpoints.ts +++ b/packages/openrouter/endpoints/model-endpoints.ts @@ -5,7 +5,7 @@ import type { ListModelEndpointsResponse } from './types'; export const listModelEndpoints: OpenRouterEndpoints['modelsEndpointsList'] = async (ctx, input) => { const result = await makeOpenRouterRequest( - `models/${input.author}/${input.slug}/endpoints`, + `models/${encodeURIComponent(input.author)}/${encodeURIComponent(input.slug)}/endpoints`, ctx.key, ); diff --git a/packages/openrouter/endpoints/models.ts b/packages/openrouter/endpoints/models.ts index 5a84dc065..14a6a090e 100644 --- a/packages/openrouter/endpoints/models.ts +++ b/packages/openrouter/endpoints/models.ts @@ -9,11 +9,12 @@ import type { export const listModels: OpenRouterEndpoints['modelsList'] = async ( ctx, - _input, + input, ) => { const result = await makeOpenRouterRequest( 'models', ctx.key, + { query: { offset: input.offset, limit: input.limit } }, ); return result; @@ -49,11 +50,12 @@ export const listEmbeddingModels: OpenRouterEndpoints['modelsEmbeddingsList'] = export const listUserModels: OpenRouterEndpoints['modelsUserList'] = async ( ctx, - _input, + input, ) => { const result = await makeOpenRouterRequest( 'models/user', ctx.key, + { query: { offset: input.offset, limit: input.limit } }, ); return result; diff --git a/packages/openrouter/endpoints/types.ts b/packages/openrouter/endpoints/types.ts index 7c701c24f..8a849f1a1 100644 --- a/packages/openrouter/endpoints/types.ts +++ b/packages/openrouter/endpoints/types.ts @@ -15,6 +15,15 @@ const MessagePartSchema = z.union([ }), ]); +const ToolCallSchema = z.object({ + id: z.string(), + type: z.literal('function'), + function: z.object({ + name: z.string(), + arguments: z.string(), + }), +}); + export const ChatMessageSchema = z.union([ z.object({ role: z.literal('system'), @@ -22,7 +31,11 @@ export const ChatMessageSchema = z.union([ }), z.object({ role: z.literal('assistant'), - content: z.union([z.string(), z.array(MessagePartSchema)]).optional(), + content: z + .union([z.string(), z.array(MessagePartSchema)]) + .nullable() + .optional(), + tool_calls: z.array(ToolCallSchema).optional(), }), z.object({ role: z.literal('user'), @@ -31,19 +44,10 @@ export const ChatMessageSchema = z.union([ z.object({ role: z.literal('tool'), tool_call_id: z.string(), - content: z.string(), + content: z.union([z.string(), z.array(MessagePartSchema)]), }), ]); -const ToolCallSchema = z.object({ - id: z.string(), - type: z.literal('function'), - function: z.object({ - name: z.string(), - arguments: z.string(), - }), -}); - const ToolSchema = z.object({ type: z.literal('function'), function: z.object({ @@ -69,10 +73,19 @@ const ResponseFormatSchema = z.object({ .optional(), }); +const ProviderPreferencesSchema = z.object({ + order: z.array(z.string()).optional(), + allow_fallbacks: z.boolean().optional(), + ignore: z.array(z.string()).optional(), + require_parameters: z.boolean().optional(), + data_collection: z.string().optional(), + zdr: z.boolean().optional(), +}); + export const CreateChatCompletionInputSchema = z.object({ model: z.string(), messages: z.array(ChatMessageSchema).min(1), - stream: z.boolean().optional(), + stream: z.literal(false).optional(), temperature: z.number().optional(), topP: z.number().optional(), maxTokens: z.number().optional(), @@ -95,20 +108,17 @@ export const CreateChatCompletionInputSchema = z.object({ ]) .optional(), reasoning: z - .object({ effort: z.enum(['low', 'medium', 'high']).optional() }) + .object({ + effort: z + .enum(['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']) + .optional(), + summary: z.enum(['auto', 'concise', 'detailed']).nullable().optional(), + }) .optional(), transforms: z.array(z.string()).optional(), models: z.array(z.string()).optional(), route: z.string().optional(), - provider: z - .object({ - order: z.array(z.string()).optional(), - allow_fallbacks: z.boolean().optional(), - ignore: z.array(z.string()).optional(), - require_parameters: z.boolean().optional(), - data_collection: z.string().optional(), - }) - .optional(), + provider: ProviderPreferencesSchema.optional(), plugins: z .array( z.object({ @@ -126,6 +136,23 @@ export const CompletionUsageSchema = z.object({ prompt_tokens: z.number(), completion_tokens: z.number(), total_tokens: z.number(), + prompt_tokens_details: z + .object({ + cached_tokens: z.number().optional(), + cache_write_tokens: z.number().optional(), + audio_tokens: z.number().optional(), + video_tokens: z.number().optional(), + }) + .optional(), + completion_tokens_details: z + .object({ + reasoning_tokens: z.number().optional(), + audio_tokens: z.number().optional(), + image_tokens: z.number().optional(), + }) + .optional(), + cost: z.number().optional(), + is_byok: z.boolean().optional(), }); export const CreateChatCompletionOutputSchema = z.object({ @@ -140,11 +167,15 @@ export const CreateChatCompletionOutputSchema = z.object({ role: z.literal('assistant'), content: z.string().nullable(), tool_calls: z.array(ToolCallSchema).optional(), + reasoning: z.string().nullable().optional(), + reasoning_details: z + .array(z.record(z.string(), z.unknown())) + .optional(), }), finish_reason: z.string().nullable(), }), ), - usage: CompletionUsageSchema, + usage: CompletionUsageSchema.optional(), provider: z.string().optional(), models: z.array(z.string()).optional(), // Anthropic-style native tool calls vary by model and tool definitions; @@ -152,6 +183,93 @@ export const CreateChatCompletionOutputSchema = z.object({ native_tool_calls: z.array(z.unknown()).optional(), }); +const AnthropicBase64SourceSchema = z.object({ + type: z.literal('base64'), + media_type: z.string(), + data: z.string(), +}); + +const AnthropicUrlSourceSchema = z.object({ + type: z.literal('url'), + url: z.string().url(), +}); + +const AnthropicImageSourceSchema = z.union([ + AnthropicBase64SourceSchema, + AnthropicUrlSourceSchema, +]); + +const AnthropicDocumentContentPartSchema = z.union([ + z.object({ type: z.literal('text'), text: z.string() }), + z.object({ + type: z.literal('image'), + source: AnthropicImageSourceSchema, + }), +]); + +const AnthropicDocumentSourceSchema = z.union([ + AnthropicBase64SourceSchema, + AnthropicUrlSourceSchema, + z.object({ + type: z.literal('text'), + media_type: z.literal('text/plain'), + data: z.string(), + }), + z.object({ + type: z.literal('content'), + content: z.union([z.string(), z.array(AnthropicDocumentContentPartSchema)]), + }), + z.object({ + type: z.literal('file'), + file_id: z.string(), + }), +]); + +const AnthropicContentBlockSchema = z.union([ + z.object({ type: z.literal('text'), text: z.string() }), + z.object({ + type: z.literal('image'), + source: AnthropicImageSourceSchema, + }), + z.object({ + type: z.literal('document'), + source: AnthropicDocumentSourceSchema, + title: z.string().nullable().optional(), + context: z.string().nullable().optional(), + citations: z + .object({ enabled: z.boolean().optional() }) + .nullable() + .optional(), + }), + z.object({ + type: z.literal('tool_use'), + id: z.string(), + name: z.string(), + input: z.record(z.string(), z.unknown()), + }), + z.object({ + type: z.literal('tool_result'), + tool_use_id: z.string(), + content: z.union([z.string(), z.array(z.unknown())]), + is_error: z.boolean().optional(), + }), + z.object({ + type: z.literal('thinking'), + thinking: z.string(), + signature: z.string().optional(), + }), + z.object({ + type: z.literal('redacted_thinking'), + data: z.string(), + }), +]); + +const AnthropicToolSchema = z.object({ + name: z.string(), + description: z.string().optional(), + input_schema: z.record(z.string(), z.unknown()), +}); + export const CreateAnthropicMessageInputSchema = z.object({ model: z.string(), maxTokens: z.number(), @@ -159,7 +277,7 @@ export const CreateAnthropicMessageInputSchema = z.object({ .array( z.object({ role: z.enum(['user', 'assistant']), - content: z.union([z.string(), z.array(MessagePartSchema)]), + content: z.union([z.string(), z.array(AnthropicContentBlockSchema)]), }), ) .min(1), @@ -167,6 +285,22 @@ export const CreateAnthropicMessageInputSchema = z.object({ temperature: z.number().optional(), topP: z.number().optional(), stopSequences: z.array(z.string()).optional(), + tools: z.array(AnthropicToolSchema).optional(), + toolChoice: z + .union([ + z.object({ type: z.literal('tool'), name: z.string() }), + z.object({ type: z.enum(['auto', 'any', 'none']) }), + ]) + .optional(), + thinking: z + .union([ + z.object({ + type: z.literal('enabled'), + budget_tokens: z.number().int().positive(), + }), + z.object({ type: z.literal('disabled') }), + ]) + .optional(), }); export const CreateAnthropicMessageOutputSchema = z.object({ @@ -176,13 +310,30 @@ export const CreateAnthropicMessageOutputSchema = z.object({ model: z.string(), stop_reason: z.string().nullable(), content: z.array( - z.object({ - type: z.literal('text'), - text: z.string(), - // Citation objects differ per provider/source and are evolving; - // kept generic rather than pinning a shape that will drift. - citations: z.array(z.unknown()).optional(), - }), + z.union([ + z.object({ + type: z.literal('text'), + text: z.string(), + // Citation objects differ per provider/source and are evolving; + // kept generic rather than pinning a shape that will drift. + citations: z.array(z.unknown()).nullable().optional(), + }), + z.object({ + type: z.literal('thinking'), + thinking: z.string(), + signature: z.string().optional(), + }), + z.object({ + type: z.literal('redacted_thinking'), + data: z.string(), + }), + z.object({ + type: z.literal('tool_use'), + id: z.string(), + name: z.string(), + input: z.record(z.string(), z.unknown()), + }), + ]), ), usage: z .object({ @@ -192,6 +343,7 @@ export const CreateAnthropicMessageOutputSchema = z.object({ cache_creation_input_tokens: z.number().nullable().optional(), output_tokens_details: z .object({ thinking_tokens: z.number().optional() }) + .nullable() .optional(), }) // Anthropic adds usage fields as models evolve; unknown keys are @@ -200,7 +352,12 @@ export const CreateAnthropicMessageOutputSchema = z.object({ provider: z.string().optional(), }); -export const ListModelsInputSchema = z.object({}); +const ModelListPaginationSchema = z.object({ + offset: z.number().int().nonnegative().optional(), + limit: z.number().int().min(1).max(1000).optional(), +}); + +export const ListModelsInputSchema = ModelListPaginationSchema; export const ModelSchema = z.object({ id: z.string(), @@ -251,6 +408,8 @@ export const ModelSchema = z.object({ export const ListModelsOutputSchema = z.object({ data: z.array(ModelSchema), + links: z.object({ next: z.string().nullable().optional() }).optional(), + total_count: z.number().optional(), }); export const ListModelsCountInputSchema = z.object({}); @@ -262,18 +421,22 @@ export const ListModelsCountOutputSchema = z.object({ }); export const ListEmbeddingModelsInputSchema = z.object({ - offset: z.number().optional(), - limit: z.number().optional(), + offset: z.number().int().nonnegative().optional(), + limit: z.number().int().min(1).max(1000).optional(), }); export const ListEmbeddingModelsOutputSchema = z.object({ data: z.array(ModelSchema), + links: z.object({ next: z.string().nullable().optional() }).optional(), + total_count: z.number().optional(), }); -export const ListUserModelsInputSchema = z.object({}); +export const ListUserModelsInputSchema = ModelListPaginationSchema; export const ListUserModelsOutputSchema = z.object({ data: z.array(ModelSchema), + links: z.object({ next: z.string().nullable().optional() }).optional(), + total_count: z.number().optional(), }); export const ListModelEndpointsInputSchema = z.object({ @@ -344,34 +507,20 @@ export const ListZdrEndpointsOutputSchema = z.object({ data: z.array(ModelEndpointSchema), }); -export const CreateCoinbaseChargeInputSchema = z.object({ - amount: z.number(), - sender: z.string(), - chainId: z.union([z.literal(1), z.literal(137), z.literal(8453)]), -}); - -export const CreateCoinbaseChargeOutputSchema = z.object({ - data: z - .object({ - id: z.string().optional(), - chain_id: z.number().optional(), - sender: z.string().optional(), - addresses: z.record(z.string(), z.string()).optional(), - calldata: z.record(z.string(), z.string()).optional(), - created_at: z.string().optional(), - expires_at: z.string().optional(), - }) - // The Coinbase charge payload evolves (web3_data etc.); unknown keys - // are tolerated so newer responses still validate. - .catchall(z.unknown()), -}); - export const CreateEmbeddingInputSchema = z.object({ model: z.string(), - input: z.union([z.string(), z.array(z.string())]), + input: z.union([ + z.string(), + z.array(z.string()), + z.array(z.number()), + z.array(z.array(z.number())), + z.array(z.record(z.string(), z.unknown())), + ]), encodingFormat: z.enum(['float', 'base64']).optional(), dimensions: z.number().optional(), user: z.string().optional(), + inputType: z.string().optional(), + provider: ProviderPreferencesSchema.optional(), }); const EmbeddingUsageSchema = z @@ -393,7 +542,7 @@ export const CreateEmbeddingOutputSchema = z.object({ }), ), model: z.string(), - usage: EmbeddingUsageSchema, + usage: EmbeddingUsageSchema.optional(), }); export const ListProvidersInputSchema = z.object({}); @@ -422,9 +571,10 @@ export const GetGenerationOutputSchema = z.object({ id: z.string(), model: z.string().optional(), provider: z.string().optional(), + provider_name: z.string().nullable().optional(), api_type: z.string().nullable().optional(), created_at: z.string().optional(), - streamed: z.boolean().optional(), + streamed: z.boolean().nullable().optional(), finish_reason: z.string().nullable().optional(), total_cost: z.number().nullable().optional(), prompt_tokens: z.number().optional(), @@ -432,19 +582,19 @@ export const GetGenerationOutputSchema = z.object({ total_tokens: z.number().optional(), // Usage breakdown and the raw provider payload are provider-defined; // kept generic rather than pinning shapes that vary per provider. - usage: z.record(z.string(), z.unknown()).optional(), + usage: z + .union([z.number(), z.record(z.string(), z.unknown())]) + .optional(), + tokens_prompt: z.number().nullable().optional(), + tokens_completion: z.number().nullable().optional(), + provider_responses: z.array(z.unknown()).nullable().optional(), provider_response: z.record(z.string(), z.unknown()).optional(), }) // Generation records gain fields over time; unknown keys tolerated. .catchall(z.unknown()), }); -export const GetCreditsInputSchema = z.object({ - query: z.string().optional(), - cursor: z.string().optional(), - perPage: z.number().optional(), - maxAge: z.number().optional(), -}); +export const GetCreditsInputSchema = z.object({}).strict(); export const ListCreditsInputSchema = GetCreditsInputSchema; @@ -452,7 +602,7 @@ export const ListCreditsOutputSchema = z.object({ data: z .object({ total_credits: z.number(), - total_usage: z.number().optional(), + total_usage: z.number(), limit_reached: z.boolean().optional(), prepaid: z.number().optional(), billed_prepaid: z.number().optional(), @@ -470,8 +620,18 @@ export const GetKeyOutputSchema = z.object({ .object({ label: z.string().optional(), usage: z.number(), + usage_daily: z.number().optional(), + usage_weekly: z.number().optional(), + usage_monthly: z.number().optional(), + byok_usage: z.number().optional(), + byok_usage_daily: z.number().optional(), + byok_usage_weekly: z.number().optional(), + byok_usage_monthly: z.number().optional(), limit: z.number().nullable().optional(), + limit_reset: z.string().nullable().optional(), limit_remaining: z.number().nullable().optional(), + include_byok_in_limit: z.boolean().optional(), + creator_user_id: z.string().nullable().optional(), is_free_tier: z.boolean().optional(), is_management_key: z.boolean().optional(), is_provisioning_key: z.boolean().optional(), @@ -529,12 +689,6 @@ export type ListZdrEndpointsInput = z.infer; export type ListZdrEndpointsResponse = z.infer< typeof ListZdrEndpointsOutputSchema >; -export type CreateCoinbaseChargeInput = z.infer< - typeof CreateCoinbaseChargeInputSchema ->; -export type CreateCoinbaseChargeResponse = z.infer< - typeof CreateCoinbaseChargeOutputSchema ->; export type GetGenerationInput = z.infer; export type GetGenerationResponse = z.infer; export type ListCreditsInput = z.infer; @@ -553,7 +707,6 @@ export type OpenRouterEndpointInputs = { modelsEndpointsList: ListModelEndpointsInput; providersList: ListProvidersInput; zdrEndpointsList: ListZdrEndpointsInput; - creditsCoinbaseCreate: CreateCoinbaseChargeInput; generationsGet: GetGenerationInput; creditsList: ListCreditsInput; keyGet: GetKeyInput; @@ -570,7 +723,6 @@ export type OpenRouterEndpointOutputs = { modelsEndpointsList: ListModelEndpointsResponse; providersList: ListProvidersResponse; zdrEndpointsList: ListZdrEndpointsResponse; - creditsCoinbaseCreate: CreateCoinbaseChargeResponse; generationsGet: GetGenerationResponse; creditsList: ListCreditsResponse; keyGet: GetKeyResponse; @@ -587,7 +739,6 @@ export const OpenRouterEndpointInputSchemas = { modelsEndpointsList: ListModelEndpointsInputSchema, providersList: ListProvidersInputSchema, zdrEndpointsList: ListZdrEndpointsInputSchema, - creditsCoinbaseCreate: CreateCoinbaseChargeInputSchema, generationsGet: GetGenerationInputSchema, creditsList: ListCreditsInputSchema, keyGet: GetKeyInputSchema, @@ -604,7 +755,6 @@ export const OpenRouterEndpointOutputSchemas = { modelsEndpointsList: ListModelEndpointsOutputSchema, providersList: ListProvidersOutputSchema, zdrEndpointsList: ListZdrEndpointsOutputSchema, - creditsCoinbaseCreate: CreateCoinbaseChargeOutputSchema, generationsGet: GetGenerationOutputSchema, creditsList: ListCreditsOutputSchema, keyGet: GetKeyOutputSchema, diff --git a/packages/openrouter/error-handlers.ts b/packages/openrouter/error-handlers.ts index 0a91bafc6..10bbcaf83 100644 --- a/packages/openrouter/error-handlers.ts +++ b/packages/openrouter/error-handlers.ts @@ -1,6 +1,22 @@ import type { CorsairErrorHandler } from 'corsair/core'; import { ApiError } from 'corsair/http'; +const PAID_WRITE_OPERATIONS = new Set([ + 'chatCompletions.create', + 'messages.create', + 'embeddings.create', +]); + +function retryTransientRead(context: { operation: string }) { + if (PAID_WRITE_OPERATIONS.has(context.operation)) { + return { maxRetries: 0 }; + } + return { + maxRetries: 3, + retryStrategy: 'exponential_backoff' as const, + }; +} + /** * Error handlers for the OpenRouter plugin. * @@ -19,7 +35,7 @@ export const errorHandlers = { match: (error: Error) => { if (error instanceof ApiError && error.status === 429) return true; const msg = error.message.toLowerCase(); - return msg.includes('rate_limit') || msg.includes('429'); + return msg.includes('rate_limit') || /(?:^|\s)429(?:\s|$)/.test(msg); }, handler: async (error: Error) => { let retryAfterMs: number | undefined; @@ -49,24 +65,29 @@ export const errorHandlers = { match: (error: Error) => { if (error instanceof ApiError && error.status === 422) return true; const msg = error.message.toLowerCase(); - return msg.includes('invalid') || msg.includes('validation'); + return ( + msg.includes('invalid request') || msg.includes('validation error') + ); }, handler: async () => ({ maxRetries: 0 }), }, + TIMEOUT_ERROR: { + match: (error: Error) => { + if (error instanceof ApiError && error.status === 408) return true; + const msg = error.message.toLowerCase(); + return msg.includes('request timeout') || msg.includes('timed out'); + }, + handler: async (_error, context) => retryTransientRead(context), + }, SERVER_ERROR: { match: (error: Error) => { if (error instanceof ApiError) { - return ( - (error.status >= 500 && error.status < 600) || error.status === 529 - ); + return error.status >= 500 && error.status < 600; } const msg = error.message.toLowerCase(); return msg.includes('server error') || msg.includes('overloaded'); }, - handler: async () => ({ - maxRetries: 3, - retryStrategy: 'exponential_backoff' as const, - }), + handler: async (_error, context) => retryTransientRead(context), }, DEFAULT: { match: () => true, diff --git a/packages/openrouter/index.ts b/packages/openrouter/index.ts index 6f51da212..c7a643139 100644 --- a/packages/openrouter/index.ts +++ b/packages/openrouter/index.ts @@ -73,7 +73,6 @@ export type OpenRouterEndpoints = { modelsEndpointsList: OpenrouterEndpoint<'modelsEndpointsList'>; providersList: OpenrouterEndpoint<'providersList'>; zdrEndpointsList: OpenrouterEndpoint<'zdrEndpointsList'>; - creditsCoinbaseCreate: OpenrouterEndpoint<'creditsCoinbaseCreate'>; generationsGet: OpenrouterEndpoint<'generationsGet'>; creditsList: OpenrouterEndpoint<'creditsList'>; keyGet: OpenrouterEndpoint<'keyGet'>; @@ -106,7 +105,6 @@ const openrouterEndpointsNested = { }, credits: { list: Credits.listCredits, - createCoinbaseCharge: Credits.createCoinbaseCharge, }, key: { get: Key.getKey, @@ -161,10 +159,6 @@ export const openrouterEndpointSchemas = { input: OpenRouterEndpointInputSchemas.creditsList, output: OpenRouterEndpointOutputSchemas.creditsList, }, - 'credits.createCoinbaseCharge': { - input: OpenRouterEndpointInputSchemas.creditsCoinbaseCreate, - output: OpenRouterEndpointOutputSchemas.creditsCoinbaseCreate, - }, 'key.get': { input: OpenRouterEndpointInputSchemas.keyGet, output: OpenRouterEndpointOutputSchemas.keyGet, @@ -206,7 +200,7 @@ const openrouterEndpointMeta = { 'models.listUser': { riskLevel: 'read', description: - 'List the models that have been created by the authenticated user', + 'List models filtered by the authenticated user’s provider preferences, privacy settings, and guardrails', }, 'embeddings.create': { riskLevel: 'write', @@ -231,12 +225,7 @@ const openrouterEndpointMeta = { 'credits.list': { riskLevel: 'read', description: - 'Get the account credit balance and usage, optionally with a Zero-Data Residency (ZDR) report when filter params are provided', - }, - 'credits.createCoinbaseCharge': { - riskLevel: 'write', - description: - 'Create a Coinbase Commerce on-chain charge to top up the account with credits', + 'Get the account credit balance and usage with a management API key', }, 'key.get': { riskLevel: 'read', From 9bf420830592cce839db36f7b53faf438b3a22ae Mon Sep 17 00:00:00 2001 From: ambikeesshh Date: Mon, 10 Aug 2026 20:36:43 +0530 Subject: [PATCH 4/8] fix(openrouter): correct docs and provider name --- packages/corsair/core/constants.ts | 2 +- packages/openrouter/README.md | 62 +++++++++++++----------------- 2 files changed, 28 insertions(+), 36 deletions(-) diff --git a/packages/corsair/core/constants.ts b/packages/corsair/core/constants.ts index b656c74eb..d320f91dd 100644 --- a/packages/corsair/core/constants.ts +++ b/packages/corsair/core/constants.ts @@ -183,7 +183,7 @@ export const ProviderDisplayNames = { onedrive: 'OneDrive', onepassword: '1Password', openai: 'OpenAI', - openrouter: 'Openrouter', + openrouter: 'OpenRouter', openweathermap: 'OpenWeatherMap', oura: 'Oura', outlook: 'Outlook', diff --git a/packages/openrouter/README.md b/packages/openrouter/README.md index a5b159a57..a50b08270 100644 --- a/packages/openrouter/README.md +++ b/packages/openrouter/README.md @@ -7,38 +7,40 @@ Corsair plugin for the [OpenRouter API](https://openrouter.ai/docs). API-key only. 1. Create a key at [openrouter.ai/keys](https://openrouter.ai/keys) -2. Set `OPENROUTER_API_KEY` in your environment, or pass the key via Corsair credentials +2. Store it in Corsair credentials, or pass it explicitly with `openrouter({ key })` Credentials are sent as `Authorization: Bearer `. Missing credentials throw `AuthMissingError` (never an empty string). +An explicit `options.key` overrides tenant-managed credentials for every tenant. +Setting `OPENROUTER_API_KEY` alone is only used by this package's optional live +tests; application code must pass it through `options.key` or Corsair credentials. ## Endpoint overview -| Operation | OpenRouter path | Description | -|-----------|-----------------|-------------| -| `chatCompletions.create` | `POST /chat/completions` | Chat completions with multi-provider routing, fallbacks, tool calling, and structured output | -| `messages.create` | `POST /messages` | Anthropic Messages API — chat with system prompts and multi-part content | -| `models.list` | `GET /models` | List all models with pricing, context length, and supported parameters | -| `models.count` | `GET /models/count` | Total count of models available on OpenRouter | -| `models.listEmbeddings` | `GET /embeddings/models` | List all embedding models | -| `models.listUser` | `GET /models/user` | List models created by the authenticated user | -| `embeddings.create` | `POST /embeddings` | Generate vector embeddings | -| `modelEndpoints.list` | `GET /models/{author}/{slug}/endpoints` | Per-provider endpoints for a model (pricing, latency, throughput) | -| `providers.list` | `GET /providers` | List providers with privacy policies and data-center regions | -| `zdr.list` | `GET /endpoints/zdr` | Zero-Data Residency (ZDR) endpoint specification for the account | -| `generations.get` | `GET /generation?id={id}` | Request & usage metadata for a previous generation | -| `credits.list` | `GET /credits` | Credit balance & usage; optional Zero-Data Residency (ZDR) report filters | -| `credits.createCoinbaseCharge` | `POST /credits/coinbase` | Create a Coinbase Commerce on-chain charge to top up credits | -| `key.get` | `GET /key` | API key metadata (usage, limits, rate limits) | +| Operation | OpenRouter path | Description | +| ------------------------ | --------------------------------------- | -------------------------------------------------------------------------------------------- | +| `chatCompletions.create` | `POST /chat/completions` | Chat completions with multi-provider routing, fallbacks, tool calling, and structured output | +| `messages.create` | `POST /messages` | Anthropic Messages API with image, document, tool-use, and thinking blocks | +| `models.list` | `GET /models` | List all models with pricing, context length, and supported parameters | +| `models.count` | `GET /models/count` | Total count of models available on OpenRouter | +| `models.listEmbeddings` | `GET /embeddings/models` | List all embedding models | +| `models.listUser` | `GET /models/user` | List models filtered by the user's provider preferences, privacy settings, and guardrails | +| `embeddings.create` | `POST /embeddings` | Generate vector embeddings | +| `modelEndpoints.list` | `GET /models/{author}/{slug}/endpoints` | Per-provider endpoints for a model (pricing, latency, throughput) | +| `providers.list` | `GET /providers` | List providers with privacy policies and data-center regions | +| `zdr.list` | `GET /endpoints/zdr` | Zero-Data Residency (ZDR) endpoint specification for the account | +| `generations.get` | `GET /generation?id={id}` | Request & usage metadata for a previous generation | +| `credits.list` | `GET /credits` | Credit balance and usage (management API key required) | +| `key.get` | `GET /key` | API key metadata (usage, limits, rate limits) | No webhooks (OpenRouter's API surface is token-only; there are no signed inbound events to subscribe to). ## Quirks & caveats -- **Streaming is off by default.** Completion and message calls send - `stream: false` so responses are a single JSON body (not SSE events). +- **Streaming is not exposed.** Completion and message calls always send + `stream: false` so responses are a single JSON body, not SSE events. - **Routing happens automatically.** OpenRouter picks the provider unless you pass `provider.order` / `provider.ignore` or pin `models` / `route`. - **Model availability varies by key.** Free-tier keys only reach a subset of @@ -46,8 +48,9 @@ inbound events to subscribe to). - **Chat calls cost credits.** Listing models/providers/credits/key works even at `$0` balance; chat, message, and embedding calls return HTTP 402 if the account has insufficient credits. -- **HTTP 529 (overloaded) is retried** like other 5xx errors with exponential - backoff (up to 3 attempts). +- **Transient 5xx and timeout errors are retried only for read operations.** + Paid chat, message, and embedding writes are not retried because repeating a + completed request can double-charge the account. - **Balances are `data`-wrapped.** `/credits` and `/key` return their payload under a `data` key (e.g. `{ data: { total_credits, total_usage } }`). @@ -60,18 +63,7 @@ pnpm --filter @corsair-dev/openrouter test - Offline schema + mocked-client handler tests always run (no API key). - Live client tests run only when `OPENROUTER_API_KEY` is set. -## Live demo +## Working proof -```bash -# PowerShell -$env:OPENROUTER_API_KEY = "sk-or-..." -pnpm --filter @corsair-dev/openrouter demo - -# bash -export OPENROUTER_API_KEY=sk-or-... -pnpm --filter @corsair-dev/openrouter demo -``` - -The demo (when added) hits key operations against -`https://openrouter.ai/api/v1`. Chat steps need non-zero credits on the -OpenRouter account. \ No newline at end of file +The PR's live recording exercises the integration against OpenRouter: +https://www.loom.com/share/5e7438d01bac4f76b6bcc351950b0f8b From 515b868e14ab999cbe2c176bbd68336516adc37f Mon Sep 17 00:00:00 2001 From: ambikeesshh Date: Mon, 10 Aug 2026 20:36:56 +0530 Subject: [PATCH 5/8] chore(openrouter): clean lockfile scope --- pnpm-lock.yaml | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9e5379752..a94d9370f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -130,7 +130,7 @@ importers: version: link:../../packages/slack corsair: specifier: ^0.1.4 - version: link:../../packages/corsair + version: 0.1.107(postgres@3.4.7)(react@19.2.7) dotenv: specifier: ^17.4.2 version: 17.4.2 @@ -4605,11 +4605,11 @@ packages: '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild-kit/esm-loader@2.6.5': resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} @@ -9521,6 +9521,14 @@ packages: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} + corsair@0.1.107: + resolution: {integrity: sha512-fh+iU4agP8j74vd2Uw2DJVLzBJ7hHvsm8bRyuanmpxkWxnXs0NxIsphv7fltSnCwg2l/lG9kEoUUckhv/nbPCg==} + peerDependencies: + react: '>=18.0.0' + peerDependenciesMeta: + react: + optional: true + cosmiconfig@9.0.1: resolution: {integrity: sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==} engines: {node: '>=14'} @@ -21378,6 +21386,17 @@ snapshots: object-assign: 4.1.1 vary: 1.1.2 + corsair@0.1.107(postgres@3.4.7)(react@19.2.7): + dependencies: + kysely: 0.28.17 + kysely-postgres-js: 3.0.0(kysely@0.28.17)(postgres@3.4.7) + uuid: 13.0.0 + zod: 4.4.3 + optionalDependencies: + react: 19.2.7 + transitivePeerDependencies: + - postgres + cosmiconfig@9.0.1(typescript@5.9.3): dependencies: env-paths: 2.2.1 @@ -23495,6 +23514,12 @@ snapshots: kleur@4.1.5: {} + kysely-postgres-js@3.0.0(kysely@0.28.17)(postgres@3.4.7): + dependencies: + kysely: 0.28.17 + optionalDependencies: + postgres: 3.4.7 + kysely-postgres-js@3.0.0(kysely@0.28.9)(postgres@3.4.7): dependencies: kysely: 0.28.9 From e2394d0426eada055e4e1c9e425e2c6bf184593f Mon Sep 17 00:00:00 2001 From: ambikeesshh Date: Mon, 10 Aug 2026 20:57:29 +0530 Subject: [PATCH 6/8] fix(openrouter): address latest review findings --- packages/openrouter/api.test.ts | 90 +++++++++----------------- packages/openrouter/endpoints/types.ts | 2 +- 2 files changed, 30 insertions(+), 62 deletions(-) diff --git a/packages/openrouter/api.test.ts b/packages/openrouter/api.test.ts index 805abd0ec..bb6ce63b1 100644 --- a/packages/openrouter/api.test.ts +++ b/packages/openrouter/api.test.ts @@ -13,9 +13,7 @@ import { Zdr, } from './endpoints'; import type { - CreateAnthropicMessageResponse, CreateChatCompletionResponse, - CreateEmbeddingOutput, GetKeyResponse, ListCreditsResponse, ListEmbeddingModelsResponse, @@ -100,6 +98,35 @@ describe('OpenRouter schemas', () => { expect(output.success).toBe(true); }); + it('validates optional Anthropic maxTokens bounds', () => { + const base = { + model: 'anthropic/claude-sonnet-4', + messages: [{ role: 'user' as const, content: 'Hello' }], + }; + + expect( + OpenRouterEndpointInputSchemas.messagesCreate.safeParse(base).success, + ).toBe(true); + expect( + OpenRouterEndpointInputSchemas.messagesCreate.safeParse({ + ...base, + maxTokens: 0, + }).success, + ).toBe(false); + expect( + OpenRouterEndpointInputSchemas.messagesCreate.safeParse({ + ...base, + maxTokens: 1.5, + }).success, + ).toBe(false); + expect( + OpenRouterEndpointInputSchemas.messagesCreate.safeParse({ + ...base, + maxTokens: 1, + }).success, + ).toBe(true); + }); + it('supports multi-turn chat tool calls', () => { const parsed = ChatMessageSchema.safeParse({ role: 'assistant', @@ -892,47 +919,6 @@ describeIfApiKey('OpenRouter API type tests (live)', () => { './client', ).makeOpenRouterRequest; - it('chat completion returns the expected shape', async () => { - const response = await makeOpenRouterRequest( - 'chat/completions', - TEST_API_KEY!, - { - method: 'POST', - body: { - model: 'openai/gpt-4o-mini', - messages: [{ role: 'user', content: 'Say hello in one word.' }], - stream: false, - max_tokens: 16, - }, - }, - ); - - const parsed = - OpenRouterEndpointOutputSchemas.chatCompletionsCreate.safeParse(response); - expect(parsed.success).toBe(true); - }); - - it('anthropic messages returns the expected shape', async () => { - const response = - await makeOpenRouterRequest( - 'messages', - TEST_API_KEY!, - { - method: 'POST', - body: { - model: 'openai/gpt-4o-mini', - max_tokens: 32, - messages: [{ role: 'user', content: 'Say hello in one word.' }], - stream: false, - }, - }, - ); - - const parsed = - OpenRouterEndpointOutputSchemas.messagesCreate.safeParse(response); - expect(parsed.success).toBe(true); - }); - it('models list returns the expected shape', async () => { const response = await makeOpenRouterRequest( 'models', @@ -1005,24 +991,6 @@ describeIfApiKey('OpenRouter API type tests (live)', () => { expect(parsed.success).toBe(true); }); - it('embeddings returns the expected shape', async () => { - const response = await makeOpenRouterRequest( - 'embeddings', - TEST_API_KEY!, - { - method: 'POST', - body: { - model: 'openai/text-embedding-3-small', - input: 'hello world', - }, - }, - ); - - const parsed = - OpenRouterEndpointOutputSchemas.embeddingsCreate.safeParse(response); - expect(parsed.success).toBe(true); - }); - it('providers returns the expected shape', async () => { const response = await makeOpenRouterRequest( 'providers', diff --git a/packages/openrouter/endpoints/types.ts b/packages/openrouter/endpoints/types.ts index 8a849f1a1..341063562 100644 --- a/packages/openrouter/endpoints/types.ts +++ b/packages/openrouter/endpoints/types.ts @@ -272,7 +272,7 @@ const AnthropicToolSchema = z.object({ export const CreateAnthropicMessageInputSchema = z.object({ model: z.string(), - maxTokens: z.number(), + maxTokens: z.number().int().min(1).optional(), messages: z .array( z.object({ From ccbc202c3f572cfac5f65cde47555c387aa8bd43 Mon Sep 17 00:00:00 2001 From: ambikeesshh Date: Fri, 14 Aug 2026 09:39:49 +0530 Subject: [PATCH 7/8] fix(openrouter): require integer chat token limits --- packages/openrouter/api.test.ts | 37 ++++++++++++++++++++++++++ packages/openrouter/endpoints/types.ts | 4 +-- packages/openrouter/schema.test.ts | 12 ++------- 3 files changed, 41 insertions(+), 12 deletions(-) diff --git a/packages/openrouter/api.test.ts b/packages/openrouter/api.test.ts index bb6ce63b1..35b9698b4 100644 --- a/packages/openrouter/api.test.ts +++ b/packages/openrouter/api.test.ts @@ -127,6 +127,43 @@ describe('OpenRouter schemas', () => { ).toBe(true); }); + it('validates optional chat maxTokens and maxCompletionTokens bounds', () => { + const base = { + model: 'openai/gpt-4o-mini', + messages: [{ role: 'user' as const, content: 'Hello' }], + }; + + expect( + OpenRouterEndpointInputSchemas.chatCompletionsCreate.safeParse(base) + .success, + ).toBe(true); + expect( + OpenRouterEndpointInputSchemas.chatCompletionsCreate.safeParse({ + ...base, + maxTokens: 0, + }).success, + ).toBe(false); + expect( + OpenRouterEndpointInputSchemas.chatCompletionsCreate.safeParse({ + ...base, + maxTokens: 1.5, + }).success, + ).toBe(false); + expect( + OpenRouterEndpointInputSchemas.chatCompletionsCreate.safeParse({ + ...base, + maxCompletionTokens: -1, + }).success, + ).toBe(false); + expect( + OpenRouterEndpointInputSchemas.chatCompletionsCreate.safeParse({ + ...base, + maxTokens: 1, + maxCompletionTokens: 16, + }).success, + ).toBe(true); + }); + it('supports multi-turn chat tool calls', () => { const parsed = ChatMessageSchema.safeParse({ role: 'assistant', diff --git a/packages/openrouter/endpoints/types.ts b/packages/openrouter/endpoints/types.ts index 341063562..ac82cad05 100644 --- a/packages/openrouter/endpoints/types.ts +++ b/packages/openrouter/endpoints/types.ts @@ -88,8 +88,8 @@ export const CreateChatCompletionInputSchema = z.object({ stream: z.literal(false).optional(), temperature: z.number().optional(), topP: z.number().optional(), - maxTokens: z.number().optional(), - maxCompletionTokens: z.number().optional(), + maxTokens: z.number().int().min(1).optional(), + maxCompletionTokens: z.number().int().min(1).optional(), n: z.number().optional(), stop: z.union([z.string(), z.array(z.string())]).optional(), presencePenalty: z.number().optional(), diff --git a/packages/openrouter/schema.test.ts b/packages/openrouter/schema.test.ts index 1d8eae534..8e992e3e0 100644 --- a/packages/openrouter/schema.test.ts +++ b/packages/openrouter/schema.test.ts @@ -6,15 +6,7 @@ describe('Openrouter schema', () => { expect(OpenrouterSchema.version).toMatch(/^\d+\.\d+\.\d+$/); }); - it('declares an entities map', () => { - expect(typeof OpenrouterSchema.entities).toBe('object'); - expect(OpenrouterSchema.entities).not.toBeNull(); - expect(Array.isArray(Object.keys(OpenrouterSchema.entities))).toBe(true); - for (const entity of Object.values(OpenrouterSchema.entities)) { - expect(entity).toBeDefined(); - } + it('declares an empty entities map', () => { + expect(OpenrouterSchema.entities).toEqual({}); }); }); - -// Per .github/PLUGIN_PR_RULES.md (R2), every implemented endpoint -// needs a corresponding test. From f0117273c791735a88e2800907c3869ecb9e41d4 Mon Sep 17 00:00:00 2001 From: ambikeesshh Date: Fri, 14 Aug 2026 21:29:00 +0530 Subject: [PATCH 8/8] fix(openrouter): cache models, providers, and generations --- packages/openrouter/api.test.ts | 97 ++++++++++++++++ packages/openrouter/endpoints/generations.ts | 2 + packages/openrouter/endpoints/models.ts | 4 + packages/openrouter/endpoints/persist.ts | 113 +++++++++++++++++++ packages/openrouter/endpoints/providers.ts | 2 + packages/openrouter/schema.test.ts | 26 ++++- packages/openrouter/schema/database.ts | 30 +++++ packages/openrouter/schema/index.ts | 14 ++- 8 files changed, 283 insertions(+), 5 deletions(-) create mode 100644 packages/openrouter/endpoints/persist.ts create mode 100644 packages/openrouter/schema/database.ts diff --git a/packages/openrouter/api.test.ts b/packages/openrouter/api.test.ts index 35b9698b4..3dced180b 100644 --- a/packages/openrouter/api.test.ts +++ b/packages/openrouter/api.test.ts @@ -892,6 +892,103 @@ describe('OpenRouter endpoint handlers (mocked client)', () => { expect(mockRequest).toHaveBeenCalledWith('key', 'k'); expect(result.data.usage).toBe(0.1); }); + + function cacheCtx() { + const models = { upsertByEntityId: jest.fn().mockResolvedValue(undefined) }; + const providers = { + upsertByEntityId: jest.fn().mockResolvedValue(undefined), + }; + const generations = { + upsertByEntityId: jest.fn().mockResolvedValue(undefined), + }; + return { + ctx: { key: 'k', db: { models, providers, generations } } as never, + models, + providers, + generations, + }; + } + + it('listModels caches each model', async () => { + mockRequest.mockResolvedValueOnce({ + data: [ + { + id: 'openai/gpt-4o-mini', + name: 'GPT-4o mini', + context_length: 128000, + }, + ], + }); + const { ctx, models } = cacheCtx(); + await Models.listModels(ctx, {}); + expect(models.upsertByEntityId).toHaveBeenCalledWith( + 'openai/gpt-4o-mini', + expect.objectContaining({ + id: 'openai/gpt-4o-mini', + name: 'GPT-4o mini', + context_length: 128000, + }), + ); + }); + + it('listEmbeddingModels caches each model', async () => { + mockRequest.mockResolvedValueOnce({ + data: [{ id: 'openai/text-embedding-3-small' }], + }); + const { ctx, models } = cacheCtx(); + await Models.listEmbeddingModels(ctx, {}); + expect(models.upsertByEntityId).toHaveBeenCalledWith( + 'openai/text-embedding-3-small', + expect.objectContaining({ id: 'openai/text-embedding-3-small' }), + ); + }); + + it('listUserModels caches each model', async () => { + mockRequest.mockResolvedValueOnce({ + data: [{ id: 'myorg/custom-model' }], + }); + const { ctx, models } = cacheCtx(); + await Models.listUserModels(ctx, {}); + expect(models.upsertByEntityId).toHaveBeenCalledWith( + 'myorg/custom-model', + expect.objectContaining({ id: 'myorg/custom-model' }), + ); + }); + + it('listProviders caches each provider', async () => { + mockRequest.mockResolvedValueOnce({ + data: [{ name: 'OpenAI', slug: 'openai', headquarters: 'US' }], + }); + const { ctx, providers } = cacheCtx(); + await Providers.listProviders(ctx, {}); + expect(providers.upsertByEntityId).toHaveBeenCalledWith( + 'openai', + expect.objectContaining({ slug: 'openai', name: 'OpenAI' }), + ); + }); + + it('getGeneration caches the generation', async () => { + mockRequest.mockResolvedValueOnce({ + data: { id: 'gen-1', model: 'openai/gpt-4o-mini', total_cost: 0.01 }, + }); + const { ctx, generations } = cacheCtx(); + await Generations.getGeneration(ctx, { id: 'gen-1' }); + expect(generations.upsertByEntityId).toHaveBeenCalledWith( + 'gen-1', + expect.objectContaining({ id: 'gen-1', model: 'openai/gpt-4o-mini' }), + ); + }); + + it('cache write failures do not fail the API call', async () => { + mockRequest.mockResolvedValueOnce({ + data: [{ id: 'openai/gpt-4o-mini' }], + }); + const { ctx, models } = cacheCtx(); + models.upsertByEntityId.mockRejectedValueOnce(new Error('db down')); + await expect(Models.listModels(ctx, {})).resolves.toMatchObject({ + data: [{ id: 'openai/gpt-4o-mini' }], + }); + }); }); describe('OpenRouter error handlers', () => { diff --git a/packages/openrouter/endpoints/generations.ts b/packages/openrouter/endpoints/generations.ts index fcfa09686..2acd5c3b8 100644 --- a/packages/openrouter/endpoints/generations.ts +++ b/packages/openrouter/endpoints/generations.ts @@ -1,5 +1,6 @@ import type { OpenRouterEndpoints } from './..'; import { makeOpenRouterRequest } from '../client'; +import { cacheGeneration } from './persist'; import type { GetGenerationInput, GetGenerationResponse } from './types'; // GET /generation returns request & usage metadata for a previous generation. @@ -15,5 +16,6 @@ export const getGeneration: OpenRouterEndpoints['generationsGet'] = async ( }, ); + await cacheGeneration(ctx, result.data); return result; }; diff --git a/packages/openrouter/endpoints/models.ts b/packages/openrouter/endpoints/models.ts index 14a6a090e..8fd47db38 100644 --- a/packages/openrouter/endpoints/models.ts +++ b/packages/openrouter/endpoints/models.ts @@ -1,5 +1,6 @@ import type { OpenRouterEndpoints } from './..'; import { makeOpenRouterRequest } from '../client'; +import { cacheModels } from './persist'; import type { ListEmbeddingModelsResponse, ListModelsCountResponse, @@ -17,6 +18,7 @@ export const listModels: OpenRouterEndpoints['modelsList'] = async ( { query: { offset: input.offset, limit: input.limit } }, ); + await cacheModels(ctx, result.data); return result; }; @@ -45,6 +47,7 @@ export const listEmbeddingModels: OpenRouterEndpoints['modelsEmbeddingsList'] = }, ); + await cacheModels(ctx, result.data); return result; }; @@ -58,5 +61,6 @@ export const listUserModels: OpenRouterEndpoints['modelsUserList'] = async ( { query: { offset: input.offset, limit: input.limit } }, ); + await cacheModels(ctx, result.data); return result; }; diff --git a/packages/openrouter/endpoints/persist.ts b/packages/openrouter/endpoints/persist.ts new file mode 100644 index 000000000..6ad4f4eea --- /dev/null +++ b/packages/openrouter/endpoints/persist.ts @@ -0,0 +1,113 @@ +import type { + OpenRouterGenerationEntity, + OpenRouterModelEntity, + OpenRouterProviderEntity, +} from '../schema/database'; + +type EntityStore = { + upsertByEntityId: (entityId: string, data: T) => Promise; +}; + +type CacheCtx = { + db?: { + models?: EntityStore; + providers?: EntityStore; + generations?: EntityStore; + }; +}; + +function entityDb(ctx: unknown): NonNullable { + if (typeof ctx !== 'object' || ctx === null) return {}; + return (ctx as CacheCtx).db ?? {}; +} + +async function safely(operation: () => Promise, what: string) { + try { + await operation(); + } catch (error) { + console.warn(`[OPENROUTER] failed to cache ${what}:`, error); + } +} + +export async function cacheModels( + ctx: unknown, + models: + | Array<{ + id: string; + name?: string; + description?: string; + context_length?: number; + created?: number; + }> + | undefined, +) { + const store = entityDb(ctx).models; + if (!store || !models) return; + for (const model of models) { + if (!model.id) continue; + await safely( + () => + store.upsertByEntityId(model.id, { + id: model.id, + name: model.name, + description: model.description, + context_length: model.context_length, + created: model.created, + }), + `model ${model.id}`, + ); + } +} + +export async function cacheProviders( + ctx: unknown, + providers: + | Array<{ slug: string; name: string; headquarters?: string | null }> + | undefined, +) { + const store = entityDb(ctx).providers; + if (!store || !providers) return; + for (const provider of providers) { + if (!provider.slug) continue; + await safely( + () => + store.upsertByEntityId(provider.slug, { + slug: provider.slug, + name: provider.name, + headquarters: provider.headquarters, + }), + `provider ${provider.slug}`, + ); + } +} + +export async function cacheGeneration( + ctx: unknown, + generation: + | { + id: string; + model?: string; + provider?: string; + total_cost?: number | null; + prompt_tokens?: number; + completion_tokens?: number; + created_at?: string; + } + | undefined, +) { + const store = entityDb(ctx).generations; + if (!store || !generation?.id) return; + await safely( + () => + store.upsertByEntityId(generation.id, { + id: generation.id, + model: generation.model, + provider: generation.provider, + total_cost: generation.total_cost, + prompt_tokens: generation.prompt_tokens, + completion_tokens: generation.completion_tokens, + created_at: generation.created_at, + }), + `generation ${generation.id}`, + ); +} diff --git a/packages/openrouter/endpoints/providers.ts b/packages/openrouter/endpoints/providers.ts index 7749716e2..b4490ba2c 100644 --- a/packages/openrouter/endpoints/providers.ts +++ b/packages/openrouter/endpoints/providers.ts @@ -1,5 +1,6 @@ import type { OpenRouterEndpoints } from './..'; import { makeOpenRouterRequest } from '../client'; +import { cacheProviders } from './persist'; import type { ListProvidersResponse } from './types'; export const listProviders: OpenRouterEndpoints['providersList'] = async ( @@ -11,5 +12,6 @@ export const listProviders: OpenRouterEndpoints['providersList'] = async ( ctx.key, ); + await cacheProviders(ctx, result.data); return result; }; diff --git a/packages/openrouter/schema.test.ts b/packages/openrouter/schema.test.ts index 8e992e3e0..d1feeab3c 100644 --- a/packages/openrouter/schema.test.ts +++ b/packages/openrouter/schema.test.ts @@ -1,12 +1,30 @@ -import { OpenrouterSchema } from './schema'; +import { + OpenRouterGenerationEntity, + OpenRouterModelEntity, + OpenRouterProviderEntity, + OpenrouterSchema, +} from './schema'; describe('Openrouter schema', () => { it('declares a semver version', () => { - expect(OpenrouterSchema.version).toBeDefined(); expect(OpenrouterSchema.version).toMatch(/^\d+\.\d+\.\d+$/); }); - it('declares an empty entities map', () => { - expect(OpenrouterSchema.entities).toEqual({}); + it('declares db schema entities aligned to OpenRouter resources', () => { + expect(Object.keys(OpenrouterSchema.entities).sort()).toEqual( + ['generations', 'models', 'providers'].sort(), + ); + expect( + OpenRouterModelEntity.parse({ + id: 'openai/gpt-4o-mini', + name: 'GPT-4o mini', + }), + ).toMatchObject({ id: 'openai/gpt-4o-mini', name: 'GPT-4o mini' }); + expect( + OpenRouterProviderEntity.parse({ slug: 'openai', name: 'OpenAI' }), + ).toMatchObject({ slug: 'openai', name: 'OpenAI' }); + expect(OpenRouterGenerationEntity.parse({ id: 'gen-1' })).toMatchObject({ + id: 'gen-1', + }); }); }); diff --git a/packages/openrouter/schema/database.ts b/packages/openrouter/schema/database.ts new file mode 100644 index 000000000..0b5d784c8 --- /dev/null +++ b/packages/openrouter/schema/database.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; + +export const OpenRouterModelEntity = z.object({ + id: z.string(), + name: z.string().optional(), + description: z.string().optional(), + context_length: z.number().optional(), + created: z.number().optional(), +}); +export type OpenRouterModelEntity = z.infer; + +export const OpenRouterProviderEntity = z.object({ + slug: z.string(), + name: z.string(), + headquarters: z.string().nullable().optional(), +}); +export type OpenRouterProviderEntity = z.infer; + +export const OpenRouterGenerationEntity = z.object({ + id: z.string(), + model: z.string().optional(), + provider: z.string().optional(), + total_cost: z.number().nullable().optional(), + prompt_tokens: z.number().optional(), + completion_tokens: z.number().optional(), + created_at: z.string().optional(), +}); +export type OpenRouterGenerationEntity = z.infer< + typeof OpenRouterGenerationEntity +>; diff --git a/packages/openrouter/schema/index.ts b/packages/openrouter/schema/index.ts index eee3fc448..5891b2897 100644 --- a/packages/openrouter/schema/index.ts +++ b/packages/openrouter/schema/index.ts @@ -1,4 +1,16 @@ +import { + OpenRouterGenerationEntity, + OpenRouterModelEntity, + OpenRouterProviderEntity, +} from './database'; + export const OpenrouterSchema = { version: '1.0.0', - entities: {}, + entities: { + models: OpenRouterModelEntity, + providers: OpenRouterProviderEntity, + generations: OpenRouterGenerationEntity, + }, } as const; + +export * from './database';