From 8d3dee46baee10d3bfd844f48091ddf2a3ad6d12 Mon Sep 17 00:00:00 2001 From: LovroG05 Date: Tue, 7 Jul 2026 11:18:17 +0200 Subject: [PATCH] inject created timestamp for OpenAI spec compatibility OpenWebUI was failing with 'NoneType' errors because it expects a 'created' field on all chat and embedding responses. OpenAI always includes this unix-timestamp field in both streaming chunks and non-streaming responses. - Inject 'created' into non-streaming responses if the upstream didn't provide one (chat + embeddings) - Inject 'created' into every streaming SSE chunk if missing - Add 'created' to Anthropic synthetic streaming chunks and non-streaming translated responses --- backend/internal/api/chat.go | 37 ++++++++++++++++++- backend/internal/api/embeddings.go | 2 +- .../internal/providers/anthropic/stream.go | 12 ++++-- .../internal/providers/anthropic/translate.go | 8 ++-- 4 files changed, 49 insertions(+), 10 deletions(-) diff --git a/backend/internal/api/chat.go b/backend/internal/api/chat.go index 6b2e983..704b674 100644 --- a/backend/internal/api/chat.go +++ b/backend/internal/api/chat.go @@ -90,9 +90,10 @@ func (a *API) chatCompletions(w http.ResponseWriter, r *http.Request) { return } a.Limiter.RecordTokens(entry.Address, resp.Usage.TotalTokens) + respBody := injectCreated(resp.Body) w.Header().Set("Content-Type", "application/json") w.WriteHeader(resp.StatusCode) - _, _ = w.Write(resp.Body) + _, _ = w.Write(respBody) completion := extractCompletion(resp.Body) a.logRequest(r, key, entry, address, resp.StatusCode, false, resp.Usage, "", start, nil, messages, completion) @@ -122,6 +123,7 @@ func (a *API) streamChat(w http.ResponseWriter, r *http.Request, cc providers.Ch var ttft *int var sb strings.Builder + created := time.Now().Unix() for { ev, err := stream.Recv() if errors.Is(err, io.EOF) { @@ -136,7 +138,7 @@ func (a *API) streamChat(w http.ResponseWriter, r *http.Request, cc providers.Ch } // Accumulate assistant content from delta chunks. sb.WriteString(extractDelta(ev.Data)) - _, _ = fmt.Fprintf(w, "data: %s\n\n", ev.Data) + _, _ = fmt.Fprintf(w, "data: %s\n\n", injectCreatedChunk(ev.Data, created)) if flusher != nil { flusher.Flush() } @@ -263,3 +265,34 @@ func clientIP(r *http.Request) string { } 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 +} diff --git a/backend/internal/api/embeddings.go b/backend/internal/api/embeddings.go index ff22518..3f6760e 100644 --- a/backend/internal/api/embeddings.go +++ b/backend/internal/api/embeddings.go @@ -71,7 +71,7 @@ func (a *API) embeddings(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(resp.StatusCode) - _, _ = w.Write(resp.Body) + _, _ = w.Write(injectCreated(resp.Body)) a.logRequest(r, key, entry, address, resp.StatusCode, false, resp.Usage, "", start, nil, nil, "") } diff --git a/backend/internal/providers/anthropic/stream.go b/backend/internal/providers/anthropic/stream.go index 7c555a1..8d27880 100644 --- a/backend/internal/providers/anthropic/stream.go +++ b/backend/internal/providers/anthropic/stream.go @@ -5,6 +5,7 @@ import ( "bytes" "encoding/json" "io" + "time" "git.govekar.net/LovroG05/localmodelroutex/internal/providers" ) @@ -16,6 +17,7 @@ type stream struct { br *bufio.Reader model string id string + created int64 roleSent bool finished bool finishReason string @@ -24,10 +26,11 @@ type stream struct { func newStream(rc io.ReadCloser, model string) *stream { return &stream{ - rc: rc, - br: bufio.NewReaderSize(rc, 64*1024), - model: model, - id: "chatcmpl-stream", + rc: rc, + br: bufio.NewReaderSize(rc, 64*1024), + model: model, + id: "chatcmpl-stream", + created: time.Now().Unix(), } } @@ -143,6 +146,7 @@ func (s *stream) chunk(delta map[string]any, finish *string, usage map[string]an obj := map[string]any{ "id": s.id, "object": "chat.completion.chunk", + "created": s.created, "model": s.model, "choices": []any{choice}, } diff --git a/backend/internal/providers/anthropic/translate.go b/backend/internal/providers/anthropic/translate.go index c52f53d..c26274b 100644 --- a/backend/internal/providers/anthropic/translate.go +++ b/backend/internal/providers/anthropic/translate.go @@ -3,6 +3,7 @@ package anthropic import ( "encoding/json" "strings" + "time" "git.govekar.net/LovroG05/localmodelroutex/internal/providers" ) @@ -246,9 +247,10 @@ func translateResponse(body []byte, model string) ([]byte, providers.Usage) { message["tool_calls"] = toolCalls } resp := map[string]any{ - "id": ar.ID, - "object": "chat.completion", - "model": model, + "id": ar.ID, + "object": "chat.completion", + "created": time.Now().Unix(), + "model": model, "choices": []any{map[string]any{ "index": 0, "message": message,