Skip to content

Commit 5469cad

Browse files
joohwcursoragent
andcommitted
fix(protocol): preserve multimodal + fix SSE text/tool/usage transcoding
- multimodal: add Message.Parts/ImageSource IR and a content_parts helper so image content survives transcoding across Claude/OpenAI Chat/Responses/Gemini in both the flat Messages and InputSlots paths (decode + encode), with round-trip tests. - openai-chat SSE: stop trimming text deltas (preserve whitespace-only and edge whitespace), and suppress the duplicate stop emitted on [DONE] after an upstream finish_reason chunk. - openai-chat SSE: decode streaming delta.tool_calls into tool start/delta/end events and emit native tool_calls chunks on encode, so tool streaming round-trips across styles. - claude SSE: emit input_tokens in message_delta.usage when known. Bumps dev core version to dev0.1.45. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 782141b commit 5469cad

12 files changed

Lines changed: 656 additions & 41 deletions

File tree

core/internal/buildinfo/buildinfo.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import "strings"
44

55
// Set at link time via -ldflags (see .goreleaser.yaml).
66
var (
7-
Version = "dev0.1.51"
7+
Version = "dev0.1.52"
88
Commit = "none"
99
Date = "unknown"
1010
)
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
package protocol
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
)
7+
8+
// decodeContentParts parses a wire message "content" value into normalized IR
9+
// content parts. It recognizes the text and image shapes used by the Claude,
10+
// OpenAI Chat, OpenAI Responses, and Gemini wire formats. hasImage reports
11+
// whether any non-text (image) part was found; when false callers should keep
12+
// using the plain-string Content path so text-only behavior is unchanged.
13+
func decodeContentParts(value any) (parts []ContentPart, hasImage bool) {
14+
arr, ok := value.([]any)
15+
if !ok {
16+
return nil, false
17+
}
18+
for _, raw := range arr {
19+
item, ok := raw.(map[string]any)
20+
if !ok {
21+
continue
22+
}
23+
// Gemini-style inline/file data is not tagged with a "type" field.
24+
if img := geminiImagePart(item); img != nil {
25+
parts = append(parts, ContentPart{Type: "image", Image: img})
26+
hasImage = true
27+
continue
28+
}
29+
typ := ""
30+
if item["type"] != nil {
31+
typ = strings.ToLower(strings.TrimSpace(fmt.Sprint(item["type"])))
32+
}
33+
switch typ {
34+
case "text", "input_text", "output_text", "":
35+
// "" covers Gemini-style untyped text parts ({"text":"..."}).
36+
if t, ok := item["text"].(string); ok && t != "" {
37+
parts = append(parts, ContentPart{Type: "text", Text: t})
38+
}
39+
case "image_url":
40+
if img := openAIImageURLPart(item["image_url"]); img != nil {
41+
parts = append(parts, ContentPart{Type: "image", Image: img})
42+
hasImage = true
43+
}
44+
case "input_image", "output_image":
45+
if img := responsesImagePart(item); img != nil {
46+
parts = append(parts, ContentPart{Type: "image", Image: img})
47+
hasImage = true
48+
}
49+
case "image":
50+
if img := claudeImagePart(item["source"]); img != nil {
51+
parts = append(parts, ContentPart{Type: "image", Image: img})
52+
hasImage = true
53+
}
54+
}
55+
}
56+
return parts, hasImage
57+
}
58+
59+
func openAIImageURLPart(value any) *ImageSource {
60+
switch v := value.(type) {
61+
case string:
62+
if u := strings.TrimSpace(v); u != "" {
63+
return imageSourceFromURL(u)
64+
}
65+
case map[string]any:
66+
if u := strings.TrimSpace(fmt.Sprint(v["url"])); u != "" && u != "<nil>" {
67+
return imageSourceFromURL(u)
68+
}
69+
}
70+
return nil
71+
}
72+
73+
func responsesImagePart(item map[string]any) *ImageSource {
74+
if u := strings.TrimSpace(fmt.Sprint(item["image_url"])); u != "" && u != "<nil>" {
75+
return imageSourceFromURL(u)
76+
}
77+
return nil
78+
}
79+
80+
func claudeImagePart(value any) *ImageSource {
81+
src, ok := value.(map[string]any)
82+
if !ok {
83+
return nil
84+
}
85+
switch strings.ToLower(strings.TrimSpace(fmt.Sprint(src["type"]))) {
86+
case "url":
87+
if u := strings.TrimSpace(fmt.Sprint(src["url"])); u != "" && u != "<nil>" {
88+
return imageSourceFromURL(u)
89+
}
90+
default: // base64
91+
data := strings.TrimSpace(fmt.Sprint(src["data"]))
92+
if data == "" || data == "<nil>" {
93+
return nil
94+
}
95+
return &ImageSource{
96+
MediaType: strings.TrimSpace(fmt.Sprint(src["media_type"])),
97+
Data: data,
98+
}
99+
}
100+
return nil
101+
}
102+
103+
func geminiImagePart(item map[string]any) *ImageSource {
104+
if inline, ok := item["inlineData"].(map[string]any); ok && inline != nil {
105+
data := strings.TrimSpace(fmt.Sprint(inline["data"]))
106+
if data != "" && data != "<nil>" {
107+
return &ImageSource{
108+
MediaType: strings.TrimSpace(fmt.Sprint(inline["mimeType"])),
109+
Data: data,
110+
}
111+
}
112+
}
113+
if file, ok := item["fileData"].(map[string]any); ok && file != nil {
114+
if u := strings.TrimSpace(fmt.Sprint(file["fileUri"])); u != "" && u != "<nil>" {
115+
return &ImageSource{URL: u, MediaType: strings.TrimSpace(fmt.Sprint(file["mimeType"]))}
116+
}
117+
}
118+
return nil
119+
}
120+
121+
// imageSourceFromURL normalizes a URL into an ImageSource, splitting inline
122+
// data: URLs into media type + base64 so downstream encoders can pick the
123+
// representation their wire format requires.
124+
func imageSourceFromURL(u string) *ImageSource {
125+
if mediaType, data, ok := parseDataURL(u); ok {
126+
return &ImageSource{MediaType: mediaType, Data: data}
127+
}
128+
return &ImageSource{URL: u}
129+
}
130+
131+
// parseDataURL extracts the media type and base64 payload from a
132+
// "data:<media>;base64,<data>" URL. ok is false for non-data or non-base64 URLs.
133+
func parseDataURL(u string) (mediaType, data string, ok bool) {
134+
if !strings.HasPrefix(u, "data:") {
135+
return "", "", false
136+
}
137+
rest := u[len("data:"):]
138+
comma := strings.IndexByte(rest, ',')
139+
if comma < 0 {
140+
return "", "", false
141+
}
142+
meta := rest[:comma]
143+
payload := rest[comma+1:]
144+
if !strings.Contains(strings.ToLower(meta), "base64") {
145+
return "", "", false
146+
}
147+
mediaType = strings.TrimSpace(strings.SplitN(meta, ";", 2)[0])
148+
return mediaType, strings.TrimSpace(payload), true
149+
}
150+
151+
// imageDataURL renders an ImageSource as a data: URL (used by formats that only
152+
// accept a single URL string). Falls back to the plain URL when no inline data.
153+
func imageDataURL(img *ImageSource) string {
154+
if img == nil {
155+
return ""
156+
}
157+
if img.Data != "" {
158+
mt := strings.TrimSpace(img.MediaType)
159+
if mt == "" {
160+
mt = "image/png"
161+
}
162+
return "data:" + mt + ";base64," + img.Data
163+
}
164+
return img.URL
165+
}
166+
167+
// messageHasImage reports whether a message carries any image content part.
168+
func messageHasImage(m Message) bool {
169+
for _, p := range m.Parts {
170+
if p.Type == "image" && p.Image != nil {
171+
return true
172+
}
173+
}
174+
return false
175+
}
176+
177+
// openAIChatContentValue returns the wire "content" for an OpenAI Chat message:
178+
// a plain string when there are no image parts, otherwise a content-part array.
179+
func openAIChatContentValue(m Message) any {
180+
if !messageHasImage(m) {
181+
return m.Content
182+
}
183+
out := make([]map[string]any, 0, len(m.Parts))
184+
for _, p := range m.Parts {
185+
switch p.Type {
186+
case "text":
187+
out = append(out, map[string]any{"type": "text", "text": p.Text})
188+
case "image":
189+
if p.Image != nil {
190+
out = append(out, map[string]any{
191+
"type": "image_url",
192+
"image_url": map[string]any{"url": imageDataURL(p.Image)},
193+
})
194+
}
195+
}
196+
}
197+
return out
198+
}
199+
200+
// claudeContentBlocks returns Anthropic content blocks for a message and whether
201+
// it contained an image (callers fall back to a plain string when false).
202+
func claudeContentBlocks(m Message) ([]map[string]any, bool) {
203+
if !messageHasImage(m) {
204+
return nil, false
205+
}
206+
out := make([]map[string]any, 0, len(m.Parts))
207+
for _, p := range m.Parts {
208+
switch p.Type {
209+
case "text":
210+
if strings.TrimSpace(p.Text) != "" {
211+
out = append(out, map[string]any{"type": "text", "text": p.Text})
212+
}
213+
case "image":
214+
if p.Image != nil {
215+
out = append(out, claudeImageBlock(p.Image))
216+
}
217+
}
218+
}
219+
return out, true
220+
}
221+
222+
func claudeImageBlock(img *ImageSource) map[string]any {
223+
if img.Data == "" && img.URL != "" {
224+
if mt, data, ok := parseDataURL(img.URL); ok {
225+
img = &ImageSource{MediaType: mt, Data: data}
226+
} else {
227+
return map[string]any{
228+
"type": "image",
229+
"source": map[string]any{"type": "url", "url": img.URL},
230+
}
231+
}
232+
}
233+
mt := strings.TrimSpace(img.MediaType)
234+
if mt == "" {
235+
mt = "image/png"
236+
}
237+
return map[string]any{
238+
"type": "image",
239+
"source": map[string]any{
240+
"type": "base64",
241+
"media_type": mt,
242+
"data": img.Data,
243+
},
244+
}
245+
}
246+
247+
// responsesContentParts returns OpenAI Responses content parts and whether the
248+
// message contained an image.
249+
func responsesContentParts(m Message) ([]map[string]any, bool) {
250+
if !messageHasImage(m) {
251+
return nil, false
252+
}
253+
textType := responsesMessageContentType(m.Role)
254+
out := make([]map[string]any, 0, len(m.Parts))
255+
for _, p := range m.Parts {
256+
switch p.Type {
257+
case "text":
258+
out = append(out, map[string]any{"type": textType, "text": p.Text})
259+
case "image":
260+
if p.Image != nil {
261+
out = append(out, map[string]any{"type": "input_image", "image_url": imageDataURL(p.Image)})
262+
}
263+
}
264+
}
265+
return out, true
266+
}

0 commit comments

Comments
 (0)