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
33 changes: 31 additions & 2 deletions responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,17 @@ func respToUpstream(instructions string, input json.RawMessage) (string, []upMes
}
for _, it := range items {
switch it.Type {
case "message":
case "message", "":
// Hermes (and some other Responses clients) send input items in the
// compact chat form {"role": "user", "content": "..."} with no
// `type` field. Treat a missing/empty type as a message so the user
// text is not silently dropped. See: ds4f "model can't see user"
// reports through Hermes -> sub2api -> qodercli2api.
if it.Type == "" && it.Role == "" && it.Content == nil && it.CallID == "" && it.Output == "" {
// not a recognizable message item; keep old default behavior
flushAssistant()
continue
}
flushAssistant()
if it.Role == "system" || it.Role == "developer" {
sysParts = append(sysParts, respPartsText(it.Content))
Expand All @@ -92,7 +102,9 @@ func respToUpstream(instructions string, input json.RawMessage) (string, []upMes
} else {
um.Content = respPartsToOAI(it.Content)
}
if txt := respPartsText(it.Content); txt != "" {
if it.Role == "user" {
um.Contents = respPartsToUpstream(it.Content)
} else if txt := respPartsText(it.Content); txt != "" {
um.Contents = []upPart{{Type: "text", Text: txt}}
}
msgs = append(msgs, um)
Expand Down Expand Up @@ -167,6 +179,23 @@ func respPartsToOAI(raw json.RawMessage) any {
return out
}

func respPartsToUpstream(raw json.RawMessage) []upPart {
parts := parseRespParts(raw)
out := make([]upPart, 0, len(parts))
for _, p := range parts {
switch p.Type {
case "input_text", "output_text", "text":
out = append(out, upPart{Type: "text", Text: p.Text})
case "input_image":
out = append(out, upPart{
Type: "image_url",
ImageURL: &upImageURL{URL: p.ImageURL},
})
}
}
return out
}

func parseRespParts(raw json.RawMessage) []respContentPart {
if len(raw) == 0 {
return nil
Expand Down
61 changes: 61 additions & 0 deletions responses_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package main

import (
"encoding/json"
"testing"
)

func TestRespToUpstreamPreservesInputImage(t *testing.T) {
input := json.RawMessage(`[
{
"type": "message",
"role": "user",
"content": [
{"type": "input_text", "text": "describe this"},
{"type": "input_image", "image_url": "data:image/png;base64,abc"}
]
}
]`)

_, msgs, err := respToUpstream("", input)
if err != nil {
t.Fatalf("respToUpstream returned error: %v", err)
}
if len(msgs) != 1 {
t.Fatalf("got %d messages, want 1", len(msgs))
}
if len(msgs[0].Contents) != 2 {
t.Fatalf("got %d upstream content parts, want 2", len(msgs[0].Contents))
}
if got := msgs[0].Contents[0].Text; got != "describe this" {
t.Fatalf("text content = %q, want %q", got, "describe this")
}
if got := msgs[0].Contents[1].ImageURL.URL; got != "data:image/png;base64,abc" {
t.Fatalf("image URL = %q, want data URL", got)
}
}

func TestRespToUpstreamAcceptsCompactRoleContentItem(t *testing.T) {
// Hermes sends Responses input in this compact form: it has role/content
// but omits the optional type:"message" discriminator.
input := json.RawMessage(`[
{"role": "user", "content": "只回复四个字:测试成功"}
]`)

_, msgs, err := respToUpstream("", input)
if err != nil {
t.Fatalf("respToUpstream returned error: %v", err)
}
if len(msgs) != 1 {
t.Fatalf("got %d messages, want 1", len(msgs))
}
if got := msgs[0].Role; got != "user" {
t.Fatalf("role = %q, want user", got)
}
if got := msgs[0].Content; got != "只回复四个字:测试成功" {
t.Fatalf("content = %q, want compact user text", got)
}
if len(msgs[0].Contents) != 1 || msgs[0].Contents[0].Text != "只回复四个字:测试成功" {
t.Fatalf("upstream contents = %#v, want compact user text", msgs[0].Contents)
}
}