language_model_hooks.go

  1package openaicompat
  2
  3import (
  4	"encoding/base64"
  5	"encoding/json"
  6	"fmt"
  7	"strings"
  8
  9	"charm.land/fantasy"
 10	"charm.land/fantasy/providers/openai"
 11	openaisdk "github.com/charmbracelet/openai-go"
 12	"github.com/charmbracelet/openai-go/packages/param"
 13	"github.com/charmbracelet/openai-go/shared"
 14)
 15
 16const reasoningStartedCtx = "reasoning_started"
 17
 18// PrepareCallFunc prepares the call for the language model.
 19func PrepareCallFunc(model fantasy.LanguageModel, params *openaisdk.ChatCompletionNewParams, call fantasy.Call) ([]fantasy.CallWarning, error) {
 20	providerOptions := &ProviderOptions{}
 21	if v, ok := call.ProviderOptions[model.Provider()]; ok {
 22		providerOptions, ok = v.(*ProviderOptions)
 23		if !ok {
 24			return nil, &fantasy.Error{Title: "invalid argument", Message: "openai-compat provider options should be *openaicompat.ProviderOptions"}
 25		}
 26	}
 27
 28	if providerOptions.ReasoningEffort != nil {
 29		switch *providerOptions.ReasoningEffort {
 30		case openai.ReasoningEffortNone:
 31			params.ReasoningEffort = shared.ReasoningEffortNone
 32		case openai.ReasoningEffortMinimal:
 33			params.ReasoningEffort = shared.ReasoningEffortMinimal
 34		case openai.ReasoningEffortLow:
 35			params.ReasoningEffort = shared.ReasoningEffortLow
 36		case openai.ReasoningEffortMedium:
 37			params.ReasoningEffort = shared.ReasoningEffortMedium
 38		case openai.ReasoningEffortHigh:
 39			params.ReasoningEffort = shared.ReasoningEffortHigh
 40		case openai.ReasoningEffortXHigh:
 41			params.ReasoningEffort = shared.ReasoningEffortXhigh
 42		default:
 43			return nil, fmt.Errorf("reasoning model `%s` not supported", *providerOptions.ReasoningEffort)
 44		}
 45	}
 46
 47	if providerOptions.User != nil {
 48		params.User = param.NewOpt(*providerOptions.User)
 49	}
 50	if len(providerOptions.ExtraBody) > 0 {
 51		params.SetExtraFields(providerOptions.ExtraBody)
 52	}
 53	return nil, nil
 54}
 55
 56// ExtraContentFunc adds extra content to the response.
 57func ExtraContentFunc(choice openaisdk.ChatCompletionChoice) []fantasy.Content {
 58	var content []fantasy.Content
 59	reasoningData := ReasoningData{}
 60	err := json.Unmarshal([]byte(choice.Message.RawJSON()), &reasoningData)
 61	if err != nil {
 62		return content
 63	}
 64	if rc := reasoningData.GetReasoningContent(); rc != "" {
 65		content = append(content, fantasy.ReasoningContent{
 66			Text: rc,
 67		})
 68	}
 69	return content
 70}
 71
 72func extractReasoningContext(ctx map[string]any) bool {
 73	reasoningStarted, ok := ctx[reasoningStartedCtx]
 74	if !ok {
 75		return false
 76	}
 77	b, ok := reasoningStarted.(bool)
 78	if !ok {
 79		return false
 80	}
 81	return b
 82}
 83
 84// StreamExtraFunc handles extra functionality for streaming responses.
 85func StreamExtraFunc(chunk openaisdk.ChatCompletionChunk, yield func(fantasy.StreamPart) bool, ctx map[string]any) (map[string]any, bool) {
 86	if len(chunk.Choices) == 0 {
 87		return ctx, true
 88	}
 89
 90	reasoningStarted := extractReasoningContext(ctx)
 91
 92	for inx, choice := range chunk.Choices {
 93		reasoningData := ReasoningData{}
 94		err := json.Unmarshal([]byte(choice.Delta.RawJSON()), &reasoningData)
 95		if err != nil {
 96			yield(fantasy.StreamPart{
 97				Type:  fantasy.StreamPartTypeError,
 98				Error: &fantasy.Error{Title: "stream error", Message: "error unmarshalling delta", Cause: err},
 99			})
100			return ctx, false
101		}
102
103		emitEvent := func(reasoningContent string) bool {
104			if !reasoningStarted {
105				shouldContinue := yield(fantasy.StreamPart{
106					Type: fantasy.StreamPartTypeReasoningStart,
107					ID:   fmt.Sprintf("%d", inx),
108				})
109				if !shouldContinue {
110					return false
111				}
112			}
113
114			return yield(fantasy.StreamPart{
115				Type:  fantasy.StreamPartTypeReasoningDelta,
116				ID:    fmt.Sprintf("%d", inx),
117				Delta: reasoningContent,
118			})
119		}
120		if rc := reasoningData.GetReasoningContent(); rc != "" {
121			if !reasoningStarted {
122				ctx[reasoningStartedCtx] = true
123			}
124			return ctx, emitEvent(rc)
125		}
126		if reasoningStarted && (choice.Delta.Content != "" || len(choice.Delta.ToolCalls) > 0) {
127			ctx[reasoningStartedCtx] = false
128			return ctx, yield(fantasy.StreamPart{
129				Type: fantasy.StreamPartTypeReasoningEnd,
130				ID:   fmt.Sprintf("%d", inx),
131			})
132		}
133	}
134	return ctx, true
135}
136
137// ToPromptFunc converts a fantasy prompt to OpenAI format with reasoning support.
138// It handles fantasy.ContentTypeReasoning in assistant messages by adding the
139// reasoning_content field to the message JSON.
140func ToPromptFunc(prompt fantasy.Prompt, _, _ string) ([]openaisdk.ChatCompletionMessageParamUnion, []fantasy.CallWarning) {
141	var messages []openaisdk.ChatCompletionMessageParamUnion
142	var warnings []fantasy.CallWarning
143	hasReasoning := false
144
145	for _, msg := range prompt {
146		switch msg.Role {
147		case fantasy.MessageRoleSystem:
148			var systemPromptParts []string
149			for _, c := range msg.Content {
150				if c.GetType() != fantasy.ContentTypeText {
151					warnings = append(warnings, fantasy.CallWarning{
152						Type:    fantasy.CallWarningTypeOther,
153						Message: "system prompt can only have text content",
154					})
155					continue
156				}
157				textPart, ok := fantasy.AsContentType[fantasy.TextPart](c)
158				if !ok {
159					warnings = append(warnings, fantasy.CallWarning{
160						Type:    fantasy.CallWarningTypeOther,
161						Message: "system prompt text part does not have the right type",
162					})
163					continue
164				}
165				text := textPart.Text
166				if strings.TrimSpace(text) != "" {
167					systemPromptParts = append(systemPromptParts, textPart.Text)
168				}
169			}
170			if len(systemPromptParts) == 0 {
171				warnings = append(warnings, fantasy.CallWarning{
172					Type:    fantasy.CallWarningTypeOther,
173					Message: "system prompt has no text parts",
174				})
175				continue
176			}
177			messages = append(messages, openaisdk.SystemMessage(strings.Join(systemPromptParts, "\n")))
178		case fantasy.MessageRoleUser:
179			// simple user message just text content
180			if len(msg.Content) == 1 && msg.Content[0].GetType() == fantasy.ContentTypeText {
181				textPart, ok := fantasy.AsContentType[fantasy.TextPart](msg.Content[0])
182				if !ok {
183					warnings = append(warnings, fantasy.CallWarning{
184						Type:    fantasy.CallWarningTypeOther,
185						Message: "user message text part does not have the right type",
186					})
187					continue
188				}
189				messages = append(messages, openaisdk.UserMessage(textPart.Text))
190				continue
191			}
192			// text content and attachments
193			var content []openaisdk.ChatCompletionContentPartUnionParam
194			for _, c := range msg.Content {
195				switch c.GetType() {
196				case fantasy.ContentTypeText:
197					textPart, ok := fantasy.AsContentType[fantasy.TextPart](c)
198					if !ok {
199						warnings = append(warnings, fantasy.CallWarning{
200							Type:    fantasy.CallWarningTypeOther,
201							Message: "user message text part does not have the right type",
202						})
203						continue
204					}
205					content = append(content, openaisdk.ChatCompletionContentPartUnionParam{
206						OfText: &openaisdk.ChatCompletionContentPartTextParam{
207							Text: textPart.Text,
208						},
209					})
210				case fantasy.ContentTypeFile:
211					filePart, ok := fantasy.AsContentType[fantasy.FilePart](c)
212					if !ok {
213						warnings = append(warnings, fantasy.CallWarning{
214							Type:    fantasy.CallWarningTypeOther,
215							Message: "user message file part does not have the right type",
216						})
217						continue
218					}
219
220					switch {
221					case strings.HasPrefix(filePart.MediaType, "text/"):
222						base64Encoded := base64.StdEncoding.EncodeToString(filePart.Data)
223						documentBlock := openaisdk.ChatCompletionContentPartFileFileParam{
224							FileData: param.NewOpt(base64Encoded),
225						}
226						content = append(content, openaisdk.FileContentPart(documentBlock))
227
228					case strings.HasPrefix(filePart.MediaType, "image/"):
229						// Handle image files
230						base64Encoded := base64.StdEncoding.EncodeToString(filePart.Data)
231						data := "data:" + filePart.MediaType + ";base64," + base64Encoded
232						imageURL := openaisdk.ChatCompletionContentPartImageImageURLParam{URL: data}
233
234						// Check for provider-specific options like image detail
235						if providerOptions, ok := filePart.ProviderOptions[openai.Name]; ok {
236							if detail, ok := providerOptions.(*openai.ProviderFileOptions); ok {
237								imageURL.Detail = detail.ImageDetail
238							}
239						}
240
241						imageBlock := openaisdk.ChatCompletionContentPartImageParam{ImageURL: imageURL}
242						content = append(content, openaisdk.ChatCompletionContentPartUnionParam{OfImageURL: &imageBlock})
243
244					case filePart.MediaType == "audio/wav":
245						// Handle WAV audio files
246						base64Encoded := base64.StdEncoding.EncodeToString(filePart.Data)
247						audioBlock := openaisdk.ChatCompletionContentPartInputAudioParam{
248							InputAudio: openaisdk.ChatCompletionContentPartInputAudioInputAudioParam{
249								Data:   base64Encoded,
250								Format: "wav",
251							},
252						}
253						content = append(content, openaisdk.ChatCompletionContentPartUnionParam{OfInputAudio: &audioBlock})
254
255					case filePart.MediaType == "audio/mpeg" || filePart.MediaType == "audio/mp3":
256						// Handle MP3 audio files
257						base64Encoded := base64.StdEncoding.EncodeToString(filePart.Data)
258						audioBlock := openaisdk.ChatCompletionContentPartInputAudioParam{
259							InputAudio: openaisdk.ChatCompletionContentPartInputAudioInputAudioParam{
260								Data:   base64Encoded,
261								Format: "mp3",
262							},
263						}
264						content = append(content, openaisdk.ChatCompletionContentPartUnionParam{OfInputAudio: &audioBlock})
265
266					case filePart.MediaType == "application/pdf":
267						// Handle PDF files
268						dataStr := string(filePart.Data)
269
270						// Check if data looks like a file ID (starts with "file-")
271						if strings.HasPrefix(dataStr, "file-") {
272							fileBlock := openaisdk.ChatCompletionContentPartFileParam{
273								File: openaisdk.ChatCompletionContentPartFileFileParam{
274									FileID: param.NewOpt(dataStr),
275								},
276							}
277							content = append(content, openaisdk.ChatCompletionContentPartUnionParam{OfFile: &fileBlock})
278						} else {
279							// Handle as base64 data
280							base64Encoded := base64.StdEncoding.EncodeToString(filePart.Data)
281							data := "data:application/pdf;base64," + base64Encoded
282
283							filename := filePart.Filename
284							if filename == "" {
285								// Generate default filename based on content index
286								filename = fmt.Sprintf("part-%d.pdf", len(content))
287							}
288
289							fileBlock := openaisdk.ChatCompletionContentPartFileParam{
290								File: openaisdk.ChatCompletionContentPartFileFileParam{
291									Filename: param.NewOpt(filename),
292									FileData: param.NewOpt(data),
293								},
294							}
295							content = append(content, openaisdk.ChatCompletionContentPartUnionParam{OfFile: &fileBlock})
296						}
297
298					default:
299						warnings = append(warnings, fantasy.CallWarning{
300							Type:    fantasy.CallWarningTypeOther,
301							Message: fmt.Sprintf("file part media type %s not supported", filePart.MediaType),
302						})
303					}
304				}
305			}
306			if !hasVisibleCompatUserContent(content) {
307				warnings = append(warnings, fantasy.CallWarning{
308					Type:    fantasy.CallWarningTypeOther,
309					Message: "dropping empty user message (contains neither user-facing content nor tool results)",
310				})
311				continue
312			}
313			messages = append(messages, openaisdk.UserMessage(content))
314		case fantasy.MessageRoleAssistant:
315			// simple assistant message just text content
316			if len(msg.Content) == 1 && msg.Content[0].GetType() == fantasy.ContentTypeText {
317				textPart, ok := fantasy.AsContentType[fantasy.TextPart](msg.Content[0])
318				if !ok {
319					warnings = append(warnings, fantasy.CallWarning{
320						Type:    fantasy.CallWarningTypeOther,
321						Message: "assistant message text part does not have the right type",
322					})
323					continue
324				}
325				messages = append(messages, openaisdk.AssistantMessage(textPart.Text))
326				continue
327			}
328			assistantMsg := openaisdk.ChatCompletionAssistantMessageParam{
329				Role: "assistant",
330			}
331			var reasoningText string
332			for _, c := range msg.Content {
333				switch c.GetType() {
334				case fantasy.ContentTypeText:
335					textPart, ok := fantasy.AsContentType[fantasy.TextPart](c)
336					if !ok {
337						warnings = append(warnings, fantasy.CallWarning{
338							Type:    fantasy.CallWarningTypeOther,
339							Message: "assistant message text part does not have the right type",
340						})
341						continue
342					}
343					assistantMsg.Content = openaisdk.ChatCompletionAssistantMessageParamContentUnion{
344						OfString: param.NewOpt(textPart.Text),
345					}
346				case fantasy.ContentTypeReasoning:
347					reasoningPart, ok := fantasy.AsContentType[fantasy.ReasoningPart](c)
348					if !ok {
349						warnings = append(warnings, fantasy.CallWarning{
350							Type:    fantasy.CallWarningTypeOther,
351							Message: "assistant message reasoning part does not have the right type",
352						})
353						continue
354					}
355					reasoningText = reasoningPart.Text
356					hasReasoning = true
357				case fantasy.ContentTypeToolCall:
358					toolCallPart, ok := fantasy.AsContentType[fantasy.ToolCallPart](c)
359					if !ok {
360						warnings = append(warnings, fantasy.CallWarning{
361							Type:    fantasy.CallWarningTypeOther,
362							Message: "assistant message tool part does not have the right type",
363						})
364						continue
365					}
366					assistantMsg.ToolCalls = append(assistantMsg.ToolCalls,
367						openaisdk.ChatCompletionMessageToolCallUnionParam{
368							OfFunction: &openaisdk.ChatCompletionMessageFunctionToolCallParam{
369								ID:   toolCallPart.ToolCallID,
370								Type: "function",
371								Function: openaisdk.ChatCompletionMessageFunctionToolCallFunctionParam{
372									Name:      toolCallPart.ToolName,
373									Arguments: toolCallPart.Input,
374								},
375							},
376						})
377				}
378			}
379			// Add reasoning_content field if present, or if thinking is enabled
380			// and the message has tool calls (some providers like Kimi require
381			// reasoning_content on all assistant messages when thinking is enabled).
382			if reasoningText != "" || (hasReasoning && len(assistantMsg.ToolCalls) > 0) {
383				assistantMsg.SetExtraFields(map[string]any{
384					"reasoning_content": reasoningText,
385				})
386			}
387			if !hasVisibleCompatAssistantContent(&assistantMsg) {
388				warnings = append(warnings, fantasy.CallWarning{
389					Type:    fantasy.CallWarningTypeOther,
390					Message: "dropping empty assistant message (contains neither user-facing content nor tool calls)",
391				})
392				continue
393			}
394			messages = append(messages, openaisdk.ChatCompletionMessageParamUnion{
395				OfAssistant: &assistantMsg,
396			})
397		case fantasy.MessageRoleTool:
398			for _, c := range msg.Content {
399				if c.GetType() != fantasy.ContentTypeToolResult {
400					warnings = append(warnings, fantasy.CallWarning{
401						Type:    fantasy.CallWarningTypeOther,
402						Message: "tool message can only have tool result content",
403					})
404					continue
405				}
406
407				toolResultPart, ok := fantasy.AsContentType[fantasy.ToolResultPart](c)
408				if !ok {
409					warnings = append(warnings, fantasy.CallWarning{
410						Type:    fantasy.CallWarningTypeOther,
411						Message: "tool message result part does not have the right type",
412					})
413					continue
414				}
415
416				switch toolResultPart.Output.GetType() {
417				case fantasy.ToolResultContentTypeText:
418					output, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentText](toolResultPart.Output)
419					if !ok {
420						warnings = append(warnings, fantasy.CallWarning{
421							Type:    fantasy.CallWarningTypeOther,
422							Message: "tool result output does not have the right type",
423						})
424						continue
425					}
426					messages = append(messages, openaisdk.ToolMessage(output.Text, toolResultPart.ToolCallID))
427				case fantasy.ToolResultContentTypeError:
428					output, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentError](toolResultPart.Output)
429					if !ok {
430						warnings = append(warnings, fantasy.CallWarning{
431							Type:    fantasy.CallWarningTypeOther,
432							Message: "tool result output does not have the right type",
433						})
434						continue
435					}
436					messages = append(messages, openaisdk.ToolMessage(output.Error.Error(), toolResultPart.ToolCallID))
437				}
438			}
439		}
440	}
441	return messages, warnings
442}
443
444func hasVisibleCompatUserContent(content []openaisdk.ChatCompletionContentPartUnionParam) bool {
445	for _, part := range content {
446		if part.OfText != nil || part.OfImageURL != nil || part.OfInputAudio != nil || part.OfFile != nil {
447			return true
448		}
449	}
450	return false
451}
452
453func hasVisibleCompatAssistantContent(msg *openaisdk.ChatCompletionAssistantMessageParam) bool {
454	// Check if there's text content
455	if !param.IsOmitted(msg.Content.OfString) || len(msg.Content.OfArrayOfContentParts) > 0 {
456		return true
457	}
458	// Check if there are tool calls
459	if len(msg.ToolCalls) > 0 {
460		return true
461	}
462	return false
463}