Changeset 0.5.0 (#35)
This commit is contained in:
160
server/providers/openrouter.go
Normal file
160
server/providers/openrouter.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"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)
|
||||
}
|
||||
|
||||
// ── 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)
|
||||
}
|
||||
|
||||
models := make([]Model, 0, len(result.Data))
|
||||
for _, m := range result.Data {
|
||||
// Start with known table, fall back to heuristic
|
||||
caps, found := LookupKnownModel(m.ID)
|
||||
if !found {
|
||||
caps = 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 *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 = &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
|
||||
}
|
||||
|
||||
models = append(models, Model{
|
||||
ID: m.ID,
|
||||
Name: name,
|
||||
OwnedBy: ownedBy,
|
||||
Capabilities: caps,
|
||||
Pricing: pricing,
|
||||
})
|
||||
}
|
||||
return models, 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"`
|
||||
}
|
||||
Reference in New Issue
Block a user