Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 38 additions & 18 deletions go/adk/pkg/models/openai_adk.go
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ func genaiContentsToOpenAIMessages(contents []*genai.Content, config *genai.Gene
if part == nil {
continue
}
if part.Text != "" {
if part.Text != "" && !part.Thought {
textParts = append(textParts, part.Text)
} else if part.FunctionCall != nil {
functionCalls = append(functionCalls, part.FunctionCall)
Expand All @@ -273,7 +273,8 @@ func genaiContentsToOpenAIMessages(contents []*genai.Content, config *genai.Gene
}
}

if len(functionCalls) > 0 && (role == openAIRoleModel || role == openAIRoleAssistant) {
mistralContent := mistralAssistantContent(content.Parts)
if (len(functionCalls) > 0 || len(mistralContent) > 0) && (role == openAIRoleModel || role == openAIRoleAssistant) {
toolCalls := make([]openai.ChatCompletionMessageToolCallUnionParam, 0, len(functionCalls))
var toolResponseMessages []openai.ChatCompletionMessageParamUnion
for _, fc := range functionCalls {
Expand Down Expand Up @@ -313,7 +314,9 @@ func genaiContentsToOpenAIMessages(contents []*genai.Content, config *genai.Gene
Role: constant.Assistant("assistant"),
ToolCalls: toolCalls,
}
if len(textParts) > 0 {
if len(mistralContent) > 0 {
asst.Content.OfArrayOfContentParts = mistralContent
} else if len(textParts) > 0 {
asst.Content.OfString = param.NewOpt(textContent)
}
messages = append(messages, openai.ChatCompletionMessageParamUnion{OfAssistant: &asst})
Expand Down Expand Up @@ -383,6 +386,7 @@ func runStreaming(ctx context.Context, m *OpenAIModel, params openai.ChatComplet
stream := m.Client.Chat.Completions.NewStreaming(ctx, params, openAIPassthroughOpts(ctx, m)...)
defer stream.Close()

var aggregatedParts []*genai.Part
var aggregatedText strings.Builder
toolCallsAcc := make(map[int64]map[string]any)
var finishReason string
Expand All @@ -400,12 +404,29 @@ func runStreaming(ctx context.Context, m *OpenAIModel, params openai.ChatComplet
}
choice := chunk.Choices[0]
delta := choice.Delta
if delta.Content != "" {
aggregatedText.WriteString(delta.Content)
parts, err := openAIContentToParts(delta.Content, delta.JSON.Content.Raw())
if err != nil {
yield(&model.LLMResponse{ErrorCode: openAIContentError, ErrorMessage: err.Error()}, nil)
return
}
for _, part := range parts {
if part.Thought {
if aggregatedText.Len() > 0 {
aggregatedParts = append(aggregatedParts, &genai.Part{Text: aggregatedText.String()})
aggregatedText.Reset()
}
aggregatedParts = append(aggregatedParts, part)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

streamed thinking deltas replay as separate blocks, closed:false mid-history

} else {
aggregatedText.WriteString(part.Text)
}
}
// Closing-only thinking deltas carry replay state, not displayable content.
parts = slices.DeleteFunc(parts, func(part *genai.Part) bool { return part.Text == "" })
if len(parts) > 0 {
if !yield(&model.LLMResponse{
Partial: true,
TurnComplete: choice.FinishReason != "",
Content: &genai.Content{Role: string(genai.RoleModel), Parts: []*genai.Part{{Text: delta.Content}}},
Content: &genai.Content{Role: string(genai.RoleModel), Parts: parts},
}, nil) {
return
}
Expand Down Expand Up @@ -449,10 +470,14 @@ func runStreaming(ctx context.Context, m *OpenAIModel, params openai.ChatComplet
indices = append(indices, k)
}
slices.Sort(indices)
finalParts := make([]*genai.Part, 0, 1+nToolCalls)
text := aggregatedText.String()
if text != "" {
finalParts = append(finalParts, &genai.Part{Text: text})
finalParts := aggregatedParts
if aggregatedText.Len() > 0 {
finalParts = append(finalParts, &genai.Part{Text: aggregatedText.String()})
}
finalParts, err := mergeMistralThinkingParts(finalParts)
if err != nil {
yield(&model.LLMResponse{ErrorCode: openAIContentError, ErrorMessage: err.Error()}, nil)
return
}
for _, idx := range indices {
tc := toolCallsAcc[idx]
Expand Down Expand Up @@ -505,14 +530,9 @@ func runNonStreaming(ctx context.Context, m *OpenAIModel, params openai.ChatComp
func chatCompletionToLLMResponse(completion *openai.ChatCompletion) *model.LLMResponse {
choice := completion.Choices[0]
msg := choice.Message
nParts := 0
if msg.Content != "" {
nParts++
}
nParts += len(msg.ToolCalls)
parts := make([]*genai.Part, 0, nParts)
if msg.Content != "" {
parts = append(parts, &genai.Part{Text: msg.Content})
parts, err := openAIContentToParts(msg.Content, msg.JSON.Content.Raw())
if err != nil {
return &model.LLMResponse{ErrorCode: openAIContentError, ErrorMessage: err.Error()}
}
for _, tc := range msg.ToolCalls {
if tc.Type == openAIToolTypeFunction && tc.Function.Name != "" {
Expand Down
158 changes: 158 additions & 0 deletions go/adk/pkg/models/openai_content.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package models

import (
"encoding/json"
"fmt"
"strings"

"github.com/openai/openai-go/v3"
"github.com/openai/openai-go/v3/packages/param"
"google.golang.org/genai"
)

const (
openAIContentError = "CONTENT_ERROR"
openAIChunkTypeText = "text"
openAIChunkTypeThinking = "thinking"
mistralThinkingChunkKey = "mistral_thinking_chunk"
)

type openAITextChunk struct {
Type string `json:"type"`
Text string `json:"text"`
}

type mistralThinkingChunk struct {
Type string `json:"type"`
Thinking []json.RawMessage `json:"thinking"`
Closed bool `json:"closed"`
}

// The SDK models content as a string, but Mistral returns typed chunk arrays.
// Inspect the wire JSON so a string containing JSON remains ordinary answer text.
func openAIContentToParts(text, raw string) ([]*genai.Part, error) {
if !strings.HasPrefix(strings.TrimSpace(raw), "[") {
if text == "" {
return nil, nil
}
return []*genai.Part{{Text: text}}, nil
}
var chunks []json.RawMessage
if err := json.Unmarshal([]byte(raw), &chunks); err != nil {
return nil, fmt.Errorf("failed to decode OpenAI content chunks: %w", err)
}
parts := make([]*genai.Part, 0, len(chunks))
for _, rawChunk := range chunks {
var chunk struct {
Type string `json:"type"`
}
if err := json.Unmarshal(rawChunk, &chunk); err != nil {
return nil, fmt.Errorf("failed to decode OpenAI content chunk: %w", err)
}
switch chunk.Type {
case openAIChunkTypeText:
var textChunk openAITextChunk
if err := json.Unmarshal(rawChunk, &textChunk); err != nil {
return nil, fmt.Errorf("failed to decode OpenAI text chunk: %w", err)
}
if textChunk.Text != "" {
parts = append(parts, &genai.Part{Text: textChunk.Text})
}
case openAIChunkTypeThinking:
var thinkingChunk mistralThinkingChunk
if err := json.Unmarshal(rawChunk, &thinkingChunk); err != nil {
return nil, fmt.Errorf("failed to decode OpenAI thinking chunk: %w", err)
}
var thought strings.Builder
for _, rawInner := range thinkingChunk.Thinking {
var inner struct {
Type string `json:"type"`
}
if err := json.Unmarshal(rawInner, &inner); err != nil {
return nil, fmt.Errorf("failed to decode OpenAI thinking content: %w", err)
}
if inner.Type == "" {
return nil, fmt.Errorf("OpenAI thinking content is missing its type")
}
if inner.Type == openAIChunkTypeText {
var textChunk openAITextChunk
if err := json.Unmarshal(rawInner, &textChunk); err != nil {
return nil, fmt.Errorf("failed to decode OpenAI thinking text: %w", err)
}
thought.WriteString(textChunk.Text)
}
}
parts = append(parts, &genai.Part{
Text: thought.String(), Thought: true,
// Preserve the native block, including closed, across persistence.
PartMetadata: map[string]any{mistralThinkingChunkKey: string(rawChunk)},
})
case "":
return nil, fmt.Errorf("OpenAI content chunk is missing its type")
default:
// Unsupported non-text extensions must not abort the answer or become text.
continue
}
}
return parts, nil
}

// Complete a streamed thinking block before persisting or replaying the turn.
func mergeMistralThinkingParts(parts []*genai.Part) ([]*genai.Part, error) {
var merged *genai.Part
var text strings.Builder
var thinking []json.RawMessage
var result []*genai.Part
for _, part := range parts {
if !part.Thought {
result = append(result, part)
continue
}
var chunk mistralThinkingChunk
raw, _ := part.PartMetadata[mistralThinkingChunkKey].(string)
if err := json.Unmarshal([]byte(raw), &chunk); err != nil {
return nil, fmt.Errorf("failed to decode streamed Mistral thinking chunk: %w", err)
}
thinking = append(thinking, chunk.Thinking...)
text.WriteString(part.Text)
if merged == nil {
merged = &genai.Part{Thought: true}
result = append(result, merged)
}
}
if merged != nil {
chunk := mistralThinkingChunk{Type: openAIChunkTypeThinking, Thinking: thinking, Closed: true}
raw, err := json.Marshal(chunk)
if err != nil {
return nil, fmt.Errorf("failed to encode complete Mistral thinking chunk: %w", err)
}
merged.Text = text.String()
merged.PartMetadata = map[string]any{mistralThinkingChunkKey: string(raw)}
}
return result, nil
}

// Only native Mistral thoughts opt a message into Mistral's chunk-list format.
func mistralAssistantContent(parts []*genai.Part) []openai.ChatCompletionAssistantMessageParamContentArrayOfContentPartUnion {
var chunks []openai.ChatCompletionAssistantMessageParamContentArrayOfContentPartUnion
var hasThinking bool
for _, part := range parts {
if part == nil {
continue
}
if part.Thought {
if raw, ok := part.PartMetadata[mistralThinkingChunkKey].(string); ok {
hasThinking = true
chunks = append(chunks, param.Override[openai.ChatCompletionAssistantMessageParamContentArrayOfContentPartUnion](json.RawMessage(raw)))
}
} else if part.Text != "" {
chunks = append(chunks, openai.ChatCompletionAssistantMessageParamContentArrayOfContentPartUnion{
OfText: &openai.ChatCompletionContentPartTextParam{Text: part.Text},
})
}
}
if !hasThinking {
return nil
}
return chunks
}
Loading
Loading