Node store/workspace 위치를 Edge가 내려주는 runtime payload에서 분리한다. Chat Completions tool 요청은 내부 실행에서 tool_choice=none으로 낮춰 downstream 자동 tool 호출 요구로 실패하지 않게 맞춘다.
384 lines
11 KiB
Go
384 lines
11 KiB
Go
package openai
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
edgeservice "iop/apps/edge/internal/service"
|
|
"iop/packages/go/config"
|
|
)
|
|
|
|
func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
|
return
|
|
}
|
|
defer r.Body.Close()
|
|
|
|
var req chatCompletionRequest
|
|
if err := decodeChatCompletionRequest(json.NewDecoder(r.Body), &req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request_error", err.Error())
|
|
return
|
|
}
|
|
runMeta, workspace, err := parseOpenAIMetadata(req.Metadata)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request_error", err.Error())
|
|
return
|
|
}
|
|
|
|
dispatch, ok := s.resolveRouteDispatch(req.Model)
|
|
if !ok {
|
|
writeError(w, http.StatusBadRequest, "invalid_request_error", "model is required")
|
|
return
|
|
}
|
|
if err := validateWorkspaceForRoute(dispatch, workspace); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request_error", err.Error())
|
|
return
|
|
}
|
|
basePrompt := promptFromMessages(req.Messages)
|
|
if strings.TrimSpace(basePrompt) == "" {
|
|
writeError(w, http.StatusBadRequest, "invalid_request_error", "messages are required")
|
|
return
|
|
}
|
|
outputPolicy := s.resolveOutputPolicy(basePrompt)
|
|
messages := req.Messages
|
|
if instruction := strictOutputContractInstruction(outputPolicy); instruction != "" {
|
|
messages = prependSystemMessage(messages, instruction)
|
|
}
|
|
prompt := promptFromMessages(messages)
|
|
input := req.runInput(prompt, messages, outputPolicy.Strict)
|
|
s.logger.Info("openai chat completion input",
|
|
zap.String("model", req.Model),
|
|
zap.String("target", dispatch.Target),
|
|
zap.String("adapter", dispatch.Adapter),
|
|
zap.Bool("strict_output", outputPolicy.Strict),
|
|
zap.Bool("strict_stream_buffer", outputPolicy.StreamBuffer),
|
|
zap.String("xml_completion_tool", outputPolicy.XMLCompletionTool),
|
|
zap.Bool("contract_instruction", outputPolicy.ContractInstruction),
|
|
zap.Bool("stream", req.Stream),
|
|
zap.Int("message_count", len(req.Messages)),
|
|
zap.Int("prompt_len", len(prompt)),
|
|
zap.String("prompt_preview", previewString(prompt, 1000)),
|
|
zap.Any("input_keys", mapKeys(input)),
|
|
)
|
|
|
|
handle, err := s.service.SubmitRun(r.Context(), edgeservice.SubmitRunRequest{
|
|
NodeRef: dispatch.NodeRef,
|
|
ModelGroupKey: strings.TrimSpace(req.Model),
|
|
Adapter: dispatch.Adapter,
|
|
Target: dispatch.Target,
|
|
SessionID: dispatch.SessionID,
|
|
Workspace: workspace,
|
|
Prompt: prompt,
|
|
Input: input,
|
|
TimeoutSec: dispatch.TimeoutSec,
|
|
MaxQueue: dispatch.MaxQueue,
|
|
QueueTimeoutMS: dispatch.QueueTimeoutMS,
|
|
Metadata: chatRunMetadata(runMeta, req, outputPolicy),
|
|
ProviderPool: dispatch.ProviderPool,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusBadGateway, "node_dispatch_error", err.Error())
|
|
return
|
|
}
|
|
defer handle.Close()
|
|
|
|
if req.Stream {
|
|
s.streamChatCompletion(w, r, req, handle, outputPolicy)
|
|
return
|
|
}
|
|
s.completeChatCompletion(w, r, req, handle, outputPolicy)
|
|
}
|
|
|
|
func decodeChatCompletionRequest(dec *json.Decoder, req *chatCompletionRequest) error {
|
|
var raw map[string]json.RawMessage
|
|
if err := dec.Decode(&raw); err != nil {
|
|
return fmt.Errorf("invalid JSON request")
|
|
}
|
|
for key := range raw {
|
|
switch key {
|
|
case "model", "messages", "stream", "metadata", "max_tokens", "max_completion_tokens", "temperature", "top_p", "presence_penalty", "frequency_penalty", "seed", "stop", "response_format", "tools", "tool_choice", "parallel_tool_calls", "stream_options":
|
|
default:
|
|
return fmt.Errorf("%s is not supported for /v1/chat/completions", key)
|
|
}
|
|
}
|
|
normalized, err := json.Marshal(raw)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid JSON request")
|
|
}
|
|
if err := json.Unmarshal(normalized, req); err != nil {
|
|
return fmt.Errorf("invalid /v1/chat/completions request format")
|
|
}
|
|
if req.MaxTokens != nil && *req.MaxTokens <= 0 {
|
|
return fmt.Errorf("max_tokens must be greater than zero")
|
|
}
|
|
if req.MaxCompletionTokens != nil && *req.MaxCompletionTokens <= 0 {
|
|
return fmt.Errorf("max_completion_tokens must be greater than zero")
|
|
}
|
|
if req.Temperature != nil && (*req.Temperature < 0 || *req.Temperature > 2) {
|
|
return fmt.Errorf("temperature must be between 0 and 2")
|
|
}
|
|
if req.TopP != nil && (*req.TopP < 0 || *req.TopP > 1) {
|
|
return fmt.Errorf("top_p must be between 0 and 1")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func chatRunMetadata(runMeta map[string]string, req chatCompletionRequest, outputPolicy strictOutputPolicy) map[string]string {
|
|
if runMeta == nil {
|
|
runMeta = make(map[string]string)
|
|
}
|
|
runMeta["openai_model"] = req.Model
|
|
runMeta["openai_stream"] = fmt.Sprintf("%t", req.Stream)
|
|
runMeta["strict_output"] = fmt.Sprintf("%t", outputPolicy.Strict)
|
|
return runMeta
|
|
}
|
|
|
|
func (s *Server) completeChatCompletion(w http.ResponseWriter, r *http.Request, req chatCompletionRequest, handle edgeservice.RunResult, outputPolicy strictOutputPolicy) {
|
|
text, reasoning, finishReason, usage, err := collectRunResult(r.Context(), handle.Stream(), handle.WaitTimeout())
|
|
if err != nil {
|
|
writeError(w, httpStatusForRunError(err), "run_error", err.Error())
|
|
return
|
|
}
|
|
text, reasoning, normalized := normalizeCompletionOutput(outputPolicy, text, reasoning)
|
|
s.logger.Info("openai chat completion output",
|
|
zap.String("run_id", handle.Dispatch().RunID),
|
|
zap.Bool("strict_output", outputPolicy.Strict),
|
|
zap.Bool("strict_stream_buffer", outputPolicy.StreamBuffer),
|
|
zap.String("xml_completion_tool", outputPolicy.XMLCompletionTool),
|
|
zap.Bool("normalized", normalized),
|
|
zap.Int("content_len", len(text)),
|
|
zap.Int("reasoning_len", len(reasoning)),
|
|
zap.String("content_preview", previewString(text, 1000)),
|
|
)
|
|
created := time.Now().Unix()
|
|
writeJSON(w, http.StatusOK, chatCompletionResponse{
|
|
ID: "chatcmpl-" + handle.Dispatch().RunID,
|
|
Object: "chat.completion",
|
|
Created: created,
|
|
Model: responseModel(req.Model, handle.Dispatch().Target),
|
|
Choices: []chatCompletionChoice{{
|
|
Index: 0,
|
|
Message: chatMessage{Role: "assistant", Content: text, ReasoningContent: reasoning},
|
|
FinishReason: finishReason,
|
|
}},
|
|
Usage: usage,
|
|
})
|
|
}
|
|
|
|
func (s *Server) resolveAdapter() string {
|
|
if s.cfg.Adapter != "" {
|
|
return s.cfg.Adapter
|
|
}
|
|
return "ollama"
|
|
}
|
|
|
|
func (s *Server) resolveTarget(model string) string {
|
|
if s.cfg.Target != "" {
|
|
return s.cfg.Target
|
|
}
|
|
return strings.TrimSpace(model)
|
|
}
|
|
|
|
// routeDispatch holds fully-resolved dispatch parameters for a single request.
|
|
type routeDispatch struct {
|
|
NodeRef string
|
|
Adapter string
|
|
Target string
|
|
SessionID string
|
|
TimeoutSec int
|
|
MaxQueue int
|
|
QueueTimeoutMS int
|
|
WorkspaceRequired bool
|
|
// ProviderPool is true when the request model matched a provider-pool
|
|
// catalog entry. Adapter and Target are empty; the service layer resolves
|
|
// them per-candidate and rewrites Target after admission.
|
|
ProviderPool bool
|
|
}
|
|
|
|
// resolveRoute returns the first catalog entry whose Model matches model.
|
|
// Entries with an empty Target are skipped.
|
|
func (s *Server) resolveRoute(model string) *config.OpenAIRouteEntry {
|
|
model = strings.TrimSpace(model)
|
|
if model == "" {
|
|
return nil
|
|
}
|
|
for i := range s.cfg.ModelRoutes {
|
|
r := &s.cfg.ModelRoutes[i]
|
|
if strings.TrimSpace(r.Model) == model && r.Target != "" {
|
|
return r
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// findProviderPoolEntry returns the catalog entry matching model, or nil.
|
|
func (s *Server) findProviderPoolEntry(model string) *config.ModelCatalogEntry {
|
|
model = strings.TrimSpace(model)
|
|
if model == "" {
|
|
return nil
|
|
}
|
|
modelCatalog := s.modelCatalogSnapshot()
|
|
for i := range modelCatalog {
|
|
if modelCatalog[i].ID == model {
|
|
entry := modelCatalog[i]
|
|
return &entry
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// resolveRouteDispatch returns fully-resolved dispatch params for model.
|
|
// Priority: provider-pool catalog → legacy model_routes → single-target fallback.
|
|
// Returns (dispatch, true) on success; (zero, false) when no target can be resolved.
|
|
func (s *Server) resolveRouteDispatch(model string) (routeDispatch, bool) {
|
|
// Provider-pool catalog takes highest priority.
|
|
if s.findProviderPoolEntry(model) != nil {
|
|
return routeDispatch{
|
|
SessionID: s.resolveSessionID(),
|
|
TimeoutSec: s.resolveTimeoutSec(),
|
|
ProviderPool: true,
|
|
}, true
|
|
}
|
|
|
|
if route := s.resolveRoute(model); route != nil {
|
|
adapter := route.Adapter
|
|
if adapter == "" {
|
|
adapter = s.resolveAdapter()
|
|
}
|
|
nodeRef := route.NodeRef
|
|
if nodeRef == "" {
|
|
nodeRef = s.cfg.NodeRef
|
|
}
|
|
sessionID := route.SessionID
|
|
if sessionID == "" {
|
|
sessionID = s.resolveSessionID()
|
|
}
|
|
timeoutSec := route.TimeoutSec
|
|
if timeoutSec <= 0 {
|
|
timeoutSec = s.resolveTimeoutSec()
|
|
}
|
|
return routeDispatch{
|
|
NodeRef: nodeRef,
|
|
Adapter: adapter,
|
|
Target: route.Target,
|
|
SessionID: sessionID,
|
|
TimeoutSec: timeoutSec,
|
|
MaxQueue: route.MaxQueue,
|
|
QueueTimeoutMS: route.QueueTimeoutMS,
|
|
WorkspaceRequired: route.WorkspaceRequired,
|
|
}, true
|
|
}
|
|
target := s.resolveTarget(model)
|
|
if target == "" {
|
|
return routeDispatch{}, false
|
|
}
|
|
return routeDispatch{
|
|
NodeRef: s.cfg.NodeRef,
|
|
Adapter: s.resolveAdapter(),
|
|
Target: target,
|
|
SessionID: s.resolveSessionID(),
|
|
TimeoutSec: s.resolveTimeoutSec(),
|
|
}, true
|
|
}
|
|
|
|
func (s *Server) resolveSessionID() string {
|
|
if s.cfg.SessionID != "" {
|
|
return s.cfg.SessionID
|
|
}
|
|
return edgeservice.DefaultSessionID
|
|
}
|
|
|
|
func (s *Server) resolveTimeoutSec() int {
|
|
if s.cfg.TimeoutSec > 0 {
|
|
return s.cfg.TimeoutSec
|
|
}
|
|
return edgeservice.DefaultTimeoutSec
|
|
}
|
|
|
|
func (s *Server) resolveStrictOutput() bool {
|
|
return s.cfg.StrictOutput
|
|
}
|
|
|
|
func (s *Server) resolveStrictStreamBuffer() bool {
|
|
return s.cfg.StrictStreamBuffer
|
|
}
|
|
|
|
func (s *Server) resolveOutputPolicy(prompt string) strictOutputPolicy {
|
|
policy := strictOutputPolicy{
|
|
Strict: s.resolveStrictOutput(),
|
|
StreamBuffer: s.resolveStrictStreamBuffer(),
|
|
}
|
|
if !policy.Strict {
|
|
return policy
|
|
}
|
|
policy.XMLCompletionTool, policy.XMLResultTag = inferXMLCompletionContract(prompt)
|
|
policy.ContractInstruction = policy.XMLCompletionTool != ""
|
|
return policy
|
|
}
|
|
|
|
func promptFromMessages(messages []chatMessage) string {
|
|
var b strings.Builder
|
|
for _, msg := range messages {
|
|
content := strings.TrimSpace(msg.Content)
|
|
if content == "" {
|
|
continue
|
|
}
|
|
role := strings.TrimSpace(msg.Role)
|
|
if role == "" {
|
|
role = "user"
|
|
}
|
|
if b.Len() > 0 {
|
|
b.WriteString("\n")
|
|
}
|
|
b.WriteString(role)
|
|
b.WriteString(": ")
|
|
b.WriteString(content)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func responseModel(requestModel, target string) string {
|
|
if requestModel != "" {
|
|
return requestModel
|
|
}
|
|
return target
|
|
}
|
|
|
|
func httpStatusForRunError(err error) int {
|
|
if errors.Is(err, context.Canceled) {
|
|
return http.StatusRequestTimeout
|
|
}
|
|
return http.StatusBadGateway
|
|
}
|
|
|
|
func validateWorkspaceForRoute(d routeDispatch, workspace string) error {
|
|
if !d.WorkspaceRequired {
|
|
return nil
|
|
}
|
|
if strings.TrimSpace(workspace) == "" {
|
|
return fmt.Errorf("workspace is required for this model route")
|
|
}
|
|
if !filepath.IsAbs(workspace) {
|
|
return fmt.Errorf("workspace must be an absolute path")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
func writeError(w http.ResponseWriter, status int, code, message string) {
|
|
writeJSON(w, status, errorResponse{Error: errorBody{Type: code, Message: message}})
|
|
}
|