1package openai
2
3import (
4 "context"
5 "encoding/base64"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "strings"
11
12 "charm.land/fantasy"
13 xjson "github.com/charmbracelet/x/json"
14 "github.com/google/uuid"
15 "github.com/openai/openai-go/v2"
16 "github.com/openai/openai-go/v2/packages/param"
17 "github.com/openai/openai-go/v2/shared"
18)
19
20type languageModel struct {
21 provider string
22 modelID string
23 client openai.Client
24 prepareCallFunc LanguageModelPrepareCallFunc
25 mapFinishReasonFunc LanguageModelMapFinishReasonFunc
26 extraContentFunc LanguageModelExtraContentFunc
27 usageFunc LanguageModelUsageFunc
28 streamUsageFunc LanguageModelStreamUsageFunc
29 streamExtraFunc LanguageModelStreamExtraFunc
30 streamProviderMetadataFunc LanguageModelStreamProviderMetadataFunc
31}
32
33type LanguageModelOption = func(*languageModel)
34
35func WithLanguageModelPrepareCallFunc(fn LanguageModelPrepareCallFunc) LanguageModelOption {
36 return func(l *languageModel) {
37 l.prepareCallFunc = fn
38 }
39}
40
41func WithLanguageModelMapFinishReasonFunc(fn LanguageModelMapFinishReasonFunc) LanguageModelOption {
42 return func(l *languageModel) {
43 l.mapFinishReasonFunc = fn
44 }
45}
46
47func WithLanguageModelExtraContentFunc(fn LanguageModelExtraContentFunc) LanguageModelOption {
48 return func(l *languageModel) {
49 l.extraContentFunc = fn
50 }
51}
52
53func WithLanguageModelStreamExtraFunc(fn LanguageModelStreamExtraFunc) LanguageModelOption {
54 return func(l *languageModel) {
55 l.streamExtraFunc = fn
56 }
57}
58
59func WithLanguageModelUsageFunc(fn LanguageModelUsageFunc) LanguageModelOption {
60 return func(l *languageModel) {
61 l.usageFunc = fn
62 }
63}
64
65func WithLanguageModelStreamUsageFunc(fn LanguageModelStreamUsageFunc) LanguageModelOption {
66 return func(l *languageModel) {
67 l.streamUsageFunc = fn
68 }
69}
70
71func newLanguageModel(modelID string, provider string, client openai.Client, opts ...LanguageModelOption) languageModel {
72 model := languageModel{
73 modelID: modelID,
74 provider: provider,
75 client: client,
76 prepareCallFunc: DefaultPrepareCallFunc,
77 mapFinishReasonFunc: DefaultMapFinishReasonFunc,
78 usageFunc: DefaultUsageFunc,
79 streamUsageFunc: DefaultStreamUsageFunc,
80 streamProviderMetadataFunc: DefaultStreamProviderMetadataFunc,
81 }
82
83 for _, o := range opts {
84 o(&model)
85 }
86 return model
87}
88
89type streamToolCall struct {
90 id string
91 name string
92 arguments string
93 hasFinished bool
94}
95
96// Model implements fantasy.LanguageModel.
97func (o languageModel) Model() string {
98 return o.modelID
99}
100
101// Provider implements fantasy.LanguageModel.
102func (o languageModel) Provider() string {
103 return o.provider
104}
105
106func (o languageModel) prepareParams(call fantasy.Call) (*openai.ChatCompletionNewParams, []fantasy.CallWarning, error) {
107 params := &openai.ChatCompletionNewParams{}
108 messages, warnings := toPrompt(call.Prompt)
109 if call.TopK != nil {
110 warnings = append(warnings, fantasy.CallWarning{
111 Type: fantasy.CallWarningTypeUnsupportedSetting,
112 Setting: "top_k",
113 })
114 }
115
116 if call.MaxOutputTokens != nil {
117 params.MaxTokens = param.NewOpt(*call.MaxOutputTokens)
118 }
119 if call.Temperature != nil {
120 params.Temperature = param.NewOpt(*call.Temperature)
121 }
122 if call.TopP != nil {
123 params.TopP = param.NewOpt(*call.TopP)
124 }
125 if call.FrequencyPenalty != nil {
126 params.FrequencyPenalty = param.NewOpt(*call.FrequencyPenalty)
127 }
128 if call.PresencePenalty != nil {
129 params.PresencePenalty = param.NewOpt(*call.PresencePenalty)
130 }
131
132 if isReasoningModel(o.modelID) {
133 // remove unsupported settings for reasoning models
134 // see https://platform.openai.com/docs/guides/reasoning#limitations
135 if call.Temperature != nil {
136 params.Temperature = param.Opt[float64]{}
137 warnings = append(warnings, fantasy.CallWarning{
138 Type: fantasy.CallWarningTypeUnsupportedSetting,
139 Setting: "temperature",
140 Details: "temperature is not supported for reasoning models",
141 })
142 }
143 if call.TopP != nil {
144 params.TopP = param.Opt[float64]{}
145 warnings = append(warnings, fantasy.CallWarning{
146 Type: fantasy.CallWarningTypeUnsupportedSetting,
147 Setting: "TopP",
148 Details: "TopP is not supported for reasoning models",
149 })
150 }
151 if call.FrequencyPenalty != nil {
152 params.FrequencyPenalty = param.Opt[float64]{}
153 warnings = append(warnings, fantasy.CallWarning{
154 Type: fantasy.CallWarningTypeUnsupportedSetting,
155 Setting: "FrequencyPenalty",
156 Details: "FrequencyPenalty is not supported for reasoning models",
157 })
158 }
159 if call.PresencePenalty != nil {
160 params.PresencePenalty = param.Opt[float64]{}
161 warnings = append(warnings, fantasy.CallWarning{
162 Type: fantasy.CallWarningTypeUnsupportedSetting,
163 Setting: "PresencePenalty",
164 Details: "PresencePenalty is not supported for reasoning models",
165 })
166 }
167
168 // reasoning models use max_completion_tokens instead of max_tokens
169 if call.MaxOutputTokens != nil {
170 if !params.MaxCompletionTokens.Valid() {
171 params.MaxCompletionTokens = param.NewOpt(*call.MaxOutputTokens)
172 }
173 params.MaxTokens = param.Opt[int64]{}
174 }
175 }
176
177 // Handle search preview models
178 if isSearchPreviewModel(o.modelID) {
179 if call.Temperature != nil {
180 params.Temperature = param.Opt[float64]{}
181 warnings = append(warnings, fantasy.CallWarning{
182 Type: fantasy.CallWarningTypeUnsupportedSetting,
183 Setting: "temperature",
184 Details: "temperature is not supported for the search preview models and has been removed.",
185 })
186 }
187 }
188
189 optionsWarnings, err := o.prepareCallFunc(o, params, call)
190 if err != nil {
191 return nil, nil, err
192 }
193
194 if len(optionsWarnings) > 0 {
195 warnings = append(warnings, optionsWarnings...)
196 }
197
198 params.Messages = messages
199 params.Model = o.modelID
200
201 if len(call.Tools) > 0 {
202 tools, toolChoice, toolWarnings := toOpenAiTools(call.Tools, call.ToolChoice)
203 params.Tools = tools
204 if toolChoice != nil {
205 params.ToolChoice = *toolChoice
206 }
207 warnings = append(warnings, toolWarnings...)
208 }
209 return params, warnings, nil
210}
211
212func (o languageModel) handleError(err error) error {
213 var apiErr *openai.Error
214 if errors.As(err, &apiErr) {
215 requestDump := apiErr.DumpRequest(true)
216 responseDump := apiErr.DumpResponse(true)
217 headers := map[string]string{}
218 for k, h := range apiErr.Response.Header {
219 v := h[len(h)-1]
220 headers[strings.ToLower(k)] = v
221 }
222 return fantasy.NewAPICallError(
223 apiErr.Message,
224 apiErr.Request.URL.String(),
225 string(requestDump),
226 apiErr.StatusCode,
227 headers,
228 string(responseDump),
229 apiErr,
230 false,
231 )
232 }
233 return err
234}
235
236// Generate implements fantasy.LanguageModel.
237func (o languageModel) Generate(ctx context.Context, call fantasy.Call) (*fantasy.Response, error) {
238 params, warnings, err := o.prepareParams(call)
239 if err != nil {
240 return nil, err
241 }
242 response, err := o.client.Chat.Completions.New(ctx, *params)
243 if err != nil {
244 return nil, o.handleError(err)
245 }
246
247 if len(response.Choices) == 0 {
248 return nil, errors.New("no response generated")
249 }
250 choice := response.Choices[0]
251 content := make([]fantasy.Content, 0, 1+len(choice.Message.ToolCalls)+len(choice.Message.Annotations))
252 text := choice.Message.Content
253 if text != "" {
254 content = append(content, fantasy.TextContent{
255 Text: text,
256 })
257 }
258 if o.extraContentFunc != nil {
259 extraContent := o.extraContentFunc(choice)
260 content = append(content, extraContent...)
261 }
262 for _, tc := range choice.Message.ToolCalls {
263 toolCallID := tc.ID
264 content = append(content, fantasy.ToolCallContent{
265 ProviderExecuted: false, // TODO: update when handling other tools
266 ToolCallID: toolCallID,
267 ToolName: tc.Function.Name,
268 Input: tc.Function.Arguments,
269 })
270 }
271 // Handle annotations/citations
272 for _, annotation := range choice.Message.Annotations {
273 if annotation.Type == "url_citation" {
274 content = append(content, fantasy.SourceContent{
275 SourceType: fantasy.SourceTypeURL,
276 ID: uuid.NewString(),
277 URL: annotation.URLCitation.URL,
278 Title: annotation.URLCitation.Title,
279 })
280 }
281 }
282
283 usage, providerMetadata := o.usageFunc(*response)
284
285 mappedFinishReason := o.mapFinishReasonFunc(choice.FinishReason)
286 if len(choice.Message.ToolCalls) > 0 {
287 mappedFinishReason = fantasy.FinishReasonToolCalls
288 }
289 return &fantasy.Response{
290 Content: content,
291 Usage: usage,
292 FinishReason: mappedFinishReason,
293 ProviderMetadata: fantasy.ProviderMetadata{
294 Name: providerMetadata,
295 },
296 Warnings: warnings,
297 }, nil
298}
299
300// Stream implements fantasy.LanguageModel.
301func (o languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.StreamResponse, error) {
302 params, warnings, err := o.prepareParams(call)
303 if err != nil {
304 return nil, err
305 }
306
307 params.StreamOptions = openai.ChatCompletionStreamOptionsParam{
308 IncludeUsage: openai.Bool(true),
309 }
310
311 stream := o.client.Chat.Completions.NewStreaming(ctx, *params)
312 isActiveText := false
313 toolCalls := make(map[int64]streamToolCall)
314
315 // Build provider metadata for streaming
316 providerMetadata := fantasy.ProviderMetadata{
317 Name: &ProviderMetadata{},
318 }
319 acc := openai.ChatCompletionAccumulator{}
320 extraContext := make(map[string]any)
321 var usage fantasy.Usage
322 var finishReason string
323 return func(yield func(fantasy.StreamPart) bool) {
324 if len(warnings) > 0 {
325 if !yield(fantasy.StreamPart{
326 Type: fantasy.StreamPartTypeWarnings,
327 Warnings: warnings,
328 }) {
329 return
330 }
331 }
332 for stream.Next() {
333 chunk := stream.Current()
334 acc.AddChunk(chunk)
335 usage, providerMetadata = o.streamUsageFunc(chunk, extraContext, providerMetadata)
336 if len(chunk.Choices) == 0 {
337 continue
338 }
339 for _, choice := range chunk.Choices {
340 if choice.FinishReason != "" {
341 finishReason = choice.FinishReason
342 }
343 switch {
344 case choice.Delta.Content != "":
345 if !isActiveText {
346 isActiveText = true
347 if !yield(fantasy.StreamPart{
348 Type: fantasy.StreamPartTypeTextStart,
349 ID: "0",
350 }) {
351 return
352 }
353 }
354 if !yield(fantasy.StreamPart{
355 Type: fantasy.StreamPartTypeTextDelta,
356 ID: "0",
357 Delta: choice.Delta.Content,
358 }) {
359 return
360 }
361 case len(choice.Delta.ToolCalls) > 0:
362 if isActiveText {
363 isActiveText = false
364 if !yield(fantasy.StreamPart{
365 Type: fantasy.StreamPartTypeTextEnd,
366 ID: "0",
367 }) {
368 return
369 }
370 }
371
372 for _, toolCallDelta := range choice.Delta.ToolCalls {
373 if existingToolCall, ok := toolCalls[toolCallDelta.Index]; ok {
374 if existingToolCall.hasFinished {
375 continue
376 }
377 if toolCallDelta.Function.Arguments != "" {
378 existingToolCall.arguments += toolCallDelta.Function.Arguments
379 }
380 if !yield(fantasy.StreamPart{
381 Type: fantasy.StreamPartTypeToolInputDelta,
382 ID: existingToolCall.id,
383 Delta: toolCallDelta.Function.Arguments,
384 }) {
385 return
386 }
387 toolCalls[toolCallDelta.Index] = existingToolCall
388 if xjson.IsValid(existingToolCall.arguments) {
389 if !yield(fantasy.StreamPart{
390 Type: fantasy.StreamPartTypeToolInputEnd,
391 ID: existingToolCall.id,
392 }) {
393 return
394 }
395
396 if !yield(fantasy.StreamPart{
397 Type: fantasy.StreamPartTypeToolCall,
398 ID: existingToolCall.id,
399 ToolCallName: existingToolCall.name,
400 ToolCallInput: existingToolCall.arguments,
401 }) {
402 return
403 }
404 existingToolCall.hasFinished = true
405 toolCalls[toolCallDelta.Index] = existingToolCall
406 }
407 } else {
408 // Does not exist
409 var err error
410 if toolCallDelta.Type != "function" {
411 err = fantasy.NewInvalidResponseDataError(toolCallDelta, "Expected 'function' type.")
412 }
413 if toolCallDelta.ID == "" {
414 err = fantasy.NewInvalidResponseDataError(toolCallDelta, "Expected 'id' to be a string.")
415 }
416 if toolCallDelta.Function.Name == "" {
417 err = fantasy.NewInvalidResponseDataError(toolCallDelta, "Expected 'function.name' to be a string.")
418 }
419 if err != nil {
420 yield(fantasy.StreamPart{
421 Type: fantasy.StreamPartTypeError,
422 Error: o.handleError(stream.Err()),
423 })
424 return
425 }
426
427 if !yield(fantasy.StreamPart{
428 Type: fantasy.StreamPartTypeToolInputStart,
429 ID: toolCallDelta.ID,
430 ToolCallName: toolCallDelta.Function.Name,
431 }) {
432 return
433 }
434 toolCalls[toolCallDelta.Index] = streamToolCall{
435 id: toolCallDelta.ID,
436 name: toolCallDelta.Function.Name,
437 arguments: toolCallDelta.Function.Arguments,
438 }
439
440 exTc := toolCalls[toolCallDelta.Index]
441 if exTc.arguments != "" {
442 if !yield(fantasy.StreamPart{
443 Type: fantasy.StreamPartTypeToolInputDelta,
444 ID: exTc.id,
445 Delta: exTc.arguments,
446 }) {
447 return
448 }
449 if xjson.IsValid(toolCalls[toolCallDelta.Index].arguments) {
450 if !yield(fantasy.StreamPart{
451 Type: fantasy.StreamPartTypeToolInputEnd,
452 ID: toolCallDelta.ID,
453 }) {
454 return
455 }
456
457 if !yield(fantasy.StreamPart{
458 Type: fantasy.StreamPartTypeToolCall,
459 ID: exTc.id,
460 ToolCallName: exTc.name,
461 ToolCallInput: exTc.arguments,
462 }) {
463 return
464 }
465 exTc.hasFinished = true
466 toolCalls[toolCallDelta.Index] = exTc
467 }
468 }
469 continue
470 }
471 }
472 }
473
474 if o.streamExtraFunc != nil {
475 updatedContext, shouldContinue := o.streamExtraFunc(chunk, yield, extraContext)
476 if !shouldContinue {
477 return
478 }
479 extraContext = updatedContext
480 }
481 }
482
483 // Check for annotations in the delta's raw JSON
484 for _, choice := range chunk.Choices {
485 if annotations := parseAnnotationsFromDelta(choice.Delta); len(annotations) > 0 {
486 for _, annotation := range annotations {
487 if annotation.Type == "url_citation" {
488 if !yield(fantasy.StreamPart{
489 Type: fantasy.StreamPartTypeSource,
490 ID: uuid.NewString(),
491 SourceType: fantasy.SourceTypeURL,
492 URL: annotation.URLCitation.URL,
493 Title: annotation.URLCitation.Title,
494 }) {
495 return
496 }
497 }
498 }
499 }
500 }
501 }
502 err := stream.Err()
503 if err == nil || errors.Is(err, io.EOF) {
504 // finished
505 if isActiveText {
506 isActiveText = false
507 if !yield(fantasy.StreamPart{
508 Type: fantasy.StreamPartTypeTextEnd,
509 ID: "0",
510 }) {
511 return
512 }
513 }
514
515 if len(acc.Choices) > 0 {
516 choice := acc.Choices[0]
517 // Add logprobs if available
518 providerMetadata = o.streamProviderMetadataFunc(choice, providerMetadata)
519
520 // Handle annotations/citations from accumulated response
521 for _, annotation := range choice.Message.Annotations {
522 if annotation.Type == "url_citation" {
523 if !yield(fantasy.StreamPart{
524 Type: fantasy.StreamPartTypeSource,
525 ID: acc.ID,
526 SourceType: fantasy.SourceTypeURL,
527 URL: annotation.URLCitation.URL,
528 Title: annotation.URLCitation.Title,
529 }) {
530 return
531 }
532 }
533 }
534 }
535 mappedFinishReason := o.mapFinishReasonFunc(finishReason)
536 if len(acc.Choices) > 0 {
537 choice := acc.Choices[0]
538 if len(choice.Message.ToolCalls) > 0 {
539 mappedFinishReason = fantasy.FinishReasonToolCalls
540 }
541 }
542 yield(fantasy.StreamPart{
543 Type: fantasy.StreamPartTypeFinish,
544 Usage: usage,
545 FinishReason: mappedFinishReason,
546 ProviderMetadata: providerMetadata,
547 })
548 return
549 } else {
550 yield(fantasy.StreamPart{
551 Type: fantasy.StreamPartTypeError,
552 Error: o.handleError(err),
553 })
554 return
555 }
556 }, nil
557}
558
559func isReasoningModel(modelID string) bool {
560 return strings.HasPrefix(modelID, "o") || strings.HasPrefix(modelID, "gpt-5") || strings.HasPrefix(modelID, "gpt-5-chat")
561}
562
563func isSearchPreviewModel(modelID string) bool {
564 return strings.Contains(modelID, "search-preview")
565}
566
567func supportsFlexProcessing(modelID string) bool {
568 return strings.HasPrefix(modelID, "o3") || strings.HasPrefix(modelID, "o4-mini") || strings.HasPrefix(modelID, "gpt-5")
569}
570
571func supportsPriorityProcessing(modelID string) bool {
572 return strings.HasPrefix(modelID, "gpt-4") || strings.HasPrefix(modelID, "gpt-5") ||
573 strings.HasPrefix(modelID, "gpt-5-mini") || strings.HasPrefix(modelID, "o3") ||
574 strings.HasPrefix(modelID, "o4-mini")
575}
576
577func toOpenAiTools(tools []fantasy.Tool, toolChoice *fantasy.ToolChoice) (openAiTools []openai.ChatCompletionToolUnionParam, openAiToolChoice *openai.ChatCompletionToolChoiceOptionUnionParam, warnings []fantasy.CallWarning) {
578 for _, tool := range tools {
579 if tool.GetType() == fantasy.ToolTypeFunction {
580 ft, ok := tool.(fantasy.FunctionTool)
581 if !ok {
582 continue
583 }
584 openAiTools = append(openAiTools, openai.ChatCompletionToolUnionParam{
585 OfFunction: &openai.ChatCompletionFunctionToolParam{
586 Function: shared.FunctionDefinitionParam{
587 Name: ft.Name,
588 Description: param.NewOpt(ft.Description),
589 Parameters: openai.FunctionParameters(ft.InputSchema),
590 Strict: param.NewOpt(false),
591 },
592 Type: "function",
593 },
594 })
595 continue
596 }
597
598 // TODO: handle provider tool calls
599 warnings = append(warnings, fantasy.CallWarning{
600 Type: fantasy.CallWarningTypeUnsupportedTool,
601 Tool: tool,
602 Message: "tool is not supported",
603 })
604 }
605 if toolChoice == nil {
606 return openAiTools, openAiToolChoice, warnings
607 }
608
609 switch *toolChoice {
610 case fantasy.ToolChoiceAuto:
611 openAiToolChoice = &openai.ChatCompletionToolChoiceOptionUnionParam{
612 OfAuto: param.NewOpt("auto"),
613 }
614 case fantasy.ToolChoiceNone:
615 openAiToolChoice = &openai.ChatCompletionToolChoiceOptionUnionParam{
616 OfAuto: param.NewOpt("none"),
617 }
618 default:
619 openAiToolChoice = &openai.ChatCompletionToolChoiceOptionUnionParam{
620 OfFunctionToolChoice: &openai.ChatCompletionNamedToolChoiceParam{
621 Type: "function",
622 Function: openai.ChatCompletionNamedToolChoiceFunctionParam{
623 Name: string(*toolChoice),
624 },
625 },
626 }
627 }
628 return openAiTools, openAiToolChoice, warnings
629}
630
631func toPrompt(prompt fantasy.Prompt) ([]openai.ChatCompletionMessageParamUnion, []fantasy.CallWarning) {
632 var messages []openai.ChatCompletionMessageParamUnion
633 var warnings []fantasy.CallWarning
634 for _, msg := range prompt {
635 switch msg.Role {
636 case fantasy.MessageRoleSystem:
637 var systemPromptParts []string
638 for _, c := range msg.Content {
639 if c.GetType() != fantasy.ContentTypeText {
640 warnings = append(warnings, fantasy.CallWarning{
641 Type: fantasy.CallWarningTypeOther,
642 Message: "system prompt can only have text content",
643 })
644 continue
645 }
646 textPart, ok := fantasy.AsContentType[fantasy.TextPart](c)
647 if !ok {
648 warnings = append(warnings, fantasy.CallWarning{
649 Type: fantasy.CallWarningTypeOther,
650 Message: "system prompt text part does not have the right type",
651 })
652 continue
653 }
654 text := textPart.Text
655 if strings.TrimSpace(text) != "" {
656 systemPromptParts = append(systemPromptParts, textPart.Text)
657 }
658 }
659 if len(systemPromptParts) == 0 {
660 warnings = append(warnings, fantasy.CallWarning{
661 Type: fantasy.CallWarningTypeOther,
662 Message: "system prompt has no text parts",
663 })
664 continue
665 }
666 messages = append(messages, openai.SystemMessage(strings.Join(systemPromptParts, "\n")))
667 case fantasy.MessageRoleUser:
668 // simple user message just text content
669 if len(msg.Content) == 1 && msg.Content[0].GetType() == fantasy.ContentTypeText {
670 textPart, ok := fantasy.AsContentType[fantasy.TextPart](msg.Content[0])
671 if !ok {
672 warnings = append(warnings, fantasy.CallWarning{
673 Type: fantasy.CallWarningTypeOther,
674 Message: "user message text part does not have the right type",
675 })
676 continue
677 }
678 messages = append(messages, openai.UserMessage(textPart.Text))
679 continue
680 }
681 // text content and attachments
682 // for now we only support image content later we need to check
683 // TODO: add the supported media types to the language model so we
684 // can use that to validate the data here.
685 var content []openai.ChatCompletionContentPartUnionParam
686 for _, c := range msg.Content {
687 switch c.GetType() {
688 case fantasy.ContentTypeText:
689 textPart, ok := fantasy.AsContentType[fantasy.TextPart](c)
690 if !ok {
691 warnings = append(warnings, fantasy.CallWarning{
692 Type: fantasy.CallWarningTypeOther,
693 Message: "user message text part does not have the right type",
694 })
695 continue
696 }
697 content = append(content, openai.ChatCompletionContentPartUnionParam{
698 OfText: &openai.ChatCompletionContentPartTextParam{
699 Text: textPart.Text,
700 },
701 })
702 case fantasy.ContentTypeFile:
703 filePart, ok := fantasy.AsContentType[fantasy.FilePart](c)
704 if !ok {
705 warnings = append(warnings, fantasy.CallWarning{
706 Type: fantasy.CallWarningTypeOther,
707 Message: "user message file part does not have the right type",
708 })
709 continue
710 }
711
712 switch {
713 case strings.HasPrefix(filePart.MediaType, "image/"):
714 // Handle image files
715 base64Encoded := base64.StdEncoding.EncodeToString(filePart.Data)
716 data := "data:" + filePart.MediaType + ";base64," + base64Encoded
717 imageURL := openai.ChatCompletionContentPartImageImageURLParam{URL: data}
718
719 // Check for provider-specific options like image detail
720 if providerOptions, ok := filePart.ProviderOptions[Name]; ok {
721 if detail, ok := providerOptions.(*ProviderFileOptions); ok {
722 imageURL.Detail = detail.ImageDetail
723 }
724 }
725
726 imageBlock := openai.ChatCompletionContentPartImageParam{ImageURL: imageURL}
727 content = append(content, openai.ChatCompletionContentPartUnionParam{OfImageURL: &imageBlock})
728
729 case filePart.MediaType == "audio/wav":
730 // Handle WAV audio files
731 base64Encoded := base64.StdEncoding.EncodeToString(filePart.Data)
732 audioBlock := openai.ChatCompletionContentPartInputAudioParam{
733 InputAudio: openai.ChatCompletionContentPartInputAudioInputAudioParam{
734 Data: base64Encoded,
735 Format: "wav",
736 },
737 }
738 content = append(content, openai.ChatCompletionContentPartUnionParam{OfInputAudio: &audioBlock})
739
740 case filePart.MediaType == "audio/mpeg" || filePart.MediaType == "audio/mp3":
741 // Handle MP3 audio files
742 base64Encoded := base64.StdEncoding.EncodeToString(filePart.Data)
743 audioBlock := openai.ChatCompletionContentPartInputAudioParam{
744 InputAudio: openai.ChatCompletionContentPartInputAudioInputAudioParam{
745 Data: base64Encoded,
746 Format: "mp3",
747 },
748 }
749 content = append(content, openai.ChatCompletionContentPartUnionParam{OfInputAudio: &audioBlock})
750
751 case filePart.MediaType == "application/pdf":
752 // Handle PDF files
753 dataStr := string(filePart.Data)
754
755 // Check if data looks like a file ID (starts with "file-")
756 if strings.HasPrefix(dataStr, "file-") {
757 fileBlock := openai.ChatCompletionContentPartFileParam{
758 File: openai.ChatCompletionContentPartFileFileParam{
759 FileID: param.NewOpt(dataStr),
760 },
761 }
762 content = append(content, openai.ChatCompletionContentPartUnionParam{OfFile: &fileBlock})
763 } else {
764 // Handle as base64 data
765 base64Encoded := base64.StdEncoding.EncodeToString(filePart.Data)
766 data := "data:application/pdf;base64," + base64Encoded
767
768 filename := filePart.Filename
769 if filename == "" {
770 // Generate default filename based on content index
771 filename = fmt.Sprintf("part-%d.pdf", len(content))
772 }
773
774 fileBlock := openai.ChatCompletionContentPartFileParam{
775 File: openai.ChatCompletionContentPartFileFileParam{
776 Filename: param.NewOpt(filename),
777 FileData: param.NewOpt(data),
778 },
779 }
780 content = append(content, openai.ChatCompletionContentPartUnionParam{OfFile: &fileBlock})
781 }
782
783 default:
784 warnings = append(warnings, fantasy.CallWarning{
785 Type: fantasy.CallWarningTypeOther,
786 Message: fmt.Sprintf("file part media type %s not supported", filePart.MediaType),
787 })
788 }
789 }
790 }
791 messages = append(messages, openai.UserMessage(content))
792 case fantasy.MessageRoleAssistant:
793 // simple assistant message just text content
794 if len(msg.Content) == 1 && msg.Content[0].GetType() == fantasy.ContentTypeText {
795 textPart, ok := fantasy.AsContentType[fantasy.TextPart](msg.Content[0])
796 if !ok {
797 warnings = append(warnings, fantasy.CallWarning{
798 Type: fantasy.CallWarningTypeOther,
799 Message: "assistant message text part does not have the right type",
800 })
801 continue
802 }
803 messages = append(messages, openai.AssistantMessage(textPart.Text))
804 continue
805 }
806 assistantMsg := openai.ChatCompletionAssistantMessageParam{
807 Role: "assistant",
808 }
809 for _, c := range msg.Content {
810 switch c.GetType() {
811 case fantasy.ContentTypeText:
812 textPart, ok := fantasy.AsContentType[fantasy.TextPart](c)
813 if !ok {
814 warnings = append(warnings, fantasy.CallWarning{
815 Type: fantasy.CallWarningTypeOther,
816 Message: "assistant message text part does not have the right type",
817 })
818 continue
819 }
820 assistantMsg.Content = openai.ChatCompletionAssistantMessageParamContentUnion{
821 OfString: param.NewOpt(textPart.Text),
822 }
823 case fantasy.ContentTypeToolCall:
824 toolCallPart, ok := fantasy.AsContentType[fantasy.ToolCallPart](c)
825 if !ok {
826 warnings = append(warnings, fantasy.CallWarning{
827 Type: fantasy.CallWarningTypeOther,
828 Message: "assistant message tool part does not have the right type",
829 })
830 continue
831 }
832 assistantMsg.ToolCalls = append(assistantMsg.ToolCalls,
833 openai.ChatCompletionMessageToolCallUnionParam{
834 OfFunction: &openai.ChatCompletionMessageFunctionToolCallParam{
835 ID: toolCallPart.ToolCallID,
836 Type: "function",
837 Function: openai.ChatCompletionMessageFunctionToolCallFunctionParam{
838 Name: toolCallPart.ToolName,
839 Arguments: toolCallPart.Input,
840 },
841 },
842 })
843 }
844 }
845 messages = append(messages, openai.ChatCompletionMessageParamUnion{
846 OfAssistant: &assistantMsg,
847 })
848 case fantasy.MessageRoleTool:
849 for _, c := range msg.Content {
850 if c.GetType() != fantasy.ContentTypeToolResult {
851 warnings = append(warnings, fantasy.CallWarning{
852 Type: fantasy.CallWarningTypeOther,
853 Message: "tool message can only have tool result content",
854 })
855 continue
856 }
857
858 toolResultPart, ok := fantasy.AsContentType[fantasy.ToolResultPart](c)
859 if !ok {
860 warnings = append(warnings, fantasy.CallWarning{
861 Type: fantasy.CallWarningTypeOther,
862 Message: "tool message result part does not have the right type",
863 })
864 continue
865 }
866
867 switch toolResultPart.Output.GetType() {
868 case fantasy.ToolResultContentTypeText:
869 output, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentText](toolResultPart.Output)
870 if !ok {
871 warnings = append(warnings, fantasy.CallWarning{
872 Type: fantasy.CallWarningTypeOther,
873 Message: "tool result output does not have the right type",
874 })
875 continue
876 }
877 messages = append(messages, openai.ToolMessage(output.Text, toolResultPart.ToolCallID))
878 case fantasy.ToolResultContentTypeError:
879 // TODO: check if better handling is needed
880 output, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentError](toolResultPart.Output)
881 if !ok {
882 warnings = append(warnings, fantasy.CallWarning{
883 Type: fantasy.CallWarningTypeOther,
884 Message: "tool result output does not have the right type",
885 })
886 continue
887 }
888 messages = append(messages, openai.ToolMessage(output.Error.Error(), toolResultPart.ToolCallID))
889 }
890 }
891 }
892 }
893 return messages, warnings
894}
895
896// parseAnnotationsFromDelta parses annotations from the raw JSON of a delta.
897func parseAnnotationsFromDelta(delta openai.ChatCompletionChunkChoiceDelta) []openai.ChatCompletionMessageAnnotation {
898 var annotations []openai.ChatCompletionMessageAnnotation
899
900 // Parse the raw JSON to extract annotations
901 var deltaData map[string]any
902 if err := json.Unmarshal([]byte(delta.RawJSON()), &deltaData); err != nil {
903 return annotations
904 }
905
906 // Check if annotations exist in the delta
907 if annotationsData, ok := deltaData["annotations"].([]any); ok {
908 for _, annotationData := range annotationsData {
909 if annotationMap, ok := annotationData.(map[string]any); ok {
910 if annotationType, ok := annotationMap["type"].(string); ok && annotationType == "url_citation" {
911 if urlCitationData, ok := annotationMap["url_citation"].(map[string]any); ok {
912 annotation := openai.ChatCompletionMessageAnnotation{
913 Type: "url_citation",
914 URLCitation: openai.ChatCompletionMessageAnnotationURLCitation{
915 URL: urlCitationData["url"].(string),
916 Title: urlCitationData["title"].(string),
917 },
918 }
919 annotations = append(annotations, annotation)
920 }
921 }
922 }
923 }
924 }
925
926 return annotations
927}