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}