Skip to content

Commit 9aa359c

Browse files
fix: accept structured annotations in responses output (#136)
1 parent 4899d25 commit 9aa359c

5 files changed

Lines changed: 119 additions & 11 deletions

File tree

internal/core/responses.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -99,11 +99,13 @@ type ResponsesOutputItem struct {
9999

100100
// ResponsesContentItem represents a content item in the output.
101101
type ResponsesContentItem struct {
102-
Type string `json:"type"` // "output_text", "input_image", "input_audio", etc.
103-
Text string `json:"text,omitempty"`
104-
ImageURL *ImageURLContent `json:"image_url,omitempty"`
105-
InputAudio *InputAudioContent `json:"input_audio,omitempty"`
106-
Annotations []string `json:"annotations,omitempty"`
102+
Type string `json:"type"` // "output_text", "input_image", "input_audio", etc.
103+
Text string `json:"text,omitempty"`
104+
ImageURL *ImageURLContent `json:"image_url,omitempty"`
105+
InputAudio *InputAudioContent `json:"input_audio,omitempty"`
106+
// Providers can return structured annotation objects here (for example
107+
// citations from native tools), so keep the payload shape liberal.
108+
Annotations []json.RawMessage `json:"annotations,omitempty"`
107109
}
108110

109111
// ResponsesUsage represents token usage for the Responses API.

internal/core/responses_json_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,68 @@ func TestResponsesRequestJSON_PreservesUnknownFields(t *testing.T) {
389389
}
390390
}
391391

392+
func TestResponsesResponseJSON_AcceptsStructuredAnnotations(t *testing.T) {
393+
var resp ResponsesResponse
394+
if err := json.Unmarshal([]byte(`{
395+
"id":"resp_123",
396+
"object":"response",
397+
"created_at":1677652288,
398+
"model":"gpt-4o-mini",
399+
"status":"completed",
400+
"output":[{
401+
"id":"msg_123",
402+
"type":"message",
403+
"role":"assistant",
404+
"status":"completed",
405+
"content":[{
406+
"type":"output_text",
407+
"text":"Found a result.",
408+
"annotations":[{
409+
"type":"url_citation",
410+
"title":"Example Domain",
411+
"url":"https://example.com"
412+
}]
413+
}]
414+
}]
415+
}`), &resp); err != nil {
416+
t.Fatalf("json.Unmarshal() error = %v", err)
417+
}
418+
419+
if len(resp.Output) != 1 || len(resp.Output[0].Content) != 1 {
420+
t.Fatalf("unexpected output shape: %+v", resp.Output)
421+
}
422+
annotations := resp.Output[0].Content[0].Annotations
423+
if len(annotations) != 1 {
424+
t.Fatalf("len(Annotations) = %d, want 1", len(annotations))
425+
}
426+
427+
var annotation map[string]any
428+
if err := json.Unmarshal(annotations[0], &annotation); err != nil {
429+
t.Fatalf("json.Unmarshal(annotation) error = %v", err)
430+
}
431+
if annotation["type"] != "url_citation" {
432+
t.Fatalf("annotation.type = %#v, want url_citation", annotation["type"])
433+
}
434+
435+
body, err := json.Marshal(resp)
436+
if err != nil {
437+
t.Fatalf("json.Marshal() error = %v", err)
438+
}
439+
440+
var decoded map[string]any
441+
if err := json.Unmarshal(body, &decoded); err != nil {
442+
t.Fatalf("json.Unmarshal(roundTrip) error = %v", err)
443+
}
444+
445+
output := decoded["output"].([]any)
446+
content := output[0].(map[string]any)["content"].([]any)
447+
roundTripAnnotations := content[0].(map[string]any)["annotations"].([]any)
448+
firstAnnotation := roundTripAnnotations[0].(map[string]any)
449+
if firstAnnotation["url"] != "https://example.com" {
450+
t.Fatalf("roundTrip annotation.url = %#v, want https://example.com", firstAnnotation["url"])
451+
}
452+
}
453+
392454
func TestResponsesInputElementMarshalJSON_FunctionCall(t *testing.T) {
393455
elem := ResponsesInputElement{
394456
Type: "function_call",

internal/providers/openai/openai_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -792,6 +792,50 @@ func TestResponses(t *testing.T) {
792792
}
793793
},
794794
},
795+
{
796+
name: "successful request with structured annotations",
797+
statusCode: http.StatusOK,
798+
responseBody: `{
799+
"id": "resp_annotated",
800+
"object": "response",
801+
"created_at": 1677652288,
802+
"model": "gpt-4o",
803+
"status": "completed",
804+
"output": [{
805+
"id": "msg_annotated",
806+
"type": "message",
807+
"role": "assistant",
808+
"status": "completed",
809+
"content": [{
810+
"type": "output_text",
811+
"text": "Search result summary",
812+
"annotations": [{
813+
"type": "url_citation",
814+
"title": "Example Domain",
815+
"url": "https://example.com"
816+
}]
817+
}]
818+
}]
819+
}`,
820+
checkResponse: func(t *testing.T, resp *core.ResponsesResponse) {
821+
if len(resp.Output) != 1 || len(resp.Output[0].Content) != 1 {
822+
t.Fatalf("unexpected output shape: %+v", resp.Output)
823+
}
824+
825+
annotations := resp.Output[0].Content[0].Annotations
826+
if len(annotations) != 1 {
827+
t.Fatalf("len(Annotations) = %d, want 1", len(annotations))
828+
}
829+
830+
var annotation map[string]any
831+
if err := json.Unmarshal(annotations[0], &annotation); err != nil {
832+
t.Fatalf("json.Unmarshal(annotation) error = %v", err)
833+
}
834+
if annotation["type"] != "url_citation" {
835+
t.Fatalf("annotation.type = %#v, want url_citation", annotation["type"])
836+
}
837+
},
838+
},
795839
{
796840
name: "API error - unauthorized",
797841
statusCode: http.StatusUnauthorized,

internal/providers/responses_adapter.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -810,7 +810,7 @@ func buildResponsesMessageContent(content any) []core.ResponsesContentItem {
810810
{
811811
Type: "output_text",
812812
Text: c,
813-
Annotations: []string{},
813+
Annotations: []json.RawMessage{},
814814
},
815815
}
816816
case []core.ContentPart:
@@ -830,7 +830,7 @@ func buildResponsesMessageContent(content any) []core.ResponsesContentItem {
830830
{
831831
Type: "output_text",
832832
Text: text,
833-
Annotations: []string{},
833+
Annotations: []json.RawMessage{},
834834
},
835835
}
836836
}
@@ -844,7 +844,7 @@ func buildResponsesContentItemsFromParts(parts []core.ContentPart) []core.Respon
844844
items = append(items, core.ResponsesContentItem{
845845
Type: "output_text",
846846
Text: part.Text,
847-
Annotations: []string{},
847+
Annotations: []json.RawMessage{},
848848
})
849849
case "image_url":
850850
if part.ImageURL == nil {
@@ -895,7 +895,7 @@ func BuildResponsesOutputItems(msg core.ResponseMessage) []core.ResponsesOutputI
895895
{
896896
Type: "output_text",
897897
Text: "",
898-
Annotations: []string{},
898+
Annotations: []json.RawMessage{},
899899
},
900900
}
901901
}
@@ -933,7 +933,7 @@ func ConvertChatResponseToResponses(resp *core.ChatResponse) *core.ResponsesResp
933933
{
934934
Type: "output_text",
935935
Text: "",
936-
Annotations: []string{},
936+
Annotations: []json.RawMessage{},
937937
},
938938
},
939939
},

internal/providers/responses_output_state.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ func (s *ResponsesOutputEventState) AssistantMessageItem(status string, includeC
8585
{
8686
"type": "output_text",
8787
"text": s.assistantText.String(),
88-
"annotations": []string{},
88+
"annotations": []json.RawMessage{},
8989
},
9090
}
9191
}

0 commit comments

Comments
 (0)