package model import ( "encoding/json" "fmt" ) // openAIChatRequest is the JSON body sent to /chat/completions. Field names // match the provider protocol so the struct marshals directly. type openAIChatRequest struct { Model string `json:"model"` Messages []openAIMessage `json:"messages"` Temperature *float64 `json:"temperature,omitempty"` MaxTokens int `json:"max_tokens,omitempty"` Stream bool `json:"stream,omitempty"` StreamOptions *openAIStreamOpts `json:"stream_options,omitempty"` Tools []openAITool `json:"tools,omitempty"` // ReasoningEffort is the OpenAI-compatible reasoning strength knob. It is set // only after validation against the detected vendor's whitelist (design/04 // §3.2); an unsupported value is downgraded or dropped, never forwarded as-is. ReasoningEffort string `json:"reasoning_effort,omitempty"` } // openAIStreamOpts asks the provider to include usage in the final stream chunk. type openAIStreamOpts struct { IncludeUsage bool `json:"include_usage"` } // openAIMessage is one request message in the wire body. Content is either a // plain string (text-only) or an array of {type,text|image_url} parts. Tool // messages carry ToolCallID (required by OpenAI); assistant messages that // requested functions carry ToolCalls so the following tool messages can be // correlated. type openAIMessage struct { Role string `json:"role"` Content any `json:"content"` ToolCallID string `json:"tool_call_id,omitempty"` ToolCalls []openAIToolCall `json:"tool_calls,omitempty"` } // openAIToolCall is the wire form of one assistant tool_call in a request. OpenAI // requires each subsequent tool message to reference the id back to these calls. type openAIToolCall struct { ID string `json:"id"` Type string `json:"type"` Function openAIToolCallFn `json:"function"` } // openAITool is the wire form of a function tool schema (M2 placeholder). type openAITool struct { Type string `json:"type"` Function openAIToolFn `json:"function"` } // openAIToolFn carries the function definition. type openAIToolFn struct { Name string `json:"name"` Description string `json:"description,omitempty"` Parameters any `json:"parameters,omitempty"` } // openAIToolCallFn is the function part of a streaming tool-call fragment. type openAIToolCallFn struct { Name string `json:"name"` Arguments string `json:"arguments"` } func (a *OpenAIChatAdapter) buildRequest(req Request, stream bool) ([]byte, error) { msgs, err := buildMessages(req.Messages) if err != nil { return nil, err } // The vendor behind an OpenAI-compatible endpoint decides which // reasoning_effort tiers are legal; classify it from the resolved model (and // the base URL as a fallback) before touching the request (design/04 §3.2). vendor := reasoningVendorFor(req.Model, a.baseURL) reportUnmappedThinking(a.log, vendor, req.Model, req.Thinking) wire := openAIChatRequest{ Model: req.Model, Messages: msgs, Temperature: req.Temperature, MaxTokens: req.MaxTokens, Stream: stream, Tools: buildTools(req.Tools), ReasoningEffort: resolveReasoningEffort(a.log, vendor, req.ReasoningEffort, req.Model), } if stream { wire.StreamOptions = &openAIStreamOpts{IncludeUsage: true} } buf, err := json.Marshal(wire) if err != nil { return nil, fmt.Errorf("model: %w: marshal request: %v", ErrBadRequest, err) } return buf, nil } func buildMessages(msgs []Message) ([]openAIMessage, error) { out := make([]openAIMessage, 0, len(msgs)) for _, m := range msgs { wire, err := encodeOpenAIMessage(m) if err != nil { return nil, err } out = append(out, wire) } return out, nil } // encodeOpenAIMessage renders one normalized message in OpenAI wire form. A // RoleTool turn becomes a tool message carrying tool_call_id (required by OpenAI, // and the audit's gap); an assistant turn that advertised tool calls carries the // corresponding tool_calls array so the provider can correlate the tool messages. func encodeOpenAIMessage(m Message) (openAIMessage, error) { if m.Role == RoleTool { return openAIMessage{ Role: string(RoleTool), ToolCallID: toolMessageCallID(m), Content: messageText(m), }, nil } content, err := encodeContent(m.Content) if err != nil { return openAIMessage{}, err } return openAIMessage{Role: string(m.Role), Content: content, ToolCalls: encodeToolCalls(m.Content)}, nil } // toolMessageCallID resolves the tool_call_id for a RoleTool turn: the // message-level id, else the id carried on the first tool-result part. func toolMessageCallID(m Message) string { if m.ToolCallID != "" { return m.ToolCallID } for _, p := range m.Content { if p.ToolCallID != "" { return p.ToolCallID } } return "" } // encodeToolCalls builds the assistant tool_calls array from a turn's tool-call // parts. Arguments are serialized to JSON because OpenAI expects a string. func encodeToolCalls(parts []Content) []openAIToolCall { calls := make([]openAIToolCall, 0, len(parts)) for _, p := range parts { if p.Type != ContentTypeToolCall { continue } arguments := "{}" if len(p.ToolArgs) > 0 { if b, err := json.Marshal(p.ToolArgs); err == nil { arguments = string(b) } } calls = append(calls, openAIToolCall{ ID: p.ToolCallID, Type: "function", Function: openAIToolCallFn{Name: p.ToolCallName, Arguments: arguments}, }) } return calls } func encodeContent(parts []Content) (any, error) { if len(parts) == 0 { return "", nil } arr := make([]any, 0, len(parts)) for _, p := range parts { switch p.Type { case ContentTypeText: arr = append(arr, map[string]any{"type": "text", "text": p.Text}) case ContentTypeImage: arr = append(arr, map[string]any{ "type": "image_url", "image_url": map[string]any{"url": p.ImageURL}, }) case ContentTypeToolCall, ContentTypeToolResult: // Tool-call and tool-result parts are encoded at the message level // (tool_calls array / tool_call_id), never as text-image content. continue default: return nil, fmt.Errorf("model: %w: unsupported content type %q", ErrBadRequest, p.Type) } } if len(arr) == 0 { return "", nil } if len(arr) == 1 { if blk, ok := arr[0].(map[string]any); ok && blk["type"] == "image_url" { return arr, nil } return arr[0].(map[string]any)["text"], nil } return arr, nil } func buildTools(tools []ToolSpec) []openAITool { if len(tools) == 0 { return nil } out := make([]openAITool, 0, len(tools)) for _, t := range tools { out = append(out, openAITool{Type: "function", Function: openAIToolFn{ Name: t.Name, Description: t.Description, Parameters: t.Parameters, }}) } return out }