mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-20 13:38:11 +02:00
Trois choses manquaient au tour tenu par la façade, actives même sans clé opt-in, et qu'il fallait avant d'en faire le défaut : - la télémétrie de chaque complétion (perfRecord) : /api/perf/summary et l'alerte « cache perdu » posée sur les stats finales d'une étape. La boucle Rust la remet au Go (/internal/v1/turn/perf) au même moment que la boucle Go, avec ses empreintes de messages ; le Go range, calcule la perte et rend nature, jetons perdus et alerte, posés sur la copie finale des stats ; - la sonde du gabarit, lancée dans les mêmes conditions (requête de forme normale du moteur local) ; - le comptage des requêtes en vol : chaque requête au moteur local ouvre un ticket (/internal/v1/oai/begin), marqué servi à la réponse 200 (engineServed, engineMarkMain pour une étape de la discussion), rendu en fin de lecture du flux. Le slot de la discussion n'est ainsi ni effacé pendant une requête ni pris pour un autre. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
168 lines
5.3 KiB
Go
168 lines
5.3 KiB
Go
package loki
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// API interne de la surface /v1 servie par la façade Rust (lot 5).
|
|
//
|
|
// Rust relaie désormais /v1 directement au moteur, mais les DÉCISIONS restent
|
|
// ici, parce qu'elles dépendent de l'état de la conversation que ce process
|
|
// tient en mémoire :
|
|
//
|
|
// - route : ce qu'il faut pour router (port du moteur, Strata, clé des
|
|
// complétions, second slot en service) ;
|
|
// - begin : la requête entre « en vol » (engineRequestBeginSide : le slot de
|
|
// la discussion n'est pas effacé pendant ce temps, un préchauffage est
|
|
// annulé si besoin), puis les refus benchmark / optimisation, puis la trace
|
|
// de perte de cache (perfNoteForeign) — dans l'ordre d'oaiHandler ;
|
|
// - end : la requête est terminée (fin du flux, ou client parti).
|
|
//
|
|
// Un ticket jamais rendu (façade tuée en plein flux) expire au bout de
|
|
// oaiTicketMaxAge : sans ça, le compteur de requêtes en vol resterait bloqué
|
|
// et le slot ne serait plus jamais isolé.
|
|
|
|
const oaiTicketMaxAge = 6 * time.Hour
|
|
|
|
type oaiTicket struct {
|
|
end func()
|
|
when time.Time
|
|
// seq / mainConv : requête d'un tour de discussion tenu par la façade —
|
|
// une fois acceptée par le moteur (served), le slot porte cette discussion
|
|
// (engineMarkMain). Vides pour un client /v1.
|
|
seq uint64
|
|
mainConv string
|
|
}
|
|
|
|
var oaiTickets = struct {
|
|
mu sync.Mutex
|
|
next uint64
|
|
m map[uint64]oaiTicket
|
|
}{m: map[uint64]oaiTicket{}}
|
|
|
|
func handleInternalOAIRoute(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query()
|
|
method, path := q.Get("method"), q.Get("path")
|
|
key, err := effectiveAPIKeyErr()
|
|
out := map[string]any{
|
|
"port": LLMPort(),
|
|
"strata": strataActive(),
|
|
"key": key,
|
|
"key_ok": err == nil,
|
|
"max_body": sideSlotProxyMaxBody,
|
|
}
|
|
// La sonde du second slot interroge le moteur (avec cache) : seulement
|
|
// pour une complétion, comme sideSlotProxyRewrite.
|
|
out["side_live"] = method == http.MethodPost && strings.HasPrefix(path, "/v1/") &&
|
|
sideSlotLive(resolveChatEndpoint())
|
|
sendJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
func handleInternalOAIBegin(w http.ResponseWriter, r *http.Request) {
|
|
var in struct {
|
|
Read bool `json:"read"` // GET/HEAD : ne calcule rien dans un slot
|
|
Side bool `json:"side"` // corps routé vers le second slot
|
|
PostV1 bool `json:"post_v1"` // complétion d'un client externe
|
|
// MainConv : étape d'un tour de cette discussion, tenue par la façade
|
|
// (LOKI_CHAT=rust) — comptée comme la requête principale du Go.
|
|
MainConv string `json:"main_conv"`
|
|
}
|
|
if r.Method != http.MethodPost || json.NewDecoder(r.Body).Decode(&in) != nil {
|
|
sendJSON(w, http.StatusBadRequest, map[string]any{"error": "POST JSON attendu"})
|
|
return
|
|
}
|
|
var end func()
|
|
var seq uint64
|
|
if in.Read {
|
|
end, seq = engineRequestBeginSide(func(*prewarmRun) bool { return true }, true)
|
|
} else {
|
|
end, seq = engineRequestBeginSide(nil, in.Side)
|
|
}
|
|
if in.PostV1 {
|
|
refuse := func(retry, msg, code string) {
|
|
end()
|
|
sendJSON(w, http.StatusOK, map[string]any{"refuse": map[string]any{
|
|
"status": http.StatusServiceUnavailable, "message": msg,
|
|
"type": "server_error", "code": code, "retry_after": retry,
|
|
}})
|
|
}
|
|
if benchRunning() {
|
|
refuse("30", "benchmark en cours sur ce moteur — réessaie dans un moment", "benchmark_running")
|
|
return
|
|
}
|
|
if tuneGuard() != nil {
|
|
refuse("60", "optimisation du moteur en cours — réessaie dans quelques minutes", "tuning_running")
|
|
return
|
|
}
|
|
perfNoteForeign()
|
|
}
|
|
now := time.Now()
|
|
oaiTickets.mu.Lock()
|
|
for id, t := range oaiTickets.m {
|
|
if now.Sub(t.when) > oaiTicketMaxAge {
|
|
t.end()
|
|
delete(oaiTickets.m, id)
|
|
}
|
|
}
|
|
oaiTickets.next++
|
|
id := oaiTickets.next
|
|
oaiTickets.m[id] = oaiTicket{end: end, when: now, seq: seq, mainConv: in.MainConv}
|
|
oaiTickets.mu.Unlock()
|
|
sendJSON(w, http.StatusOK, map[string]any{"ticket": id})
|
|
}
|
|
|
|
func handleInternalOAIEnd(w http.ResponseWriter, r *http.Request) {
|
|
var in struct {
|
|
Ticket uint64 `json:"ticket"`
|
|
}
|
|
if r.Method != http.MethodPost || json.NewDecoder(r.Body).Decode(&in) != nil {
|
|
sendJSON(w, http.StatusBadRequest, map[string]any{"error": "POST JSON attendu"})
|
|
return
|
|
}
|
|
oaiTickets.mu.Lock()
|
|
t, ok := oaiTickets.m[in.Ticket]
|
|
delete(oaiTickets.m, in.Ticket)
|
|
oaiTickets.mu.Unlock()
|
|
if !ok {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
t.end()
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// oaiInflight : nombre de tickets ouverts (tests).
|
|
func oaiInflight() int {
|
|
oaiTickets.mu.Lock()
|
|
defer oaiTickets.mu.Unlock()
|
|
return len(oaiTickets.m)
|
|
}
|
|
|
|
// handleInternalOAIServed : la requête du ticket a été acceptée par le moteur
|
|
// (réponse 200) — engineServed, et pour l'étape d'un tour de discussion,
|
|
// engineMarkMain : le slot porte désormais cette discussion. Le ticket reste
|
|
// ouvert jusqu'à end (fin de lecture du flux).
|
|
func handleInternalOAIServed(w http.ResponseWriter, r *http.Request) {
|
|
var in struct {
|
|
Ticket uint64 `json:"ticket"`
|
|
}
|
|
if r.Method != http.MethodPost || json.NewDecoder(r.Body).Decode(&in) != nil {
|
|
sendJSON(w, http.StatusBadRequest, map[string]any{"error": "POST JSON attendu"})
|
|
return
|
|
}
|
|
oaiTickets.mu.Lock()
|
|
t, ok := oaiTickets.m[in.Ticket]
|
|
oaiTickets.mu.Unlock()
|
|
if !ok {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
engineServed()
|
|
engineMarkMain(t.seq, t.mainConv)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|