Replace go-openai with native client

Drop the github.com/sashabaranov/go-openai dependency in favor of an
internal HTTP client.

The LLM package is now split into:
- llm.go: type definitions (Message, ChatRequest, ChatResponse, etc.)
- client.go: client construction and configuration
- client_completions.go: Completions and CompletionsStream methods
- client_models.go: ListModels
- client_tools.go: tool call execution

CompletionsStream uses typed SSE events (delta, done, message) so the
frontend can render progressive streaming output.

Move tool.go and tool_test.go from internal/tool/ into internal/llm/
to co-locate the registry with the client that consumes it.

Update job.go and service.go to use the new llm.Message types and
Completions/CompletionsStream methods. Remove the Voice field from
chat request/response (voice is now tracked via message metadata).

Frontend: handle delta/done/message SSE events, render a streaming
placeholder that receives progressive text deltas, and finalize with
full message binding (reasoning, tool calls, actions).
This commit is contained in:
dwrz
2026-06-26 14:22:05 +00:00
parent e480538ec2
commit f6fb21d40c
56 changed files with 1671 additions and 7854 deletions

View File

@@ -24,11 +24,9 @@ import (
"code.chimeric.al/chimerical/odidere/internal/job"
"code.chimeric.al/chimerical/odidere/internal/llm"
"code.chimeric.al/chimerical/odidere/internal/service/templates"
"code.chimeric.al/chimerical/odidere/internal/tool"
"github.com/google/uuid"
"github.com/robfig/cron/v3"
openai "github.com/sashabaranov/go-openai"
)
// Context keys for request-scoped values.
@@ -57,7 +55,7 @@ type Service struct {
mux *http.ServeMux
server *http.Server
tmpl *template.Template
tools *tool.Registry
tools *llm.Registry
}
// New creates a Service from the provided configuration.
@@ -70,7 +68,7 @@ func New(cfg *config.Config, log *slog.Logger) (*Service, error) {
}
// Setup tool registry.
registry, err := tool.NewRegistry(cfg.Tools)
registry, err := llm.NewRegistry(cfg.Tools)
if err != nil {
return nil, fmt.Errorf("load tools: %v", err)
}
@@ -441,28 +439,24 @@ func (svc *Service) status(w http.ResponseWriter, r *http.Request) {
// Request is the incoming request format for the chat endpoints.
type Request struct {
// Messages is the conversation history.
Messages []openai.ChatCompletionMessage `json:"messages"`
Messages []llm.Message `json:"messages"`
// Provider is the LLM provider name. If empty, the default provider is used.
Provider string `json:"provider,omitempty"`
// Model is the LLM model ID. If empty, the default model is used.
Model string `json:"model,omitempty"`
// SystemMessage overrides the configured system message for this request.
SystemMessage string `json:"system_message,omitempty"`
// Voice is the voice ID for TTS.
Voice string `json:"voice,omitempty"`
}
// Response is the response format for chat and voice endpoints.
type Response struct {
// Messages is the full list of messages generated during the query,
// Messages is the full list of messages generated during the completions request,
// including tool calls and tool results.
Messages []openai.ChatCompletionMessage `json:"messages,omitempty"`
Messages []llm.Message `json:"messages,omitempty"`
// Provider is the LLM provider used for the response.
Provider string `json:"used_provider,omitempty"`
// Model is the LLM model used for the response.
Model string `json:"used_model,omitempty"`
// Voice is the voice used for TTS synthesis.
Voice string `json:"used_voice,omitempty"`
}
// chat processes text chat requests.
@@ -490,7 +484,9 @@ func (svc *Service) chat(w http.ResponseWriter, r *http.Request) {
http.Error(w, "messages required", http.StatusBadRequest)
return
}
log.InfoContext(ctx, "messages",
log.DebugContext(
ctx,
"messages",
slog.Any("data", req.Messages),
)
@@ -540,7 +536,9 @@ func (svc *Service) chat(w http.ResponseWriter, r *http.Request) {
}
}
msgs, err := llmc.Query(ctx, req.Messages, model, req.SystemMessage, 0)
msgs, err := llmc.Completions(
ctx, model, req.SystemMessage, 0, req.Messages,
)
if err != nil {
log.ErrorContext(
ctx,
@@ -559,10 +557,10 @@ func (svc *Service) chat(w http.ResponseWriter, r *http.Request) {
return
}
final := msgs[len(msgs)-1]
log.InfoContext(
log.DebugContext(
ctx,
"LLM response",
slog.String("text", final.Content),
slog.String("text", final.Text()),
slog.String("provider", provider),
slog.String("model", model),
)
@@ -572,7 +570,6 @@ func (svc *Service) chat(w http.ResponseWriter, r *http.Request) {
Messages: msgs,
Provider: provider,
Model: model,
Voice: req.Voice,
}); err != nil {
log.ErrorContext(
ctx,
@@ -586,14 +583,14 @@ func (svc *Service) chat(w http.ResponseWriter, r *http.Request) {
type StreamMessage struct {
// Error is an error message, if any.
Error string `json:"error,omitempty"`
// Delta is a text fragment from the assistant response.
Delta string `json:"delta,omitempty"`
// Message is the chat completion message.
Message openai.ChatCompletionMessage `json:"message"`
Message *llm.Message `json:"message,omitempty"`
// Provider is the LLM provider used for the response.
Provider string `json:"provider,omitempty"`
// Model is the LLM model used for the response.
Model string `json:"model,omitempty"`
// Voice is the voice used for TTS synthesis.
Voice string `json:"voice,omitempty"`
}
// chatStream processes chat requests with streaming SSE output.
@@ -685,7 +682,7 @@ func (svc *Service) chatStream(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
// Helper to send an SSE event.
send := func(msg StreamMessage) {
send := func(event string, msg StreamMessage) {
data, err := json.Marshal(msg)
if err != nil {
log.ErrorContext(ctx, "failed to marshal SSE event",
@@ -693,54 +690,47 @@ func (svc *Service) chatStream(w http.ResponseWriter, r *http.Request) {
)
return
}
fmt.Fprintf(w, "event: message\ndata: %s\n\n", data)
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, data)
flusher.Flush()
}
// Start streaming LLM query.
// Start streaming LLM completions request.
var (
events = make(chan llm.StreamEvent)
llmErr error
errs = make(chan error, 1)
)
go func() {
llmErr = llmc.QueryStream(
ctx, req.Messages, model, req.SystemMessage, 0, events,
errs <- llmc.CompletionsStream(
ctx, model, req.SystemMessage, 0, req.Messages, events,
)
close(errs)
}()
// Consume events and send as SSE.
var last StreamMessage
for evt := range events {
msg := StreamMessage{Message: evt.Message}
// Track the last assistant message for TTS.
if evt.Message.Role == openai.ChatMessageRoleAssistant &&
len(evt.Message.ToolCalls) == 0 {
last = msg
continue
msg := StreamMessage{
Delta: evt.Delta,
Provider: provider,
Model: model,
}
send(msg)
if evt.Message.Role != "" {
msg.Message = &evt.Message
}
send(evt.Type, msg)
}
// Check for LLM errors.
if llmErr != nil {
if err := <-errs; err != nil {
log.ErrorContext(
ctx,
"LLM stream failed",
slog.Any("error", llmErr),
slog.Any("error", err),
)
send(StreamMessage{
Message: openai.ChatCompletionMessage{
Role: openai.ChatMessageRoleAssistant,
},
Error: fmt.Sprintf("LLM error: %v", llmErr),
msg := llm.Message{Role: llm.RoleAssistant}
send("message", StreamMessage{
Message: &msg,
Error: fmt.Sprintf("LLM error: %v", err),
})
return
}
last.Provider = provider
last.Model = model
last.Voice = req.Voice
send(last)
}
// ModelStatus represents the availability status of a model.