This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/providers/openrouter.go
2026-02-24 14:50:53 +00:00

165 lines
4.9 KiB
Go

package providers
import (
"git.gobha.me/xcaliber/chat-switchboard/capabilities"
"git.gobha.me/xcaliber/chat-switchboard/models"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
)
// OpenRouterProvider handles the OpenRouter unified API.
// OpenRouter is OpenAI-compatible with 300+ models, per-model pricing,
// and architecture metadata. Requires HTTP-Referer and X-Title headers.
//
// Ported from ai-editor js/providers/openrouter.js
type OpenRouterProvider struct{}
func (p *OpenRouterProvider) ID() string { return "openrouter" }
// ── Chat Completion ─────────────────────────
// Delegates to OpenAI provider — OpenRouter is fully OAI-compatible.
func (p *OpenRouterProvider) ChatCompletion(ctx context.Context, cfg ProviderConfig, req CompletionRequest) (*CompletionResponse, error) {
oai := &OpenAIProvider{}
return oai.ChatCompletion(ctx, cfg, req)
}
func (p *OpenRouterProvider) StreamCompletion(ctx context.Context, cfg ProviderConfig, req CompletionRequest) (<-chan StreamEvent, error) {
oai := &OpenAIProvider{}
return oai.StreamCompletion(ctx, cfg, req)
}
func (p *OpenRouterProvider) Embed(ctx context.Context, cfg ProviderConfig, req EmbeddingRequest) (*EmbeddingResponse, error) {
oai := &OpenAIProvider{}
return oai.Embed(ctx, cfg, req)
}
// ── List Models ─────────────────────────────
// OpenRouter /v1/models returns architecture, pricing, context_length.
func (p *OpenRouterProvider) ListModels(ctx context.Context, cfg ProviderConfig) ([]Model, error) {
endpoint := strings.TrimSuffix(cfg.Endpoint, "/") + "/models"
httpReq, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
if err != nil {
return nil, err
}
if cfg.APIKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+cfg.APIKey)
}
for k, v := range cfg.CustomHeaders {
httpReq.Header.Set(k, v)
}
resp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("openrouter list models: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("openrouter list models: HTTP %d: %s", resp.StatusCode, string(b))
}
var result orModelsResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("openrouter decode models: %w", err)
}
out := make([]Model, 0, len(result.Data))
for _, m := range result.Data {
// Heuristic inference from model ID
caps := capabilities.InferCapabilities(m.ID)
// Overlay context length from OpenRouter metadata
if m.ContextLength > 0 && caps.MaxContext == 0 {
caps.MaxContext = m.ContextLength
}
// Overlay max output from OpenRouter if available
if m.TopProvider.MaxCompletionTokens > 0 && caps.MaxOutputTokens == 0 {
caps.MaxOutputTokens = m.TopProvider.MaxCompletionTokens
}
// Vision from architecture modality
arch := m.Architecture
if arch.Modality == "multimodal" {
caps.Vision = true
}
// Streaming is universal on OpenRouter
caps.Streaming = true
// Parse pricing (OpenRouter uses per-token strings, convert to per-1M)
var pricing *models.ModelPricing
if m.Pricing.Prompt != "" {
inputPerToken, _ := strconv.ParseFloat(m.Pricing.Prompt, 64)
outputPerToken, _ := strconv.ParseFloat(m.Pricing.Completion, 64)
if inputPerToken > 0 || outputPerToken > 0 {
pricing = &models.ModelPricing{
InputPerM: inputPerToken * 1_000_000,
OutputPerM: outputPerToken * 1_000_000,
}
}
}
// Owner from model ID prefix (e.g. "anthropic/claude-3-opus" → "anthropic")
ownedBy := ""
if idx := strings.Index(m.ID, "/"); idx >= 0 {
ownedBy = m.ID[:idx]
}
name := m.Name
if name == "" {
name = m.ID
}
out = append(out, Model{
ID: m.ID,
Name: name,
OwnedBy: ownedBy,
Capabilities: caps,
Pricing: pricing,
})
}
return out, nil
}
// ── OpenRouter Wire Types ───────────────────
type orModelsResponse struct {
Data []orModel `json:"data"`
}
type orModel struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
ContextLength int `json:"context_length"`
Architecture orArch `json:"architecture"`
Pricing orPricing `json:"pricing"`
TopProvider orTopProvider `json:"top_provider"`
}
type orArch struct {
Modality string `json:"modality"`
Tokenizer string `json:"tokenizer"`
InstructType string `json:"instruct_type"`
}
type orPricing struct {
Prompt string `json:"prompt"` // per-token cost as string
Completion string `json:"completion"` // per-token cost as string
}
type orTopProvider struct {
MaxCompletionTokens int `json:"max_completion_tokens"`
IsModerated bool `json:"is_moderated"`
}