Files
localmodelroutex/backend/internal/api/chat.go
LovroG05 abecc4b888
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
feat: export request statistics to InfluxDB 3
Add an optional exporter that writes one point per proxied request to
the InfluxDB 3 write endpoint. Points are batched in memory and flushed
by size or interval. The exporter is off until GATEWAY_INFLUX_URL is set.
2026-10-11 17:20:14 +02:00

415 lines
12 KiB
Go

package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"maps"
"net"
"net/http"
"strings"
"time"
chimw "github.com/go-chi/chi/v5/middleware"
"github.com/google/uuid"
"git.govekar.net/LovroG05/localmodelroutex/internal/billing"
"git.govekar.net/LovroG05/localmodelroutex/internal/catalog"
"git.govekar.net/LovroG05/localmodelroutex/internal/providers"
"git.govekar.net/LovroG05/localmodelroutex/internal/store"
)
const maxRequestBody = 10 << 20 // 10 MiB
func (a *API) chatCompletions(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, maxRequestBody))
if err != nil {
writeError(w, http.StatusBadRequest, "failed to read request body")
return
}
req, err := providers.ParseChatRequest(body)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if req.Model == "" {
writeError(w, http.StatusBadRequest, "missing 'model'")
return
}
key := keyFrom(r.Context())
address := req.Model
entry, err := a.Cache.Resolve(address)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
if !allowed(key, entry.Address) {
writeError(w, http.StatusForbidden, fmt.Sprintf("model %q is not allowed for this key", address))
return
}
if !a.Limiter.AllowRequest(entry.Address, ptrInt(entry.RPMLimit)) {
writeError(w, http.StatusTooManyRequests, "rate limit exceeded (RPM)")
return
}
if !a.Limiter.AllowTokens(entry.Address, ptrInt(entry.TPMLimit)) {
writeError(w, http.StatusTooManyRequests, "rate limit exceeded (TPM)")
return
}
prov, ok := a.Manager.Get(entry.ProviderName)
if !ok {
writeError(w, http.StatusServiceUnavailable, "provider unavailable")
return
}
cc, ok := prov.(providers.ChatCompleter)
if !ok {
writeError(w, http.StatusNotImplemented, "provider does not support chat completions")
return
}
// Snapshot the messages before we overwrite req.Model below.
messages := marshalMessages(req.Payload["messages"])
req.Model = entry.Slug
applyOverrides(req.Payload, entry.ParamOverrides)
start := time.Now()
if req.Stream {
a.streamChat(w, r, cc, req, entry, key, address, messages, start)
return
}
resp, err := cc.ChatCompletion(r.Context(), req)
if err != nil {
writeError(w, http.StatusBadGateway, err.Error())
a.logRequest(r, key, entry, address, http.StatusBadGateway, false, providers.Usage{}, err.Error(), start, nil, messages, "")
return
}
a.Limiter.RecordTokens(entry.Address, resp.Usage.TotalTokens)
respBody := resp.Body
errMsg := ""
if resp.StatusCode >= 400 {
errMsg = upstreamErrorMessage(resp.Body)
} else {
respBody = injectCreated(resp.Body)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
_, _ = w.Write(respBody)
completion := extractCompletion(resp.Body)
a.logRequest(r, key, entry, address, resp.StatusCode, false, resp.Usage, errMsg, start, nil, messages, completion)
}
func (a *API) streamChat(w http.ResponseWriter, r *http.Request, cc providers.ChatCompleter, req *providers.ChatRequest, entry *catalog.Entry, key *store.VirtualKey, address string, messages json.RawMessage, start time.Time) {
stream, err := cc.ChatCompletionStream(r.Context(), req)
if err != nil {
var apiErr *providers.APIError
if errors.As(err, &apiErr) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(apiErr.StatusCode)
_, _ = w.Write(apiErr.Body)
a.logRequest(r, key, entry, address, apiErr.StatusCode, true, providers.Usage{}, upstreamErrorMessage(apiErr.Body), start, nil, messages, "")
return
}
writeError(w, http.StatusBadGateway, err.Error())
a.logRequest(r, key, entry, address, http.StatusBadGateway, true, providers.Usage{}, err.Error(), start, nil, messages, "")
return
}
defer stream.Close()
flusher, _ := w.(http.Flusher)
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.WriteHeader(http.StatusOK)
var ttft *int
var reasoning, content strings.Builder
created := time.Now().Unix()
errMsg := ""
for {
ev, err := stream.Recv()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
// The status line is already sent, so the error can only be
// recorded in the log row and signalled by closing the stream.
if r.Context().Err() != nil {
errMsg = "client disconnected"
} else {
errMsg = "stream interrupted: " + err.Error()
}
break
}
if ttft == nil {
ms := int(time.Since(start).Milliseconds())
ttft = &ms
}
// Accumulate reasoning and content deltas separately so the recorded
// completion wraps reasoning in think tags, matching extractCompletion.
r, c := extractDelta(ev.Data)
reasoning.WriteString(r)
content.WriteString(c)
_, _ = fmt.Fprintf(w, "data: %s\n\n", injectCreatedChunk(ev.Data, created))
if flusher != nil {
flusher.Flush()
}
}
_, _ = fmt.Fprint(w, "data: [DONE]\n\n")
if flusher != nil {
flusher.Flush()
}
completion := content.String()
if reasoning.Len() > 0 {
completion = "<think>\n" + reasoning.String() + "\n</think>\n\n" + completion
}
usage := stream.Usage()
a.Limiter.RecordTokens(entry.Address, usage.TotalTokens)
a.logRequest(r, key, entry, address, http.StatusOK, true, usage, errMsg, start, ttft, messages, completion)
}
func applyOverrides(payload, overrides map[string]any) {
maps.Copy(payload, overrides)
}
func (a *API) logRequest(r *http.Request, key *store.VirtualKey, entry *catalog.Entry, address string, status int, streamed bool, usage providers.Usage, errMsg string, start time.Time, ttft *int, messages json.RawMessage, completion string) {
entryID := entry.ID
providerID := entry.ProviderID
rec := store.RequestLog{
ProviderModelID: &entryID,
ProviderID: &providerID,
ProviderName: entry.ProviderName,
ModelName: address,
StatusCode: status,
Streamed: streamed,
Error: errMsg,
PromptTokens: usage.PromptTokens,
CompletionTokens: usage.CompletionTokens,
CachedTokens: usage.CachedTokens,
TotalTokens: usage.TotalTokens,
Cost: billing.Cost(entry, usage),
LatencyMS: int(time.Since(start).Milliseconds()),
TTFTMs: ttft,
RequestID: chimw.GetReqID(r.Context()),
ClientIP: clientIP(r),
}
if key != nil {
rec.KeyPrefix = key.KeyPrefix
rec.UserID = key.UserID
// Only set VirtualKeyID for real keys — synthetic dashboard keys have a nil UUID.
if key.ID != uuid.Nil {
rec.VirtualKeyID = &key.ID
}
}
if a.Stats != nil {
a.Stats.Record(rec)
}
// The log write must outlive the request, so it gets its own context.
go func() { //nolint:gosec // G118: intentional, see above
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
id, err := a.Logs.InsertRequestLog(ctx, rec)
if err != nil {
slog.Warn("failed to write request log", "err", err)
return
}
if err := a.Logs.InsertLogContent(ctx, id, messages, completion); err != nil {
slog.Warn("failed to write request log content", "err", err)
}
if key != nil && key.ID != uuid.Nil {
_ = a.Logs.TouchVirtualKey(ctx, key.ID)
}
}()
}
// marshalMessages extracts the messages array from the request payload as raw
// JSON for the content log. Inline image data is replaced by a short marker so
// a single multimodal request cannot write megabytes of base64 into the log.
func marshalMessages(v any) json.RawMessage {
arr, ok := v.([]any)
if !ok {
return json.RawMessage("[]")
}
b, err := json.Marshal(redactImages(arr))
if err != nil {
return json.RawMessage("[]")
}
return b
}
// redactImages returns a copy of the messages with data: image URLs replaced.
func redactImages(messages []any) []any {
out := make([]any, len(messages))
for i, raw := range messages {
m, ok := raw.(map[string]any)
if !ok {
out[i] = raw
continue
}
parts, ok := m["content"].([]any)
if !ok {
out[i] = m
continue
}
cp := make(map[string]any, len(m))
maps.Copy(cp, m)
newParts := make([]any, len(parts))
for j, p := range parts {
part, ok := p.(map[string]any)
if !ok || part["type"] != "image_url" {
newParts[j] = p
continue
}
iu, _ := part["image_url"].(map[string]any)
url, _ := iu["url"].(string)
if !strings.HasPrefix(url, "data:") {
newParts[j] = p
continue
}
mediaType := url
if i := strings.IndexAny(url, ";,"); i > 0 {
mediaType = url[:i]
}
newParts[j] = map[string]any{
"type": "image_url",
"image_url": map[string]any{"url": fmt.Sprintf("[inline image %s, %d bytes omitted]", strings.TrimPrefix(mediaType, "data:"), len(url))},
}
}
cp["content"] = newParts
out[i] = cp
}
return out
}
// upstreamErrorMessage pulls the message out of an OpenAI-style error body,
// falling back to a truncated copy of the raw body.
func upstreamErrorMessage(body []byte) string {
var e struct {
Error struct {
Message string `json:"message"`
} `json:"error"`
}
if json.Unmarshal(body, &e) == nil && e.Error.Message != "" {
return e.Error.Message
}
msg := strings.TrimSpace(string(body))
if len(msg) > 500 {
msg = msg[:500] + "…"
}
return msg
}
// extractCompletion pulls the first choice's message content from a non-streaming
// response body. Reasoning (reasoning_content, reasoning, or reasoning_details) is
// wrapped in think tags and prepended when present.
func extractCompletion(body []byte) string {
var resp struct {
Choices []struct {
Message struct {
ReasoningContent string `json:"reasoning_content"`
Reasoning string `json:"reasoning"`
ReasoningDetails []reasoningDetail `json:"reasoning_details"`
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
if err := json.Unmarshal(body, &resp); err != nil || len(resp.Choices) == 0 {
return ""
}
m := resp.Choices[0].Message
r := reasoningText(m.ReasoningContent, m.Reasoning, m.ReasoningDetails)
if r != "" {
return "<think>\n" + r + "\n</think>\n\n" + m.Content
}
return m.Content
}
// extractDelta pulls the reasoning and content deltas from a streaming SSE chunk.
// reasoning_content, reasoning, and reasoning_details are all recognized.
func extractDelta(data []byte) (reasoning, content string) {
var chunk struct {
Choices []struct {
Delta struct {
ReasoningContent string `json:"reasoning_content"`
Reasoning string `json:"reasoning"`
ReasoningDetails []reasoningDetail `json:"reasoning_details"`
Content string `json:"content"`
} `json:"delta"`
} `json:"choices"`
}
if err := json.Unmarshal(data, &chunk); err != nil || len(chunk.Choices) == 0 {
return "", ""
}
d := chunk.Choices[0].Delta
return reasoningText(d.ReasoningContent, d.Reasoning, d.ReasoningDetails), d.Content
}
// reasoningDetail is one entry in a reasoning_details array.
type reasoningDetail struct {
Type string `json:"type"`
Text string `json:"text"`
Summary string `json:"summary"`
}
// reasoningText joins reasoning fragments a provider may emit: reasoning_content
// (llama.cpp), reasoning (OpenRouter), and reasoning_details (structured blocks).
func reasoningText(reasoningContent, reasoning string, details []reasoningDetail) string {
var b strings.Builder
b.WriteString(reasoningContent)
b.WriteString(reasoning)
for _, d := range details {
b.WriteString(d.Text)
b.WriteString(d.Summary)
}
return b.String()
}
func clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
// injectCreated ensures a non-streaming response has a "created" timestamp.
// If the upstream already provided one, it is preserved.
func injectCreated(body []byte) []byte {
var obj map[string]any
if json.Unmarshal(body, &obj) == nil {
if _, ok := obj["created"]; ok {
return body // upstream already set it
}
obj["created"] = time.Now().Unix()
if out, err := json.Marshal(obj); err == nil {
return out
}
}
return body // fall back to raw body on any error
}
// injectCreatedChunk ensures a streaming SSE chunk has a "created" timestamp.
func injectCreatedChunk(data []byte, created int64) []byte {
var obj map[string]any
if json.Unmarshal(data, &obj) == nil {
if _, ok := obj["created"]; ok {
return data // upstream already set it
}
obj["created"] = created
if out, err := json.Marshal(obj); err == nil {
return out
}
}
return data // fall back to raw chunk on any error
}