236
server/providers/anthropic.go
Normal file
236
server/providers/anthropic.go
Normal file
@@ -0,0 +1,236 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const anthropicDefaultEndpoint = "https://api.anthropic.com"
|
||||
const anthropicAPIVersion = "2023-06-01"
|
||||
|
||||
// AnthropicProvider handles the Anthropic Messages API.
|
||||
type AnthropicProvider struct{}
|
||||
|
||||
func (p *AnthropicProvider) ID() string { return "anthropic" }
|
||||
|
||||
// ── Chat Completion (non-streaming) ─────────
|
||||
|
||||
func (p *AnthropicProvider) ChatCompletion(ctx context.Context, cfg ProviderConfig, req CompletionRequest) (*CompletionResponse, error) {
|
||||
req.Stream = false
|
||||
|
||||
body, err := p.doRequest(ctx, cfg, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
var resp anthropicResponse
|
||||
if err := json.NewDecoder(body).Decode(&resp); err != nil {
|
||||
return nil, fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
|
||||
// Extract text from content blocks
|
||||
var content string
|
||||
for _, block := range resp.Content {
|
||||
if block.Type == "text" {
|
||||
content += block.Text
|
||||
}
|
||||
}
|
||||
|
||||
return &CompletionResponse{
|
||||
Content: content,
|
||||
Model: resp.Model,
|
||||
FinishReason: resp.StopReason,
|
||||
InputTokens: resp.Usage.InputTokens,
|
||||
OutputTokens: resp.Usage.OutputTokens,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ── Stream Completion ───────────────────────
|
||||
|
||||
func (p *AnthropicProvider) StreamCompletion(ctx context.Context, cfg ProviderConfig, req CompletionRequest) (<-chan StreamEvent, error) {
|
||||
req.Stream = true
|
||||
|
||||
body, err := p.doRequest(ctx, cfg, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ch := make(chan StreamEvent, 64)
|
||||
|
||||
go func() {
|
||||
defer close(ch)
|
||||
defer body.Close()
|
||||
|
||||
scanner := bufio.NewScanner(body)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
continue
|
||||
}
|
||||
|
||||
data := strings.TrimPrefix(line, "data: ")
|
||||
|
||||
var event anthropicStreamEvent
|
||||
if err := json.Unmarshal([]byte(data), &event); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch event.Type {
|
||||
case "content_block_delta":
|
||||
if event.Delta.Type == "text_delta" {
|
||||
ch <- StreamEvent{Delta: event.Delta.Text}
|
||||
}
|
||||
case "message_delta":
|
||||
ch <- StreamEvent{
|
||||
Done: true,
|
||||
FinishReason: event.Delta.StopReason,
|
||||
}
|
||||
case "message_stop":
|
||||
ch <- StreamEvent{Done: true}
|
||||
return
|
||||
case "error":
|
||||
ch <- StreamEvent{
|
||||
Error: fmt.Errorf("anthropic stream error: %s", data),
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
ch <- StreamEvent{Error: err}
|
||||
}
|
||||
}()
|
||||
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// ── List Models ─────────────────────────────
|
||||
|
||||
func (p *AnthropicProvider) ListModels(_ context.Context, _ ProviderConfig) ([]Model, error) {
|
||||
// Anthropic doesn't have a list models endpoint.
|
||||
// Return the known model families.
|
||||
return []Model{
|
||||
{ID: "claude-sonnet-4-20250514", Name: "Claude Sonnet 4"},
|
||||
{ID: "claude-haiku-4-20250414", Name: "Claude Haiku 4"},
|
||||
{ID: "claude-opus-4-20250514", Name: "Claude Opus 4"},
|
||||
{ID: "claude-3-5-sonnet-20241022", Name: "Claude 3.5 Sonnet"},
|
||||
{ID: "claude-3-5-haiku-20241022", Name: "Claude 3.5 Haiku"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ── HTTP Layer ──────────────────────────────
|
||||
|
||||
func (p *AnthropicProvider) doRequest(ctx context.Context, cfg ProviderConfig, req CompletionRequest) (io.ReadCloser, error) {
|
||||
endpoint := cfg.Endpoint
|
||||
if endpoint == "" {
|
||||
endpoint = anthropicDefaultEndpoint
|
||||
}
|
||||
endpoint = strings.TrimSuffix(endpoint, "/") + "/v1/messages"
|
||||
|
||||
// Separate system message from conversation
|
||||
var system string
|
||||
messages := make([]anthropicMessage, 0, len(req.Messages))
|
||||
for _, m := range req.Messages {
|
||||
if m.Role == "system" {
|
||||
system = m.Content
|
||||
continue
|
||||
}
|
||||
messages = append(messages, anthropicMessage{
|
||||
Role: m.Role,
|
||||
Content: m.Content,
|
||||
})
|
||||
}
|
||||
|
||||
// Build Anthropic-format request
|
||||
antReq := anthropicRequest{
|
||||
Model: req.Model,
|
||||
Messages: messages,
|
||||
MaxTokens: req.MaxTokens,
|
||||
Stream: req.Stream,
|
||||
}
|
||||
if system != "" {
|
||||
antReq.System = system
|
||||
}
|
||||
if antReq.MaxTokens == 0 {
|
||||
antReq.MaxTokens = 4096 // Anthropic requires max_tokens
|
||||
}
|
||||
if req.Temperature != nil {
|
||||
antReq.Temperature = req.Temperature
|
||||
}
|
||||
if req.TopP != nil {
|
||||
antReq.TopP = req.TopP
|
||||
}
|
||||
|
||||
body, err := json.Marshal(antReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("x-api-key", cfg.APIKey)
|
||||
httpReq.Header.Set("anthropic-version", anthropicAPIVersion)
|
||||
|
||||
resp, err := http.DefaultClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("provider request: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("provider error: HTTP %d: %s", resp.StatusCode, string(b))
|
||||
}
|
||||
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
// ── Anthropic Wire Types ────────────────────
|
||||
|
||||
type anthropicMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type anthropicRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []anthropicMessage `json:"messages"`
|
||||
System string `json:"system,omitempty"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
TopP *float64 `json:"top_p,omitempty"`
|
||||
Stream bool `json:"stream"`
|
||||
}
|
||||
|
||||
type anthropicResponse struct {
|
||||
Model string `json:"model"`
|
||||
StopReason string `json:"stop_reason"`
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
Usage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
|
||||
type anthropicStreamEvent struct {
|
||||
Type string `json:"type"`
|
||||
Delta struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
StopReason string `json:"stop_reason"`
|
||||
} `json:"delta"`
|
||||
}
|
||||
245
server/providers/openai.go
Normal file
245
server/providers/openai.go
Normal file
@@ -0,0 +1,245 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// OpenAIProvider handles any OpenAI-compatible API.
|
||||
type OpenAIProvider struct{}
|
||||
|
||||
func (p *OpenAIProvider) ID() string { return "openai" }
|
||||
|
||||
// ── Chat Completion (non-streaming) ─────────
|
||||
|
||||
func (p *OpenAIProvider) ChatCompletion(ctx context.Context, cfg ProviderConfig, req CompletionRequest) (*CompletionResponse, error) {
|
||||
req.Stream = false
|
||||
|
||||
body, err := p.doRequest(ctx, cfg, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
var resp openaiChatResponse
|
||||
if err := json.NewDecoder(body).Decode(&resp); err != nil {
|
||||
return nil, fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
|
||||
if len(resp.Choices) == 0 {
|
||||
return nil, fmt.Errorf("no choices in response")
|
||||
}
|
||||
|
||||
return &CompletionResponse{
|
||||
Content: resp.Choices[0].Message.Content,
|
||||
Model: resp.Model,
|
||||
FinishReason: resp.Choices[0].FinishReason,
|
||||
InputTokens: resp.Usage.PromptTokens,
|
||||
OutputTokens: resp.Usage.CompletionTokens,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ── Stream Completion ───────────────────────
|
||||
|
||||
func (p *OpenAIProvider) StreamCompletion(ctx context.Context, cfg ProviderConfig, req CompletionRequest) (<-chan StreamEvent, error) {
|
||||
req.Stream = true
|
||||
|
||||
body, err := p.doRequest(ctx, cfg, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ch := make(chan StreamEvent, 64)
|
||||
|
||||
go func() {
|
||||
defer close(ch)
|
||||
defer body.Close()
|
||||
|
||||
scanner := bufio.NewScanner(body)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
continue
|
||||
}
|
||||
|
||||
data := strings.TrimPrefix(line, "data: ")
|
||||
if data == "[DONE]" {
|
||||
ch <- StreamEvent{Done: true}
|
||||
return
|
||||
}
|
||||
|
||||
var chunk openaiStreamChunk
|
||||
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(chunk.Choices) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
ev := StreamEvent{
|
||||
Delta: chunk.Choices[0].Delta.Content,
|
||||
Model: chunk.Model,
|
||||
FinishReason: chunk.Choices[0].FinishReason,
|
||||
}
|
||||
if ev.FinishReason != "" {
|
||||
ev.Done = true
|
||||
}
|
||||
|
||||
ch <- ev
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
ch <- StreamEvent{Error: err}
|
||||
}
|
||||
}()
|
||||
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// ── List Models ─────────────────────────────
|
||||
|
||||
func (p *OpenAIProvider) 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)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list models: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("list models: HTTP %d: %s", resp.StatusCode, string(b))
|
||||
}
|
||||
|
||||
var result openaiModelsResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("decode models: %w", err)
|
||||
}
|
||||
|
||||
models := make([]Model, 0, len(result.Data))
|
||||
for _, m := range result.Data {
|
||||
models = append(models, Model{
|
||||
ID: m.ID,
|
||||
OwnedBy: m.OwnedBy,
|
||||
})
|
||||
}
|
||||
return models, nil
|
||||
}
|
||||
|
||||
// ── HTTP Layer ──────────────────────────────
|
||||
|
||||
func (p *OpenAIProvider) doRequest(ctx context.Context, cfg ProviderConfig, req CompletionRequest) (io.ReadCloser, error) {
|
||||
endpoint := strings.TrimSuffix(cfg.Endpoint, "/") + "/chat/completions"
|
||||
|
||||
// Build OpenAI-format request
|
||||
oaiReq := openaiChatRequest{
|
||||
Model: req.Model,
|
||||
Stream: req.Stream,
|
||||
Messages: make([]openaiMessage, 0, len(req.Messages)),
|
||||
}
|
||||
if req.MaxTokens > 0 {
|
||||
oaiReq.MaxTokens = req.MaxTokens
|
||||
}
|
||||
if req.Temperature != nil {
|
||||
oaiReq.Temperature = req.Temperature
|
||||
}
|
||||
if req.TopP != nil {
|
||||
oaiReq.TopP = req.TopP
|
||||
}
|
||||
|
||||
for _, m := range req.Messages {
|
||||
oaiReq.Messages = append(oaiReq.Messages, openaiMessage{
|
||||
Role: m.Role,
|
||||
Content: m.Content,
|
||||
})
|
||||
}
|
||||
|
||||
body, err := json.Marshal(oaiReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
if cfg.APIKey != "" {
|
||||
httpReq.Header.Set("Authorization", "Bearer "+cfg.APIKey)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("provider request: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("provider error: HTTP %d: %s", resp.StatusCode, string(b))
|
||||
}
|
||||
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
// ── OpenAI Wire Types ───────────────────────
|
||||
|
||||
type openaiMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type openaiChatRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []openaiMessage `json:"messages"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
TopP *float64 `json:"top_p,omitempty"`
|
||||
Stream bool `json:"stream"`
|
||||
}
|
||||
|
||||
type openaiChatResponse struct {
|
||||
Model string `json:"model"`
|
||||
Choices []struct {
|
||||
Message openaiMessage `json:"message"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
} `json:"choices"`
|
||||
Usage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
|
||||
type openaiStreamChunk struct {
|
||||
Model string `json:"model"`
|
||||
Choices []struct {
|
||||
Delta struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"delta"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
|
||||
type openaiModelsResponse struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
OwnedBy string `json:"owned_by"`
|
||||
} `json:"data"`
|
||||
}
|
||||
85
server/providers/provider.go
Normal file
85
server/providers/provider.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// ── Provider Interface ──────────────────────
|
||||
|
||||
// Provider is the contract for LLM API adapters.
|
||||
type Provider interface {
|
||||
// ID returns the provider identifier (e.g. "openai", "anthropic").
|
||||
ID() string
|
||||
|
||||
// ChatCompletion sends a non-streaming request and returns the full response.
|
||||
ChatCompletion(ctx context.Context, cfg ProviderConfig, req CompletionRequest) (*CompletionResponse, error)
|
||||
|
||||
// StreamCompletion sends a streaming request and returns a channel of events.
|
||||
// The channel is closed when the stream ends. Errors are sent as StreamEvents.
|
||||
StreamCompletion(ctx context.Context, cfg ProviderConfig, req CompletionRequest) (<-chan StreamEvent, error)
|
||||
|
||||
// ListModels returns available models from this provider.
|
||||
ListModels(ctx context.Context, cfg ProviderConfig) ([]Model, error)
|
||||
}
|
||||
|
||||
// ── Configuration ───────────────────────────
|
||||
|
||||
// ProviderConfig holds credentials and endpoint for a configured provider.
|
||||
// Populated from the api_configs table at call time.
|
||||
type ProviderConfig struct {
|
||||
Endpoint string
|
||||
APIKey string
|
||||
Extra map[string]interface{} // Provider-specific settings from config JSONB
|
||||
}
|
||||
|
||||
// ── Request / Response Types ────────────────
|
||||
|
||||
// Message represents a chat message in the conversation.
|
||||
type Message struct {
|
||||
Role string `json:"role"` // user, assistant, system
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// CompletionRequest is the normalized request sent to any provider.
|
||||
type CompletionRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []Message `json:"messages"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
TopP *float64 `json:"top_p,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
}
|
||||
|
||||
// CompletionResponse is the normalized non-streaming response.
|
||||
type CompletionResponse struct {
|
||||
Content string `json:"content"`
|
||||
Model string `json:"model"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
}
|
||||
|
||||
// StreamEvent is a single chunk from a streaming response.
|
||||
type StreamEvent struct {
|
||||
// Delta is the incremental text content (empty for non-content events).
|
||||
Delta string `json:"delta,omitempty"`
|
||||
|
||||
// Done is true when the stream is complete.
|
||||
Done bool `json:"done,omitempty"`
|
||||
|
||||
// FinishReason is set on the final event.
|
||||
FinishReason string `json:"finish_reason,omitempty"`
|
||||
|
||||
// Model echoes back the model used.
|
||||
Model string `json:"model,omitempty"`
|
||||
|
||||
// Error is set if the stream encountered an error.
|
||||
Error error `json:"-"`
|
||||
}
|
||||
|
||||
// Model represents an available model from a provider.
|
||||
type Model struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
OwnedBy string `json:"owned_by,omitempty"`
|
||||
}
|
||||
47
server/providers/registry.go
Normal file
47
server/providers/registry.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// registry holds all registered providers.
|
||||
var (
|
||||
registry = make(map[string]Provider)
|
||||
mu sync.RWMutex
|
||||
)
|
||||
|
||||
// Register adds a provider to the registry.
|
||||
func Register(p Provider) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
registry[p.ID()] = p
|
||||
}
|
||||
|
||||
// Get returns a provider by ID.
|
||||
func Get(id string) (Provider, error) {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
p, ok := registry[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown provider: %s", id)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// List returns all registered provider IDs.
|
||||
func List() []string {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
ids := make([]string, 0, len(registry))
|
||||
for id := range registry {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// Init registers all built-in providers.
|
||||
func Init() {
|
||||
Register(&OpenAIProvider{})
|
||||
Register(&AnthropicProvider{})
|
||||
}
|
||||
Reference in New Issue
Block a user