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": 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}}) }