diff --git a/responses.go b/responses.go index 286e5b2..e2f09ce 100644 --- a/responses.go +++ b/responses.go @@ -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)) @@ -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) @@ -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 diff --git a/responses_test.go b/responses_test.go new file mode 100644 index 0000000..a7227f9 --- /dev/null +++ b/responses_test.go @@ -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) + } +}