diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index e74cf89..e2cd351 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -97,6 +97,11 @@ where fixture migration remains. No production construction supplies the removed ## Stage 2: Encode One Root Wire Representation +**Completed:** Requests now build the provider payload directly, with structured +dispatch evidence derived from that payload. Go 1.27.3 `go test ./internal/llm +./internal/generation` passed, including mapping, presence, payload freezing, +session, structured-output, and encoding-failure tests. + **Finding:** 2. **Files:** `internal/llm/openai_compatible_client.go` and request-mapping tests. 1. Replace the root `domain request -> openAIChatRequest -> map` sequence with diff --git a/internal/llm/openai_compatible_client.go b/internal/llm/openai_compatible_client.go index 986101c..129c921 100644 --- a/internal/llm/openai_compatible_client.go +++ b/internal/llm/openai_compatible_client.go @@ -125,12 +125,7 @@ func (c *OpenAICompatibleClient) PrepareGeneration(req domain.GenerateRequest) ( return nil, fmt.Errorf("%w: invalid endpoint path: %v", ErrInvalidRequest, err) } - wireReq, err := openAIChatRequestFromGenerateRequest(req) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrInvalidRequest, err) - } - - wirePayload, err := openAIChatRequestPayload(wireReq) + wirePayload, err := openAIChatRequestPayload(req) if err != nil { return nil, fmt.Errorf("%w: %v", ErrInvalidRequest, err) } @@ -159,7 +154,7 @@ func (c *OpenAICompatibleClient) PrepareGeneration(req domain.GenerateRequest) ( return &PreparedGeneration{ endpoint: endpoint, payload: payload, apiKey: apiKey, timeoutSeconds: req.Target.TimeoutSeconds, httpClient: httpClient, - structuredOutputRequested: wireReq.ResponseFormat != nil, + structuredOutputRequested: wirePayload["response_format"] != nil, }, nil } @@ -425,96 +420,52 @@ func openAIChatResponseTooLargeError() error { ) } -func openAIChatRequestFromGenerateRequest(req domain.GenerateRequest) (openAIChatRequest, error) { +func openAIChatRequestPayload(req domain.GenerateRequest) (map[string]any, error) { model := strings.TrimSpace(req.Target.Model) if model == "" { - return openAIChatRequest{}, errors.New("model is required") + return nil, errors.New("model is required") } - wireReq := openAIChatRequest{ - Model: model, - } sessionID, err := domain.NormalizeSessionID(req.Prompt.SessionID) if err != nil { - return openAIChatRequest{}, err + return nil, err } - wireReq.SessionID = sessionID - wireReq.Messages = make([]openAIChatRequestMessage, 0, len(req.Prompt.Messages)) + messages := make([]openAIChatRequestMessage, 0, len(req.Prompt.Messages)) for _, msg := range req.Prompt.Messages { - wireReq.Messages = append(wireReq.Messages, openAIChatRequestMessageFromRenderedMessage(msg)) + messages = append(messages, openAIChatRequestMessageFromRenderedMessage(msg)) } + out := map[string]any{"model": model, "messages": messages} + if sessionID != "" { + out["session_id"] = sessionID + } if req.Target.Temperature != 0 || req.TargetPresence.Temperature { - wireReq.Temperature = &req.Target.Temperature + out["temperature"] = req.Target.Temperature } if req.Target.MaxTokens != 0 || req.TargetPresence.MaxTokens { - wireReq.MaxTokens = &req.Target.MaxTokens + out["max_tokens"] = req.Target.MaxTokens } if req.Target.TopP != 0 || req.TargetPresence.TopP { - wireReq.TopP = &req.Target.TopP + out["top_p"] = req.Target.TopP } if strings.TrimSpace(req.Target.ServiceTier) != "" { - wireReq.ServiceTier = req.Target.ServiceTier + out["service_tier"] = req.Target.ServiceTier } if strings.TrimSpace(req.Target.ReasoningEffort) != "" { - wireReq.ReasoningEffort = req.Target.ReasoningEffort - } - if len(req.Target.ExtraParams) > 0 { - wireReq.ExtraParams = req.Target.ExtraParams + out["reasoning_effort"] = req.Target.ReasoningEffort } if req.StructuredOutput != nil { responseFormat, err := toOpenAIResponseFormat(req.StructuredOutput) if err != nil { - return openAIChatRequest{}, err + return nil, err + } + if responseFormat != nil { + out["response_format"] = responseFormat } - wireReq.ResponseFormat = responseFormat } - return wireReq, nil -} - -type openAIChatRequest struct { - Model string `json:"model"` - SessionID string `json:"session_id,omitempty"` - Messages []openAIChatRequestMessage `json:"messages"` - Temperature *float64 `json:"temperature,omitempty"` - MaxTokens *int `json:"max_tokens,omitempty"` - TopP *float64 `json:"top_p,omitempty"` - ServiceTier string `json:"service_tier,omitempty"` - ReasoningEffort string `json:"reasoning_effort,omitempty"` - ResponseFormat *openAIResponseFormat `json:"response_format,omitempty"` - ExtraParams map[string]any `json:"-"` -} - -func openAIChatRequestPayload(req openAIChatRequest) (map[string]any, error) { - out := map[string]any{ - "model": req.Model, - "messages": req.Messages, - } - if req.SessionID != "" { - out["session_id"] = req.SessionID - } - if req.Temperature != nil { - out["temperature"] = *req.Temperature - } - if req.MaxTokens != nil { - out["max_tokens"] = *req.MaxTokens - } - if req.TopP != nil { - out["top_p"] = *req.TopP - } - if req.ServiceTier != "" { - out["service_tier"] = req.ServiceTier - } - if req.ReasoningEffort != "" { - out["reasoning_effort"] = req.ReasoningEffort - } - if req.ResponseFormat != nil { - out["response_format"] = req.ResponseFormat - } - - for key, value := range req.ExtraParams { + for key, value := range req.Target.ExtraParams { if key == "" { return nil, errors.New("extra_params key must not be empty") }