nub/internal/llm/openai/request.go
Tom bd7e13385d Update 0.2.0
- siehe Changelog
2026-07-27 20:06:22 +02:00

147 lines
3.5 KiB
Go

package openai
import (
"encoding/json"
"fmt"
"nub/internal/llm"
)
const systemSeparator = "--- system ---\n"
func buildRequest(req llm.Request, caps llm.Caps) wireRequest {
wr := wireRequest{
Model: req.Model,
Temperature: req.Temp,
Stream: true,
}
if caps.Reasoning {
wr.MaxCompletionTokens = req.MaxTokens
} else {
wr.MaxTokens = req.MaxTokens
}
if caps.UsageInStream {
wr.StreamOptions = &streamOptions{IncludeUsage: true}
}
var messages []wireMessage
for _, m := range req.Messages {
messages = append(messages, mapMessage(m)...)
}
// System-Rolle je nach Endpoint-Capability (5.1 Punkt 4): "system" oder
// "developer" als eigene Message-Rolle, sonst als erste User-Message mit
// Trennmarker (Endpoint ohne System-Rollen-Unterstützung).
if len(req.System) > 0 {
text := blocksToText(req.System)
switch caps.SystemRole {
case "system", "developer":
messages = append([]wireMessage{{Role: caps.SystemRole, Content: textContent(text)}}, messages...)
default:
messages = prependSystemAsUser(messages, text)
}
}
wr.Messages = messages
// "tools" weglassen statt leer senden (5.1 Punkt 2).
if len(req.Tools) > 0 {
wr.Tools = make([]wireToolDef, len(req.Tools))
for i, t := range req.Tools {
wr.Tools[i] = wireToolDef{
Type: "function",
Function: wireFunctionDef{
Name: t.Name,
Description: t.Description,
Parameters: t.Schema,
},
}
}
}
return wr
}
func mapMessage(m llm.Message) []wireMessage {
var out []wireMessage
role := string(m.Role)
var textParts []string
var toolCalls []wireToolCall
flushAssistant := func() {
if len(textParts) == 0 && len(toolCalls) == 0 {
return
}
wm := wireMessage{Role: role, ToolCalls: toolCalls}
if len(textParts) > 0 {
wm.Content = textContent(joinText(textParts))
}
out = append(out, wm)
textParts = nil
toolCalls = nil
}
for _, b := range m.Content {
switch b.Kind {
case llm.KindText:
textParts = append(textParts, b.Text)
case llm.KindThinking:
// Reasoning-Blöcke werden beim Zurücksenden nicht erneut mitgeschickt;
// sie sind rein empfangsseitig relevant (5.1 Punkt 5).
case llm.KindToolUse:
toolCalls = append(toolCalls, wireToolCall{
ID: b.ID,
Type: "function",
Function: wireToolCallFunc{
Name: b.Name,
Arguments: string(b.Input),
},
})
case llm.KindToolResult:
flushAssistant()
out = append(out, wireMessage{
Role: "tool",
ToolCallID: b.ToolUseID,
Content: textContent(blocksToText(b.Result)),
})
case llm.KindImage:
// v1: Bild-Content wird nicht gemappt (SupportsImages-Endpoints folgen später).
}
}
flushAssistant()
return out
}
func blocksToText(blocks []llm.Block) string {
var parts []string
for _, b := range blocks {
if b.Kind == llm.KindText || b.Kind == llm.KindThinking {
parts = append(parts, b.Text)
}
}
return joinText(parts)
}
func joinText(parts []string) string {
out := ""
for i, p := range parts {
if i > 0 {
out += "\n"
}
out += p
}
return out
}
func textContent(s string) json.RawMessage {
b, _ := json.Marshal(s)
return b
}
// prependSystemAsUser wird verwendet, wenn Caps.SystemRole == "none" gesetzt
// ist (Endpoint ohne System-Rolle): System-Text als erste User-Message mit
// Trennmarker.
func prependSystemAsUser(messages []wireMessage, systemText string) []wireMessage {
marker := fmt.Sprintf("%s%s", systemSeparator, systemText)
return append([]wireMessage{{Role: "user", Content: textContent(marker)}}, messages...)
}