da9c8334d8
- SSE Keepalive Ping (15s心跳防止代理断连) - Timing HTTP 头 (X-Timing-Queue/Inference/Total-Ms) - Adapter Request-ID 传播到后端 - Session 清理日志回调 - Server 安全加固 (ReadHeaderTimeout/MaxHeaderBytes 防 slowloris) - Usage Tracker 数据保留清理 (retentionDays + 定期清理) - Config Reload 后 Adapter Registry 更新 (RegisterIfAbsent + RWMutex) - Rate Limiter 空闲 Bucket 清理 (30分钟过期) - Shutdown Drain 超时可配置 (ShutdownDrainSeconds) - Config 模型字段校验增强 (provider/endpoint/actual_model) - Auth 过期 Key 自动清理 (5分钟扫描) - Admin API Rate Limiting - Adapter Health Check 独立超时 (每个 adapter 3s) - TCP 连接阶段超时 (DialContext 5s + KeepAlive 30s) - 幂等键缓存、审计日志、Gzip 中间件、CORS Expose Headers - Backpressure 响应头、熔断器 Prometheus 指标 - 连接池优化、Trace-ID 全链路传播
283 lines
7.5 KiB
Go
283 lines
7.5 KiB
Go
package adapter
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// newHTTPClient 创建带连接池优化的 HTTP 客户端,复用 TCP 连接以减少延迟。
|
|
// timeout 为整体请求超时,connectTimeout 为 TCP 连接阶段超时。
|
|
func newHTTPClient(timeout time.Duration) *http.Client {
|
|
return &http.Client{
|
|
Timeout: timeout,
|
|
Transport: &http.Transport{
|
|
MaxIdleConns: 100,
|
|
MaxIdleConnsPerHost: 20,
|
|
IdleConnTimeout: 90 * time.Second,
|
|
MaxConnsPerHost: 0, // 不限制
|
|
DialContext: (&net.Dialer{
|
|
Timeout: 5 * time.Second,
|
|
KeepAlive: 30 * time.Second,
|
|
}).DialContext,
|
|
},
|
|
}
|
|
}
|
|
|
|
// OllamaAdapter implements ModelAdapter for Ollama inference engine.
|
|
type OllamaAdapter struct {
|
|
endpoint string
|
|
httpClient *http.Client
|
|
}
|
|
|
|
// NewOllamaAdapter creates a new Ollama adapter.
|
|
func NewOllamaAdapter(endpoint string) *OllamaAdapter {
|
|
return &OllamaAdapter{
|
|
endpoint: strings.TrimRight(endpoint, "/"),
|
|
httpClient: newHTTPClient(120 * time.Second),
|
|
}
|
|
}
|
|
|
|
func (a *OllamaAdapter) Name() string {
|
|
return "ollama"
|
|
}
|
|
|
|
// ollamaChatRequest is the Ollama /api/chat request format.
|
|
type ollamaChatRequest struct {
|
|
Model string `json:"model"`
|
|
Messages []ollamaMsg `json:"messages"`
|
|
Stream bool `json:"stream"`
|
|
Options ollamaOptions `json:"options,omitempty"`
|
|
}
|
|
|
|
type ollamaMsg struct {
|
|
Role string `json:"role"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
type ollamaOptions struct {
|
|
Temperature float64 `json:"temperature,omitempty"`
|
|
TopP float64 `json:"top_p,omitempty"`
|
|
NumPredict int `json:"num_predict,omitempty"`
|
|
}
|
|
|
|
// ollamaChatResponse is the Ollama /api/chat non-streaming response.
|
|
type ollamaChatResponse struct {
|
|
Model string `json:"model"`
|
|
Message ollamaMsg `json:"message"`
|
|
Done bool `json:"done"`
|
|
PromptEvalCount int `json:"prompt_eval_count"`
|
|
EvalCount int `json:"eval_count"`
|
|
}
|
|
|
|
// ollamaChatStreamResponse is a single chunk in Ollama streaming response.
|
|
type ollamaChatStreamResponse struct {
|
|
Model string `json:"model"`
|
|
Message ollamaMsg `json:"message"`
|
|
Done bool `json:"done"`
|
|
PromptEvalCount int `json:"prompt_eval_count,omitempty"`
|
|
EvalCount int `json:"eval_count,omitempty"`
|
|
}
|
|
|
|
func (a *OllamaAdapter) ChatCompletion(ctx context.Context, req *ChatRequest) (*ChatResponse, error) {
|
|
ollamaReq := a.buildRequest(req, false)
|
|
|
|
body, err := json.Marshal(ollamaReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal ollama request: %w", err)
|
|
}
|
|
|
|
httpReq, err := http.NewRequestWithContext(ctx, "POST", a.endpoint+"/api/chat", bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create ollama request: %w", err)
|
|
}
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
if req.RequestID != "" {
|
|
httpReq.Header.Set("X-Request-ID", req.RequestID)
|
|
}
|
|
|
|
resp, err := a.httpClient.Do(httpReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ollama request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("ollama returned status %d: %s", resp.StatusCode, string(bodyBytes))
|
|
}
|
|
|
|
var ollamaResp ollamaChatResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&ollamaResp); err != nil {
|
|
return nil, fmt.Errorf("decode ollama response: %w", err)
|
|
}
|
|
|
|
return &ChatResponse{
|
|
Content: ollamaResp.Message.Content,
|
|
FinishReason: "stop",
|
|
InputTokens: ollamaResp.PromptEvalCount,
|
|
OutputTokens: ollamaResp.EvalCount,
|
|
ActualModel: ollamaResp.Model,
|
|
}, nil
|
|
}
|
|
|
|
func (a *OllamaAdapter) ChatCompletionStream(ctx context.Context, req *ChatRequest) (<-chan StreamChunk, error) {
|
|
ollamaReq := a.buildRequest(req, true)
|
|
|
|
body, err := json.Marshal(ollamaReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal ollama stream request: %w", err)
|
|
}
|
|
|
|
httpReq, err := http.NewRequestWithContext(ctx, "POST", a.endpoint+"/api/chat", bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create ollama stream request: %w", err)
|
|
}
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
if req.RequestID != "" {
|
|
httpReq.Header.Set("X-Request-ID", req.RequestID)
|
|
}
|
|
|
|
resp, err := a.httpClient.Do(httpReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ollama stream request failed: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
return nil, fmt.Errorf("ollama stream returned status %d: %s", resp.StatusCode, string(bodyBytes))
|
|
}
|
|
|
|
ch := make(chan StreamChunk, 100)
|
|
go func() {
|
|
defer close(ch)
|
|
defer resp.Body.Close()
|
|
|
|
decoder := json.NewDecoder(resp.Body)
|
|
for {
|
|
var chunk ollamaChatStreamResponse
|
|
if err := decoder.Decode(&chunk); err != nil {
|
|
if err == io.EOF {
|
|
ch <- StreamChunk{Done: true, FinishReason: "stop"}
|
|
return
|
|
}
|
|
ch <- StreamChunk{Error: fmt.Errorf("decode stream chunk: %w", err)}
|
|
return
|
|
}
|
|
|
|
// Check for cancellation
|
|
select {
|
|
case <-req.CancelCh:
|
|
ch <- StreamChunk{Done: true, FinishReason: "cancelled"}
|
|
return
|
|
default:
|
|
}
|
|
|
|
if chunk.Done {
|
|
ch <- StreamChunk{
|
|
Done: true,
|
|
FinishReason: "stop",
|
|
InputTokens: chunk.PromptEvalCount,
|
|
OutputTokens: chunk.EvalCount,
|
|
}
|
|
return
|
|
}
|
|
|
|
if chunk.Message.Content != "" {
|
|
ch <- StreamChunk{Delta: chunk.Message.Content}
|
|
}
|
|
}
|
|
}()
|
|
|
|
return ch, nil
|
|
}
|
|
|
|
func (a *OllamaAdapter) ListModels(ctx context.Context) ([]ModelInfo, error) {
|
|
httpReq, err := http.NewRequestWithContext(ctx, "GET", a.endpoint+"/api/tags", nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create list models request: %w", err)
|
|
}
|
|
|
|
resp, err := a.httpClient.Do(httpReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list models failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("list models returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
var tagsResp struct {
|
|
Models []struct {
|
|
Name string `json:"name"`
|
|
} `json:"models"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&tagsResp); err != nil {
|
|
return nil, fmt.Errorf("decode tags response: %w", err)
|
|
}
|
|
|
|
models := make([]ModelInfo, len(tagsResp.Models))
|
|
for i, m := range tagsResp.Models {
|
|
models[i] = ModelInfo{Name: m.Name}
|
|
}
|
|
return models, nil
|
|
}
|
|
|
|
func (a *OllamaAdapter) HealthCheck(ctx context.Context) error {
|
|
httpReq, err := http.NewRequestWithContext(ctx, "GET", a.endpoint+"/api/tags", nil)
|
|
if err != nil {
|
|
return fmt.Errorf("create health check request: %w", err)
|
|
}
|
|
|
|
resp, err := a.httpClient.Do(httpReq)
|
|
if err != nil {
|
|
return fmt.Errorf("health check failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("health check returned status %d", resp.StatusCode)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *OllamaAdapter) Cancel(requestID string) error {
|
|
// Ollama doesn't support request cancellation by ID in the API.
|
|
// Cancellation is handled by closing the HTTP connection (context cancellation).
|
|
return nil
|
|
}
|
|
|
|
func (a *OllamaAdapter) buildRequest(req *ChatRequest, stream bool) ollamaChatRequest {
|
|
msgs := make([]ollamaMsg, len(req.Messages))
|
|
for i, m := range req.Messages {
|
|
content, _ := m.Content.(string)
|
|
msgs[i] = ollamaMsg{Role: m.Role, Content: content}
|
|
}
|
|
|
|
ollamaReq := ollamaChatRequest{
|
|
Model: req.Model,
|
|
Messages: msgs,
|
|
Stream: stream,
|
|
}
|
|
|
|
if req.MaxTokens > 0 {
|
|
ollamaReq.Options.NumPredict = req.MaxTokens
|
|
}
|
|
if req.Temperature != nil {
|
|
ollamaReq.Options.Temperature = *req.Temperature
|
|
}
|
|
if req.TopP != nil {
|
|
ollamaReq.Options.TopP = *req.TopP
|
|
}
|
|
|
|
return ollamaReq
|
|
}
|