| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848 |
- package apicompat
- import (
- "encoding/json"
- "testing"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
- )
- // ---------------------------------------------------------------------------
- // ChatCompletionsToResponses tests
- // ---------------------------------------------------------------------------
- func TestChatCompletionsToResponses_BasicText(t *testing.T) {
- req := &ChatCompletionsRequest{
- Model: "gpt-4o",
- Messages: []ChatMessage{
- {Role: "user", Content: json.RawMessage(`"Hello"`)},
- },
- }
- resp, err := ChatCompletionsToResponses(req)
- require.NoError(t, err)
- assert.Equal(t, "gpt-4o", resp.Model)
- assert.True(t, resp.Stream) // always forced true
- assert.False(t, *resp.Store)
- var items []ResponsesInputItem
- require.NoError(t, json.Unmarshal(resp.Input, &items))
- require.Len(t, items, 1)
- assert.Equal(t, "user", items[0].Role)
- }
- func TestUsageConversionsPreserveCacheWriteTokens(t *testing.T) {
- var responsesUsage ResponsesUsage
- require.NoError(t, json.Unmarshal([]byte(`{
- "input_tokens":1000,
- "output_tokens":50,
- "input_tokens_details":{"cached_tokens":100,"cache_write_tokens":200}
- }`), &responsesUsage))
- require.NotNil(t, responsesUsage.InputTokensDetails)
- require.Equal(t, 200, responsesUsage.InputTokensDetails.CacheWriteTokens)
- chatUsage := chatUsageFromResponsesUsage(&responsesUsage)
- require.NotNil(t, chatUsage.PromptTokensDetails)
- require.Equal(t, 100, chatUsage.PromptTokensDetails.CachedTokens)
- require.Equal(t, 200, chatUsage.PromptTokensDetails.CacheWriteTokens)
- roundTrip := ChatUsageToResponsesUsage(chatUsage)
- require.NotNil(t, roundTrip.InputTokensDetails)
- require.Equal(t, 200, roundTrip.CacheCreationInputTokens)
- require.Equal(t, 200, roundTrip.InputTokensDetails.CacheWriteTokens)
- }
- func TestResponsesUsageNestedCacheWritePresenceOverridesTopLevelAlias(t *testing.T) {
- tests := []struct {
- name string
- nestedJSON string
- want int
- }{
- {name: "explicit zero", nestedJSON: `{"cache_write_tokens":0}`, want: 0},
- {name: "nonzero", nestedJSON: `{"cache_write_tokens":7}`, want: 7},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- var usage ResponsesUsage
- payload := []byte(`{"input_tokens":20,"output_tokens":2,"cache_creation_input_tokens":19,"input_tokens_details":` + tt.nestedJSON + `}`)
- require.NoError(t, json.Unmarshal(payload, &usage))
- require.Equal(t, tt.want, usage.CacheCreationInputTokens)
- })
- }
- }
- func TestChatCompletionsToResponses_SystemMessage(t *testing.T) {
- req := &ChatCompletionsRequest{
- Model: "gpt-4o",
- Messages: []ChatMessage{
- {Role: "system", Content: json.RawMessage(`"You are helpful."`)},
- {Role: "user", Content: json.RawMessage(`"Hi"`)},
- },
- }
- resp, err := ChatCompletionsToResponses(req)
- require.NoError(t, err)
- var items []ResponsesInputItem
- require.NoError(t, json.Unmarshal(resp.Input, &items))
- require.Len(t, items, 2)
- assert.Equal(t, "system", items[0].Role)
- assert.Equal(t, "user", items[1].Role)
- }
- func TestChatCompletionsToResponses_ToolCalls(t *testing.T) {
- req := &ChatCompletionsRequest{
- Model: "gpt-4o",
- Messages: []ChatMessage{
- {Role: "user", Content: json.RawMessage(`"Call the function"`)},
- {
- Role: "assistant",
- ToolCalls: []ChatToolCall{
- {
- ID: "call_1",
- Type: "function",
- Function: ChatFunctionCall{
- Name: "ping",
- Arguments: `{"host":"example.com"}`,
- },
- },
- },
- },
- {
- Role: "tool",
- ToolCallID: "call_1",
- Content: json.RawMessage(`"pong"`),
- },
- },
- Tools: []ChatTool{
- {
- Type: "function",
- Function: &ChatFunction{
- Name: "ping",
- Description: "Ping a host",
- Parameters: json.RawMessage(`{"type":"object"}`),
- },
- },
- },
- }
- resp, err := ChatCompletionsToResponses(req)
- require.NoError(t, err)
- var items []ResponsesInputItem
- require.NoError(t, json.Unmarshal(resp.Input, &items))
- // user + function_call + function_call_output = 3
- // (assistant message with empty content + tool_calls → only function_call items emitted)
- require.Len(t, items, 3)
- // Check function_call item
- assert.Equal(t, "function_call", items[1].Type)
- assert.Equal(t, "call_1", items[1].CallID)
- assert.Empty(t, items[1].ID)
- assert.Equal(t, "ping", items[1].Name)
- // Check function_call_output item
- assert.Equal(t, "function_call_output", items[2].Type)
- assert.Equal(t, "call_1", items[2].CallID)
- assert.Equal(t, "pong", items[2].Output)
- // Check tools
- require.Len(t, resp.Tools, 1)
- assert.Equal(t, "function", resp.Tools[0].Type)
- assert.Equal(t, "ping", resp.Tools[0].Name)
- }
- func TestChatCompletionsToResponses_ToolStrict(t *testing.T) {
- strictTrue := true
- strictFalse := false
- tests := []struct {
- name string
- strict *bool
- want bool
- }{
- {name: "defaults omitted strict to false", want: false},
- {name: "preserves explicit true", strict: &strictTrue, want: true},
- {name: "preserves explicit false", strict: &strictFalse, want: false},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- req := &ChatCompletionsRequest{
- Model: "gpt-4o",
- Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"Hi"`)}},
- Tools: []ChatTool{{
- Type: "function",
- Function: &ChatFunction{
- Name: "lookup",
- Strict: tt.strict,
- },
- }},
- }
- resp, err := ChatCompletionsToResponses(req)
- require.NoError(t, err)
- require.Len(t, resp.Tools, 1)
- require.NotNil(t, resp.Tools[0].Strict)
- assert.Equal(t, tt.want, *resp.Tools[0].Strict)
- payload, err := json.Marshal(resp)
- require.NoError(t, err)
- var serialized struct {
- Tools []map[string]json.RawMessage `json:"tools"`
- }
- require.NoError(t, json.Unmarshal(payload, &serialized))
- require.Len(t, serialized.Tools, 1)
- strictJSON, ok := serialized.Tools[0]["strict"]
- require.True(t, ok, "strict must be present in the Responses payload")
- assert.JSONEq(t, string(mustMarshalJSON(t, tt.want)), string(strictJSON))
- })
- }
- }
- func TestChatCompletionsToResponses_LegacyFunctionDefaultsStrictFalse(t *testing.T) {
- req := &ChatCompletionsRequest{
- Model: "gpt-4o",
- Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"Hi"`)}},
- Functions: []ChatFunction{{
- Name: "lookup",
- }},
- }
- resp, err := ChatCompletionsToResponses(req)
- require.NoError(t, err)
- require.Len(t, resp.Tools, 1)
- require.NotNil(t, resp.Tools[0].Strict)
- assert.False(t, *resp.Tools[0].Strict)
- payload, err := json.Marshal(resp)
- require.NoError(t, err)
- assert.Contains(t, string(payload), `"strict":false`)
- }
- func TestResponsesTool_StrictFalseIsSerialized(t *testing.T) {
- strict := false
- payload, err := json.Marshal(ResponsesTool{
- Type: "function",
- Strict: &strict,
- })
- require.NoError(t, err)
- assert.JSONEq(t, `{"type":"function","strict":false}`, string(payload))
- }
- func mustMarshalJSON(t *testing.T, value any) []byte {
- t.Helper()
- data, err := json.Marshal(value)
- require.NoError(t, err)
- return data
- }
- func TestChatCompletionsToResponses_MaxTokens(t *testing.T) {
- t.Run("max_tokens", func(t *testing.T) {
- maxTokens := 100
- req := &ChatCompletionsRequest{
- Model: "gpt-4o",
- MaxTokens: &maxTokens,
- Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"Hi"`)}},
- }
- resp, err := ChatCompletionsToResponses(req)
- require.NoError(t, err)
- require.NotNil(t, resp.MaxOutputTokens)
- // Below minMaxOutputTokens (128), should be clamped
- assert.Equal(t, minMaxOutputTokens, *resp.MaxOutputTokens)
- })
- t.Run("max_completion_tokens_preferred", func(t *testing.T) {
- maxTokens := 100
- maxCompletion := 500
- req := &ChatCompletionsRequest{
- Model: "gpt-4o",
- MaxTokens: &maxTokens,
- MaxCompletionTokens: &maxCompletion,
- Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"Hi"`)}},
- }
- resp, err := ChatCompletionsToResponses(req)
- require.NoError(t, err)
- require.NotNil(t, resp.MaxOutputTokens)
- assert.Equal(t, 500, *resp.MaxOutputTokens)
- })
- }
- func TestChatCompletionsToResponses_ReasoningEffort(t *testing.T) {
- req := &ChatCompletionsRequest{
- Model: "gpt-4o",
- ReasoningEffort: "high",
- Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"Hi"`)}},
- }
- resp, err := ChatCompletionsToResponses(req)
- require.NoError(t, err)
- require.NotNil(t, resp.Reasoning)
- assert.Equal(t, "high", resp.Reasoning.Effort)
- assert.Equal(t, "auto", resp.Reasoning.Summary)
- }
- func TestChatCompletionsToResponses_ResponseFormatJsonObject(t *testing.T) {
- req := &ChatCompletionsRequest{
- Model: "gpt-4o",
- Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"Return JSON"`)}},
- ResponseFormat: json.RawMessage(`{"type":"json_object"}`),
- }
- resp, err := ChatCompletionsToResponses(req)
- require.NoError(t, err)
- require.NotNil(t, resp.Text)
- assert.JSONEq(t, `{"type":"json_object"}`, string(resp.Text.Format))
- payload, err := json.Marshal(resp)
- require.NoError(t, err)
- var serialized struct {
- Text ResponsesText `json:"text"`
- }
- require.NoError(t, json.Unmarshal(payload, &serialized))
- assert.JSONEq(t, `{"type":"json_object"}`, string(serialized.Text.Format))
- }
- func TestChatCompletionsToResponses_ResponseFormatJsonSchema(t *testing.T) {
- req := &ChatCompletionsRequest{
- Model: "gpt-4o",
- Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"Return structured JSON"`)}},
- ResponseFormat: json.RawMessage(`{
- "type":"json_schema",
- "json_schema":{
- "name":"answer",
- "schema":{
- "type":"object",
- "properties":{"ok":{"type":"boolean"}},
- "required":["ok"],
- "additionalProperties":false
- },
- "strict":true
- }
- }`),
- }
- resp, err := ChatCompletionsToResponses(req)
- require.NoError(t, err)
- require.NotNil(t, resp.Text)
- assert.JSONEq(t, `{
- "type":"json_schema",
- "name":"answer",
- "schema":{
- "type":"object",
- "properties":{"ok":{"type":"boolean"}},
- "required":["ok"],
- "additionalProperties":false
- },
- "strict":true
- }`, string(resp.Text.Format))
- }
- func TestChatCompletionsToResponses_ImageURL(t *testing.T) {
- content := `[{"type":"text","text":"Describe this"},{"type":"image_url","image_url":{"url":"data:image/png;base64,abc123"}}]`
- req := &ChatCompletionsRequest{
- Model: "gpt-4o",
- Messages: []ChatMessage{
- {Role: "user", Content: json.RawMessage(content)},
- },
- }
- resp, err := ChatCompletionsToResponses(req)
- require.NoError(t, err)
- var items []ResponsesInputItem
- require.NoError(t, json.Unmarshal(resp.Input, &items))
- require.Len(t, items, 1)
- var parts []ResponsesContentPart
- require.NoError(t, json.Unmarshal(items[0].Content, &parts))
- require.Len(t, parts, 2)
- assert.Equal(t, "input_text", parts[0].Type)
- assert.Equal(t, "Describe this", parts[0].Text)
- assert.Equal(t, "input_image", parts[1].Type)
- assert.Equal(t, "data:image/png;base64,abc123", parts[1].ImageURL)
- }
- func TestChatCompletionsToResponses_EmptyBase64ImageURLSkipped(t *testing.T) {
- content := `[{"type":"text","text":"Describe this"},{"type":"image_url","image_url":{"url":"data:image/png;base64,"}}]`
- req := &ChatCompletionsRequest{
- Model: "gpt-4o",
- Messages: []ChatMessage{
- {Role: "user", Content: json.RawMessage(content)},
- },
- }
- resp, err := ChatCompletionsToResponses(req)
- require.NoError(t, err)
- var items []ResponsesInputItem
- require.NoError(t, json.Unmarshal(resp.Input, &items))
- require.Len(t, items, 1)
- var parts []ResponsesContentPart
- require.NoError(t, json.Unmarshal(items[0].Content, &parts))
- require.Len(t, parts, 1)
- assert.Equal(t, "input_text", parts[0].Type)
- assert.Equal(t, "Describe this", parts[0].Text)
- }
- func TestChatCompletionsToResponses_WhitespaceOnlyBase64ImageURLSkipped(t *testing.T) {
- content := `[{"type":"text","text":"Describe this"},{"type":"image_url","image_url":{"url":"data:image/png;base64, "}}]`
- req := &ChatCompletionsRequest{
- Model: "gpt-4o",
- Messages: []ChatMessage{
- {Role: "user", Content: json.RawMessage(content)},
- },
- }
- resp, err := ChatCompletionsToResponses(req)
- require.NoError(t, err)
- var items []ResponsesInputItem
- require.NoError(t, json.Unmarshal(resp.Input, &items))
- require.Len(t, items, 1)
- var parts []ResponsesContentPart
- require.NoError(t, json.Unmarshal(items[0].Content, &parts))
- require.Len(t, parts, 1)
- assert.Equal(t, "input_text", parts[0].Type)
- assert.Equal(t, "Describe this", parts[0].Text)
- }
- func TestChatCompletionsToResponses_FilePartFileData(t *testing.T) {
- content := `[{"type":"text","text":"Summarize the attached document"},{"type":"file","file":{"filename":"document.pdf","file_data":"data:application/pdf;base64,JVBERi0xLjQ="}}]`
- req := &ChatCompletionsRequest{
- Model: "gpt-4o",
- Messages: []ChatMessage{
- {Role: "user", Content: json.RawMessage(content)},
- },
- }
- resp, err := ChatCompletionsToResponses(req)
- require.NoError(t, err)
- var items []ResponsesInputItem
- require.NoError(t, json.Unmarshal(resp.Input, &items))
- require.Len(t, items, 1)
- var parts []ResponsesContentPart
- require.NoError(t, json.Unmarshal(items[0].Content, &parts))
- require.Len(t, parts, 2)
- assert.Equal(t, "input_text", parts[0].Type)
- assert.Equal(t, "Summarize the attached document", parts[0].Text)
- assert.Equal(t, "input_file", parts[1].Type)
- assert.Equal(t, "document.pdf", parts[1].Filename)
- assert.Equal(t, "data:application/pdf;base64,JVBERi0xLjQ=", parts[1].FileData)
- assert.Empty(t, parts[1].FileID)
- }
- func TestChatCompletionsToResponses_FilePartFileID(t *testing.T) {
- content := `[{"type":"file","file":{"file_id":"file-abc123"}}]`
- req := &ChatCompletionsRequest{
- Model: "gpt-4o",
- Messages: []ChatMessage{
- {Role: "user", Content: json.RawMessage(content)},
- },
- }
- resp, err := ChatCompletionsToResponses(req)
- require.NoError(t, err)
- var items []ResponsesInputItem
- require.NoError(t, json.Unmarshal(resp.Input, &items))
- require.Len(t, items, 1)
- var parts []ResponsesContentPart
- require.NoError(t, json.Unmarshal(items[0].Content, &parts))
- require.Len(t, parts, 1)
- assert.Equal(t, "input_file", parts[0].Type)
- assert.Equal(t, "file-abc123", parts[0].FileID)
- assert.Empty(t, parts[0].FileData)
- }
- func TestChatCompletionsToResponses_EmptyFilePartSkipped(t *testing.T) {
- // A file part with neither file_data nor file_id carries nothing the
- // Responses API can use; dropping it (like empty image URLs) avoids an
- // upstream 400 on an empty input_file part.
- content := `[{"type":"text","text":"Describe this"},{"type":"file","file":{"filename":"empty.pdf"}}]`
- req := &ChatCompletionsRequest{
- Model: "gpt-4o",
- Messages: []ChatMessage{
- {Role: "user", Content: json.RawMessage(content)},
- },
- }
- resp, err := ChatCompletionsToResponses(req)
- require.NoError(t, err)
- var items []ResponsesInputItem
- require.NoError(t, json.Unmarshal(resp.Input, &items))
- require.Len(t, items, 1)
- var parts []ResponsesContentPart
- require.NoError(t, json.Unmarshal(items[0].Content, &parts))
- require.Len(t, parts, 1)
- assert.Equal(t, "input_text", parts[0].Type)
- }
- func TestChatCompletionsToResponses_EmptyContentNeverNull(t *testing.T) {
- // Regression for #2515: the upstream Responses API rejects an input item
- // whose content field is JSON null. Any chat-completions message that
- // yields no usable content parts must serialize content as a string.
- cases := []struct {
- name string
- content json.RawMessage
- }{
- {"null content", json.RawMessage(`null`)},
- {"empty array content", json.RawMessage(`[]`)},
- {"only empty text part", json.RawMessage(`[{"type":"text","text":""}]`)},
- {"only empty base64 image part", json.RawMessage(`[{"type":"image_url","image_url":{"url":"data:image/png;base64,"}}]`)},
- }
- for _, tc := range cases {
- t.Run(tc.name, func(t *testing.T) {
- req := &ChatCompletionsRequest{
- Model: "gpt-5.5",
- Messages: []ChatMessage{
- {Role: "user", Content: tc.content},
- },
- }
- resp, err := ChatCompletionsToResponses(req)
- require.NoError(t, err)
- assert.NotContains(t, string(resp.Input), `"content":null`,
- "converted input must not contain a null content field")
- var items []ResponsesInputItem
- require.NoError(t, json.Unmarshal(resp.Input, &items))
- require.Len(t, items, 1)
- assert.Equal(t, `""`, string(items[0].Content),
- "content must be an empty string, not null")
- })
- }
- }
- func TestChatCompletionsResponseToResponses_DeepSeekReasoningOnlyFallsBackToMessageText(t *testing.T) {
- content := json.RawMessage(`""`)
- resp := &ChatCompletionsResponse{
- ID: "chatcmpl_deepseek_reasoning_only",
- Object: "chat.completion",
- Model: "deepseek-reasoner",
- Choices: []ChatChoice{{
- Index: 0,
- Message: ChatMessage{
- Role: "assistant",
- Content: content,
- ReasoningContent: "reasoning-only answer",
- },
- FinishReason: "stop",
- }},
- }
- out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner", nil, nil, false, nil)
- require.Len(t, out.Output, 2)
- require.Equal(t, "reasoning", out.Output[0].Type)
- require.Equal(t, "message", out.Output[1].Type)
- require.Len(t, out.Output[1].Content, 1)
- assert.Equal(t, "reasoning-only answer", out.Output[1].Content[0].Text)
- }
- func TestChatCompletionsResponseToResponses_DeepSeekReasoningToolCallDoesNotFallbackToMessageText(t *testing.T) {
- content := json.RawMessage(`""`)
- resp := &ChatCompletionsResponse{
- ID: "chatcmpl_deepseek_reasoning_tool",
- Object: "chat.completion",
- Model: "deepseek-reasoner",
- Choices: []ChatChoice{{
- Index: 0,
- Message: ChatMessage{
- Role: "assistant",
- Content: content,
- ReasoningContent: "call a tool",
- ToolCalls: []ChatToolCall{{
- ID: "call_a",
- Type: "function",
- Function: ChatFunctionCall{
- Name: "exec",
- Arguments: `{}`,
- },
- }},
- },
- FinishReason: "tool_calls",
- }},
- }
- out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner", nil, nil, false, nil)
- require.Len(t, out.Output, 2)
- require.Equal(t, "reasoning", out.Output[0].Type)
- require.Equal(t, "function_call", out.Output[1].Type)
- assert.Equal(t, "exec", out.Output[1].Name)
- }
- func TestChatCompletionsToResponses_SystemArrayContent(t *testing.T) {
- req := &ChatCompletionsRequest{
- Model: "gpt-4o",
- Messages: []ChatMessage{
- {Role: "system", Content: json.RawMessage(`[{"type":"text","text":"You are a careful visual assistant."}]`)},
- {Role: "user", Content: json.RawMessage(`[{"type":"text","text":"Describe this image"},{"type":"image_url","image_url":{"url":"data:image/png;base64,abc123"}}]`)},
- },
- }
- resp, err := ChatCompletionsToResponses(req)
- require.NoError(t, err)
- var items []ResponsesInputItem
- require.NoError(t, json.Unmarshal(resp.Input, &items))
- require.Len(t, items, 2)
- var systemParts []ResponsesContentPart
- require.NoError(t, json.Unmarshal(items[0].Content, &systemParts))
- require.Len(t, systemParts, 1)
- assert.Equal(t, "input_text", systemParts[0].Type)
- assert.Equal(t, "You are a careful visual assistant.", systemParts[0].Text)
- var userParts []ResponsesContentPart
- require.NoError(t, json.Unmarshal(items[1].Content, &userParts))
- require.Len(t, userParts, 2)
- assert.Equal(t, "input_image", userParts[1].Type)
- assert.Equal(t, "data:image/png;base64,abc123", userParts[1].ImageURL)
- }
- func TestChatCompletionsToResponses_LegacyFunctions(t *testing.T) {
- req := &ChatCompletionsRequest{
- Model: "gpt-4o",
- Messages: []ChatMessage{
- {Role: "user", Content: json.RawMessage(`"Hi"`)},
- },
- Functions: []ChatFunction{
- {
- Name: "get_weather",
- Description: "Get weather",
- Parameters: json.RawMessage(`{"type":"object"}`),
- },
- },
- FunctionCall: json.RawMessage(`{"name":"get_weather"}`),
- }
- resp, err := ChatCompletionsToResponses(req)
- require.NoError(t, err)
- require.Len(t, resp.Tools, 1)
- assert.Equal(t, "function", resp.Tools[0].Type)
- assert.Equal(t, "get_weather", resp.Tools[0].Name)
- // tool_choice should be converted
- require.NotNil(t, resp.ToolChoice)
- var tc map[string]any
- require.NoError(t, json.Unmarshal(resp.ToolChoice, &tc))
- assert.Equal(t, "function", tc["type"])
- assert.Equal(t, "get_weather", tc["name"])
- assert.NotContains(t, tc, "function")
- }
- func TestChatCompletionsToResponses_ServiceTier(t *testing.T) {
- req := &ChatCompletionsRequest{
- Model: "gpt-4o",
- ServiceTier: "flex",
- Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"Hi"`)}},
- }
- resp, err := ChatCompletionsToResponses(req)
- require.NoError(t, err)
- assert.Equal(t, "flex", resp.ServiceTier)
- }
- func TestChatCompletionsToResponses_ParallelToolCalls(t *testing.T) {
- for _, value := range []bool{false, true} {
- req := &ChatCompletionsRequest{
- Model: "gpt-4o",
- ParallelToolCalls: &value,
- Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"Hi"`)}},
- }
- resp, err := ChatCompletionsToResponses(req)
- require.NoError(t, err)
- require.NotNil(t, resp.ParallelToolCalls)
- assert.Equal(t, value, *resp.ParallelToolCalls)
- payload, err := json.Marshal(resp)
- require.NoError(t, err)
- assert.Contains(t, string(payload), `"parallel_tool_calls":`+string(mustMarshalJSON(t, value)))
- }
- }
- // ---------------------------------------------------------------------------
- // temperature / top_p stripping for reasoning models
- // ---------------------------------------------------------------------------
- func TestChatCompletionsToResponses_TemperatureStrippedForReasoningModel(t *testing.T) {
- temp := 0.7
- req := &ChatCompletionsRequest{
- Model: "gpt-5.2",
- Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"Hi"`)}},
- Temperature: &temp,
- TopP: &temp,
- }
- resp, err := ChatCompletionsToResponses(req)
- require.NoError(t, err)
- assert.Nil(t, resp.Temperature, "reasoning model: temperature must be stripped")
- assert.Nil(t, resp.TopP, "reasoning model: top_p must be stripped")
- // Must not appear in the serialised request body sent to the upstream.
- b, err := json.Marshal(resp)
- require.NoError(t, err)
- assert.NotContains(t, string(b), `"temperature"`)
- assert.NotContains(t, string(b), `"top_p"`)
- }
- func TestChatCompletionsToResponses_TemperaturePreservedForNonReasoningModel(t *testing.T) {
- temp := 0.7
- req := &ChatCompletionsRequest{
- Model: "gpt-4o",
- Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"Hi"`)}},
- Temperature: &temp,
- TopP: &temp,
- }
- resp, err := ChatCompletionsToResponses(req)
- require.NoError(t, err)
- require.NotNil(t, resp.Temperature, "non-reasoning model: temperature must be preserved")
- assert.InDelta(t, 0.7, *resp.Temperature, 1e-9)
- require.NotNil(t, resp.TopP, "non-reasoning model: top_p must be preserved")
- assert.InDelta(t, 0.7, *resp.TopP, 1e-9)
- }
- func TestChatCompletionsToResponses_AssistantWithTextAndToolCalls(t *testing.T) {
- req := &ChatCompletionsRequest{
- Model: "gpt-4o",
- Messages: []ChatMessage{
- {Role: "user", Content: json.RawMessage(`"Do something"`)},
- {
- Role: "assistant",
- Content: json.RawMessage(`"Let me call a function."`),
- ToolCalls: []ChatToolCall{
- {
- ID: "call_abc",
- Type: "function",
- Function: ChatFunctionCall{
- Name: "do_thing",
- Arguments: `{}`,
- },
- },
- },
- },
- },
- }
- resp, err := ChatCompletionsToResponses(req)
- require.NoError(t, err)
- var items []ResponsesInputItem
- require.NoError(t, json.Unmarshal(resp.Input, &items))
- // user + assistant message (with text) + function_call
- require.Len(t, items, 3)
- assert.Equal(t, "user", items[0].Role)
- assert.Equal(t, "assistant", items[1].Role)
- assert.Equal(t, "function_call", items[2].Type)
- assert.Empty(t, items[2].ID)
- }
- func TestChatCompletionsToResponses_AssistantArrayContentPreserved(t *testing.T) {
- req := &ChatCompletionsRequest{
- Model: "gpt-4o",
- Messages: []ChatMessage{
- {Role: "user", Content: json.RawMessage(`"Hi"`)},
- {Role: "assistant", Content: json.RawMessage(`[{"type":"text","text":"A"},{"type":"text","text":"B"}]`)},
- },
- }
- resp, err := ChatCompletionsToResponses(req)
- require.NoError(t, err)
- var items []ResponsesInputItem
- require.NoError(t, json.Unmarshal(resp.Input, &items))
- require.Len(t, items, 2)
- assert.Equal(t, "assistant", items[1].Role)
- var parts []ResponsesContentPart
- require.NoError(t, json.Unmarshal(items[1].Content, &parts))
- require.Len(t, parts, 1)
- assert.Equal(t, "output_text", parts[0].Type)
- assert.Equal(t, "AB", parts[0].Text)
- }
- func TestChatCompletionsToResponses_AssistantThinkingTagPreserved(t *testing.T) {
- req := &ChatCompletionsRequest{
- Model: "gpt-4o",
- Messages: []ChatMessage{
- {Role: "user", Content: json.RawMessage(`"Hi"`)},
- {Role: "assistant", Content: json.RawMessage(`[{"type":"thinking","thinking":"internal plan"},{"type":"text","text":"final answer"}]`)},
- },
- }
- resp, err := ChatCompletionsToResponses(req)
- require.NoError(t, err)
- var items []ResponsesInputItem
- require.NoError(t, json.Unmarshal(resp.Input, &items))
- require.Len(t, items, 2)
- var parts []ResponsesContentPart
- require.NoError(t, json.Unmarshal(items[1].Content, &parts))
- require.Len(t, parts, 1)
- assert.Equal(t, "output_text", parts[0].Type)
- assert.Contains(t, parts[0].Text, "<thinking>internal plan</thinking>")
- assert.Contains(t, parts[0].Text, "final answer")
- }
- func TestChatCompletionsToResponses_AssistantReasoningContentPreserved(t *testing.T) {
- req := &ChatCompletionsRequest{
- Model: "gpt-4o",
- Messages: []ChatMessage{
- {Role: "user", Content: json.RawMessage(`"Hi"`)},
- {
- Role: "assistant",
- ReasoningContent: "internal plan",
- Content: json.RawMessage(`"final answer"`),
- },
- },
- }
- resp, err := ChatCompletionsToResponses(req)
- require.NoError(t, err)
- var items []ResponsesInputItem
- require.NoError(t, json.Unmarshal(resp.Input, &items))
- require.Len(t, items, 2)
- var parts []ResponsesContentPart
- require.NoError(t, json.Unmarshal(items[1].Content, &parts))
- require.Len(t, parts, 1)
- assert.Equal(t, "output_text", parts[0].Type)
- assert.Contains(t, parts[0].Text, "<thinking>internal plan</thinking>")
- assert.Contains(t, parts[0].Text, "final answer")
- }
- // ---------------------------------------------------------------------------
- // ResponsesToChatCompletions tests
- // ---------------------------------------------------------------------------
- func TestResponsesToChatCompletions_BasicText(t *testing.T) {
- resp := &ResponsesResponse{
- ID: "resp_123",
- Status: "completed",
- Output: []ResponsesOutput{
- {
- Type: "message",
- Content: []ResponsesContentPart{
- {Type: "output_text", Text: "Hello, world!"},
- },
- },
- },
- Usage: &ResponsesUsage{
- InputTokens: 10,
- OutputTokens: 5,
- TotalTokens: 15,
- },
- }
- chat := ResponsesToChatCompletions(resp, "gpt-4o")
- assert.Equal(t, "chat.completion", chat.Object)
- assert.Equal(t, "gpt-4o", chat.Model)
- require.Len(t, chat.Choices, 1)
- assert.Equal(t, "stop", chat.Choices[0].FinishReason)
- var content string
- require.NoError(t, json.Unmarshal(chat.Choices[0].Message.Content, &content))
- assert.Equal(t, "Hello, world!", content)
- require.NotNil(t, chat.Usage)
- assert.Equal(t, 10, chat.Usage.PromptTokens)
- assert.Equal(t, 5, chat.Usage.CompletionTokens)
- assert.Equal(t, 15, chat.Usage.TotalTokens)
- }
- func TestResponsesToChatCompletions_ToolCalls(t *testing.T) {
- resp := &ResponsesResponse{
- ID: "resp_456",
- Status: "completed",
- Output: []ResponsesOutput{
- {
- Type: "function_call",
- CallID: "call_xyz",
- Name: "get_weather",
- Arguments: `{"city":"NYC"}`,
- },
- },
- }
- chat := ResponsesToChatCompletions(resp, "gpt-4o")
- require.Len(t, chat.Choices, 1)
- assert.Equal(t, "tool_calls", chat.Choices[0].FinishReason)
- msg := chat.Choices[0].Message
- require.Len(t, msg.ToolCalls, 1)
- assert.Equal(t, "call_xyz", msg.ToolCalls[0].ID)
- assert.Equal(t, "function", msg.ToolCalls[0].Type)
- assert.Equal(t, "get_weather", msg.ToolCalls[0].Function.Name)
- assert.Equal(t, `{"city":"NYC"}`, msg.ToolCalls[0].Function.Arguments)
- }
- func TestResponsesToChatCompletions_Reasoning(t *testing.T) {
- resp := &ResponsesResponse{
- ID: "resp_789",
- Status: "completed",
- Output: []ResponsesOutput{
- {
- Type: "reasoning",
- Summary: []ResponsesSummary{
- {Type: "summary_text", Text: "I thought about it."},
- },
- },
- {
- Type: "message",
- Content: []ResponsesContentPart{
- {Type: "output_text", Text: "The answer is 42."},
- },
- },
- },
- }
- chat := ResponsesToChatCompletions(resp, "gpt-4o")
- require.Len(t, chat.Choices, 1)
- var content string
- require.NoError(t, json.Unmarshal(chat.Choices[0].Message.Content, &content))
- assert.Equal(t, "The answer is 42.", content)
- assert.Equal(t, "I thought about it.", chat.Choices[0].Message.ReasoningContent)
- }
- func TestChatCompletionsToResponses_ToolArrayContent(t *testing.T) {
- req := &ChatCompletionsRequest{
- Model: "gpt-4o",
- Messages: []ChatMessage{
- {Role: "user", Content: json.RawMessage(`"Use the tool"`)},
- {
- Role: "assistant",
- ToolCalls: []ChatToolCall{
- {
- ID: "call_1",
- Type: "function",
- Function: ChatFunctionCall{
- Name: "inspect_image",
- Arguments: `{}`,
- },
- },
- },
- },
- {
- Role: "tool",
- ToolCallID: "call_1",
- Content: json.RawMessage(
- `[{"type":"text","text":"image width: 100"},{"type":"image_url","image_url":{"url":"data:image/png;base64,ignored"}},{"type":"text","text":"; image height: 200"}]`,
- ),
- },
- },
- }
- resp, err := ChatCompletionsToResponses(req)
- require.NoError(t, err)
- var items []ResponsesInputItem
- require.NoError(t, json.Unmarshal(resp.Input, &items))
- require.Len(t, items, 3)
- assert.Equal(t, "function_call_output", items[2].Type)
- assert.Equal(t, "call_1", items[2].CallID)
- assert.Equal(t, "image width: 100; image height: 200", items[2].Output)
- }
- func TestResponsesToChatCompletions_Incomplete(t *testing.T) {
- resp := &ResponsesResponse{
- ID: "resp_inc",
- Status: "incomplete",
- IncompleteDetails: &ResponsesIncompleteDetails{Reason: "max_output_tokens"},
- Output: []ResponsesOutput{
- {
- Type: "message",
- Content: []ResponsesContentPart{
- {Type: "output_text", Text: "partial..."},
- },
- },
- },
- }
- chat := ResponsesToChatCompletions(resp, "gpt-4o")
- require.Len(t, chat.Choices, 1)
- assert.Equal(t, "length", chat.Choices[0].FinishReason)
- }
- func TestResponsesToChatCompletions_CachedTokens(t *testing.T) {
- resp := &ResponsesResponse{
- ID: "resp_cache",
- Status: "completed",
- Output: []ResponsesOutput{
- {
- Type: "message",
- Content: []ResponsesContentPart{{Type: "output_text", Text: "cached"}},
- },
- },
- Usage: &ResponsesUsage{
- InputTokens: 100,
- OutputTokens: 10,
- TotalTokens: 110,
- InputTokensDetails: &ResponsesInputTokensDetails{
- CachedTokens: 80,
- },
- },
- }
- chat := ResponsesToChatCompletions(resp, "gpt-4o")
- require.NotNil(t, chat.Usage)
- require.NotNil(t, chat.Usage.PromptTokensDetails)
- assert.Equal(t, 80, chat.Usage.PromptTokensDetails.CachedTokens)
- }
- func TestResponsesToChatCompletions_ReasoningTokens(t *testing.T) {
- resp := &ResponsesResponse{
- ID: "resp_reasoning",
- Status: "completed",
- Output: []ResponsesOutput{
- {
- Type: "message",
- Content: []ResponsesContentPart{{Type: "output_text", Text: "ping"}},
- },
- },
- Usage: &ResponsesUsage{
- InputTokens: 24,
- OutputTokens: 33,
- TotalTokens: 57,
- OutputTokensDetails: &ResponsesOutputTokensDetails{
- ReasoningTokens: 32,
- },
- },
- }
- chat := ResponsesToChatCompletions(resp, "gpt-5.5")
- require.NotNil(t, chat.Usage)
- assert.Equal(t, 33, chat.Usage.CompletionTokens)
- require.NotNil(t, chat.Usage.CompletionTokensDetails)
- assert.Equal(t, 32, chat.Usage.CompletionTokensDetails.ReasoningTokens)
- }
- func TestResponsesToChatCompletions_AllTokenDetailsPassThrough(t *testing.T) {
- // Covers the full OpenAI CompletionUsage detail field set so future audio
- // and prediction-outputs responses propagate without further changes.
- resp := &ResponsesResponse{
- ID: "resp_full_details",
- Status: "completed",
- Output: []ResponsesOutput{
- {
- Type: "message",
- Content: []ResponsesContentPart{{Type: "output_text", Text: "x"}},
- },
- },
- Usage: &ResponsesUsage{
- InputTokens: 100,
- OutputTokens: 50,
- TotalTokens: 150,
- InputTokensDetails: &ResponsesInputTokensDetails{
- CachedTokens: 60,
- AudioTokens: 4,
- },
- OutputTokensDetails: &ResponsesOutputTokensDetails{
- ReasoningTokens: 30,
- AudioTokens: 2,
- AcceptedPredictionTokens: 10,
- RejectedPredictionTokens: 3,
- },
- },
- }
- chat := ResponsesToChatCompletions(resp, "gpt-5.5")
- require.NotNil(t, chat.Usage)
- require.NotNil(t, chat.Usage.PromptTokensDetails)
- assert.Equal(t, 60, chat.Usage.PromptTokensDetails.CachedTokens)
- assert.Equal(t, 4, chat.Usage.PromptTokensDetails.AudioTokens)
- require.NotNil(t, chat.Usage.CompletionTokensDetails)
- assert.Equal(t, 30, chat.Usage.CompletionTokensDetails.ReasoningTokens)
- assert.Equal(t, 2, chat.Usage.CompletionTokensDetails.AudioTokens)
- assert.Equal(t, 10, chat.Usage.CompletionTokensDetails.AcceptedPredictionTokens)
- assert.Equal(t, 3, chat.Usage.CompletionTokensDetails.RejectedPredictionTokens)
- raw, err := json.Marshal(chat.Usage)
- require.NoError(t, err)
- assert.Contains(t, string(raw), `"prompt_tokens_details"`)
- assert.Contains(t, string(raw), `"completion_tokens_details"`)
- assert.Contains(t, string(raw), `"reasoning_tokens":30`)
- assert.Contains(t, string(raw), `"accepted_prediction_tokens":10`)
- }
- func TestResponsesToChatCompletions_NoReasoningTokensWhenZero(t *testing.T) {
- // Non-reasoning models do not return reasoning_tokens. The mapping must
- // omit completion_tokens_details entirely rather than emitting a zero-valued
- // field, so non-reasoning responses stay clean.
- resp := &ResponsesResponse{
- ID: "resp_no_reasoning",
- Status: "completed",
- Output: []ResponsesOutput{
- {
- Type: "message",
- Content: []ResponsesContentPart{{Type: "output_text", Text: "hi"}},
- },
- },
- Usage: &ResponsesUsage{
- InputTokens: 10,
- OutputTokens: 5,
- TotalTokens: 15,
- OutputTokensDetails: &ResponsesOutputTokensDetails{
- ReasoningTokens: 0,
- },
- },
- }
- chat := ResponsesToChatCompletions(resp, "gpt-4o")
- require.NotNil(t, chat.Usage)
- assert.Nil(t, chat.Usage.CompletionTokensDetails)
- raw, err := json.Marshal(chat.Usage)
- require.NoError(t, err)
- assert.NotContains(t, string(raw), "completion_tokens_details")
- assert.NotContains(t, string(raw), "reasoning_tokens")
- }
- func TestResponsesToChatCompletions_WebSearch(t *testing.T) {
- resp := &ResponsesResponse{
- ID: "resp_ws",
- Status: "completed",
- Output: []ResponsesOutput{
- {
- Type: "web_search_call",
- Action: &WebSearchAction{Type: "search", Query: "test"},
- },
- {
- Type: "message",
- Content: []ResponsesContentPart{{Type: "output_text", Text: "search results"}},
- },
- },
- }
- chat := ResponsesToChatCompletions(resp, "gpt-4o")
- require.Len(t, chat.Choices, 1)
- assert.Equal(t, "stop", chat.Choices[0].FinishReason)
- var content string
- require.NoError(t, json.Unmarshal(chat.Choices[0].Message.Content, &content))
- assert.Equal(t, "search results", content)
- }
- // ---------------------------------------------------------------------------
- // Streaming: ResponsesEventToChatChunks tests
- // ---------------------------------------------------------------------------
- func TestResponsesEventToChatChunks_TextDelta(t *testing.T) {
- state := NewResponsesEventToChatState()
- state.Model = "gpt-4o"
- // response.created → role chunk
- chunks := ResponsesEventToChatChunks(&ResponsesStreamEvent{
- Type: "response.created",
- Response: &ResponsesResponse{
- ID: "resp_stream",
- },
- }, state)
- require.Len(t, chunks, 1)
- assert.Equal(t, "assistant", chunks[0].Choices[0].Delta.Role)
- assert.True(t, state.SentRole)
- // response.output_text.delta → content chunk
- chunks = ResponsesEventToChatChunks(&ResponsesStreamEvent{
- Type: "response.output_text.delta",
- Delta: "Hello",
- }, state)
- require.Len(t, chunks, 1)
- require.NotNil(t, chunks[0].Choices[0].Delta.Content)
- assert.Equal(t, "Hello", *chunks[0].Choices[0].Delta.Content)
- }
- func TestResponsesEventToChatChunks_ToolCallDelta(t *testing.T) {
- state := NewResponsesEventToChatState()
- state.Model = "gpt-4o"
- state.SentRole = true
- // response.output_item.added (function_call) — output_index=1 (e.g. after a message item at 0)
- chunks := ResponsesEventToChatChunks(&ResponsesStreamEvent{
- Type: "response.output_item.added",
- OutputIndex: 1,
- Item: &ResponsesOutput{
- Type: "function_call",
- CallID: "call_1",
- Name: "get_weather",
- },
- }, state)
- require.Len(t, chunks, 1)
- require.Len(t, chunks[0].Choices[0].Delta.ToolCalls, 1)
- tc := chunks[0].Choices[0].Delta.ToolCalls[0]
- assert.Equal(t, "call_1", tc.ID)
- assert.Equal(t, "get_weather", tc.Function.Name)
- require.NotNil(t, tc.Index)
- assert.Equal(t, 0, *tc.Index)
- // response.function_call_arguments.delta — uses output_index (NOT call_id) to find tool
- chunks = ResponsesEventToChatChunks(&ResponsesStreamEvent{
- Type: "response.function_call_arguments.delta",
- OutputIndex: 1, // matches the output_index from output_item.added above
- Delta: `{"city":`,
- }, state)
- require.Len(t, chunks, 1)
- tc = chunks[0].Choices[0].Delta.ToolCalls[0]
- require.NotNil(t, tc.Index)
- assert.Equal(t, 0, *tc.Index, "argument delta must use same index as the tool call")
- assert.Equal(t, `{"city":`, tc.Function.Arguments)
- // Add a second function call at output_index=2
- chunks = ResponsesEventToChatChunks(&ResponsesStreamEvent{
- Type: "response.output_item.added",
- OutputIndex: 2,
- Item: &ResponsesOutput{
- Type: "function_call",
- CallID: "call_2",
- Name: "get_time",
- },
- }, state)
- require.Len(t, chunks, 1)
- tc = chunks[0].Choices[0].Delta.ToolCalls[0]
- require.NotNil(t, tc.Index)
- assert.Equal(t, 1, *tc.Index, "second tool call should get index 1")
- // Argument delta for second tool call
- chunks = ResponsesEventToChatChunks(&ResponsesStreamEvent{
- Type: "response.function_call_arguments.delta",
- OutputIndex: 2,
- Delta: `{"tz":"UTC"}`,
- }, state)
- require.Len(t, chunks, 1)
- tc = chunks[0].Choices[0].Delta.ToolCalls[0]
- require.NotNil(t, tc.Index)
- assert.Equal(t, 1, *tc.Index, "second tool arg delta must use index 1")
- // Argument delta for first tool call (interleaved)
- chunks = ResponsesEventToChatChunks(&ResponsesStreamEvent{
- Type: "response.function_call_arguments.delta",
- OutputIndex: 1,
- Delta: `"Tokyo"}`,
- }, state)
- require.Len(t, chunks, 1)
- tc = chunks[0].Choices[0].Delta.ToolCalls[0]
- require.NotNil(t, tc.Index)
- assert.Equal(t, 0, *tc.Index, "first tool arg delta must still use index 0")
- }
- func TestResponsesEventToChatChunks_Completed(t *testing.T) {
- state := NewResponsesEventToChatState()
- state.Model = "gpt-4o"
- state.IncludeUsage = true
- chunks := ResponsesEventToChatChunks(&ResponsesStreamEvent{
- Type: "response.completed",
- Response: &ResponsesResponse{
- Status: "completed",
- Usage: &ResponsesUsage{
- InputTokens: 50,
- OutputTokens: 20,
- TotalTokens: 70,
- InputTokensDetails: &ResponsesInputTokensDetails{
- CachedTokens: 30,
- },
- },
- },
- }, state)
- // finish chunk + usage chunk
- require.Len(t, chunks, 2)
- // First chunk: finish_reason
- require.NotNil(t, chunks[0].Choices[0].FinishReason)
- assert.Equal(t, "stop", *chunks[0].Choices[0].FinishReason)
- // Second chunk: usage
- require.NotNil(t, chunks[1].Usage)
- assert.Equal(t, 50, chunks[1].Usage.PromptTokens)
- assert.Equal(t, 20, chunks[1].Usage.CompletionTokens)
- assert.Equal(t, 70, chunks[1].Usage.TotalTokens)
- require.NotNil(t, chunks[1].Usage.PromptTokensDetails)
- assert.Equal(t, 30, chunks[1].Usage.PromptTokensDetails.CachedTokens)
- }
- func TestResponsesEventToChatChunks_CompletedWithReasoningTokens(t *testing.T) {
- state := NewResponsesEventToChatState()
- state.Model = "gpt-5.5"
- state.IncludeUsage = true
- chunks := ResponsesEventToChatChunks(&ResponsesStreamEvent{
- Type: "response.completed",
- Response: &ResponsesResponse{
- Status: "completed",
- Usage: &ResponsesUsage{
- InputTokens: 24,
- OutputTokens: 33,
- TotalTokens: 57,
- OutputTokensDetails: &ResponsesOutputTokensDetails{
- ReasoningTokens: 32,
- },
- },
- },
- }, state)
- require.Len(t, chunks, 2)
- require.NotNil(t, chunks[1].Usage)
- require.NotNil(t, chunks[1].Usage.CompletionTokensDetails)
- assert.Equal(t, 32, chunks[1].Usage.CompletionTokensDetails.ReasoningTokens)
- }
- func TestResponsesEventToChatChunks_ResponseDone(t *testing.T) {
- state := NewResponsesEventToChatState()
- state.Model = "gpt-4o"
- state.IncludeUsage = true
- chunks := ResponsesEventToChatChunks(&ResponsesStreamEvent{
- Type: "response.done",
- Response: &ResponsesResponse{
- Status: "completed",
- Usage: &ResponsesUsage{InputTokens: 13, OutputTokens: 7},
- },
- }, state)
- require.Len(t, chunks, 2)
- require.NotNil(t, chunks[0].Choices[0].FinishReason)
- assert.Equal(t, "stop", *chunks[0].Choices[0].FinishReason)
- require.NotNil(t, chunks[1].Usage)
- assert.Equal(t, 13, chunks[1].Usage.PromptTokens)
- assert.Equal(t, 7, chunks[1].Usage.CompletionTokens)
- assert.Nil(t, FinalizeResponsesChatStream(state))
- }
- func TestResponsesEventToChatChunks_TopLevelTerminalUsage(t *testing.T) {
- state := NewResponsesEventToChatState()
- state.Model = "gpt-4o"
- state.IncludeUsage = true
- chunks := ResponsesEventToChatChunks(&ResponsesStreamEvent{
- Type: "response.completed",
- Response: &ResponsesResponse{
- Status: "completed",
- },
- Usage: &ResponsesUsage{
- InputTokens: 21,
- OutputTokens: 9,
- InputTokensDetails: &ResponsesInputTokensDetails{
- CachedTokens: 4,
- },
- },
- }, state)
- require.Len(t, chunks, 2)
- require.NotNil(t, chunks[1].Usage)
- assert.Equal(t, 21, chunks[1].Usage.PromptTokens)
- assert.Equal(t, 9, chunks[1].Usage.CompletionTokens)
- require.NotNil(t, chunks[1].Usage.PromptTokensDetails)
- assert.Equal(t, 4, chunks[1].Usage.PromptTokensDetails.CachedTokens)
- }
- func TestResponsesEventToChatChunks_ResponseDoneIncomplete(t *testing.T) {
- state := NewResponsesEventToChatState()
- state.Model = "gpt-4o"
- state.IncludeUsage = true
- chunks := ResponsesEventToChatChunks(&ResponsesStreamEvent{
- Type: "response.done",
- Response: &ResponsesResponse{
- Status: "incomplete",
- IncompleteDetails: &ResponsesIncompleteDetails{Reason: "max_output_tokens"},
- Usage: &ResponsesUsage{InputTokens: 13, OutputTokens: 7},
- },
- }, state)
- require.Len(t, chunks, 2)
- require.NotNil(t, chunks[0].Choices[0].FinishReason)
- assert.Equal(t, "length", *chunks[0].Choices[0].FinishReason)
- require.NotNil(t, chunks[1].Usage)
- assert.Equal(t, 13, chunks[1].Usage.PromptTokens)
- assert.Equal(t, 7, chunks[1].Usage.CompletionTokens)
- assert.Nil(t, FinalizeResponsesChatStream(state))
- }
- func TestResponsesEventToChatChunks_CompletedWithToolCalls(t *testing.T) {
- state := NewResponsesEventToChatState()
- state.Model = "gpt-4o"
- state.SawToolCall = true
- chunks := ResponsesEventToChatChunks(&ResponsesStreamEvent{
- Type: "response.completed",
- Response: &ResponsesResponse{
- Status: "completed",
- },
- }, state)
- require.Len(t, chunks, 1)
- require.NotNil(t, chunks[0].Choices[0].FinishReason)
- assert.Equal(t, "tool_calls", *chunks[0].Choices[0].FinishReason)
- }
- func TestResponsesEventToChatChunks_ReasoningDelta(t *testing.T) {
- state := NewResponsesEventToChatState()
- state.Model = "gpt-4o"
- state.SentRole = true
- chunks := ResponsesEventToChatChunks(&ResponsesStreamEvent{
- Type: "response.reasoning_summary_text.delta",
- Delta: "Thinking...",
- }, state)
- require.Len(t, chunks, 1)
- require.NotNil(t, chunks[0].Choices[0].Delta.ReasoningContent)
- assert.Equal(t, "Thinking...", *chunks[0].Choices[0].Delta.ReasoningContent)
- chunks = ResponsesEventToChatChunks(&ResponsesStreamEvent{
- Type: "response.reasoning_summary_text.done",
- }, state)
- require.Len(t, chunks, 0)
- }
- func TestResponsesEventToChatChunks_ReasoningThenTextAutoCloseTag(t *testing.T) {
- state := NewResponsesEventToChatState()
- state.Model = "gpt-4o"
- state.SentRole = true
- chunks := ResponsesEventToChatChunks(&ResponsesStreamEvent{
- Type: "response.reasoning_summary_text.delta",
- Delta: "plan",
- }, state)
- require.Len(t, chunks, 1)
- require.NotNil(t, chunks[0].Choices[0].Delta.ReasoningContent)
- assert.Equal(t, "plan", *chunks[0].Choices[0].Delta.ReasoningContent)
- chunks = ResponsesEventToChatChunks(&ResponsesStreamEvent{
- Type: "response.output_text.delta",
- Delta: "answer",
- }, state)
- require.Len(t, chunks, 1)
- require.NotNil(t, chunks[0].Choices[0].Delta.Content)
- assert.Equal(t, "answer", *chunks[0].Choices[0].Delta.Content)
- }
- func TestFinalizeResponsesChatStream(t *testing.T) {
- state := NewResponsesEventToChatState()
- state.Model = "gpt-4o"
- state.IncludeUsage = true
- state.Usage = &ChatUsage{
- PromptTokens: 100,
- CompletionTokens: 50,
- TotalTokens: 150,
- }
- chunks := FinalizeResponsesChatStream(state)
- require.Len(t, chunks, 2)
- // Finish chunk
- require.NotNil(t, chunks[0].Choices[0].FinishReason)
- assert.Equal(t, "stop", *chunks[0].Choices[0].FinishReason)
- // Usage chunk
- require.NotNil(t, chunks[1].Usage)
- assert.Equal(t, 100, chunks[1].Usage.PromptTokens)
- // Idempotent: second call returns nil
- assert.Nil(t, FinalizeResponsesChatStream(state))
- }
- func TestFinalizeResponsesChatStream_AfterCompleted(t *testing.T) {
- // If response.completed already emitted the finish chunk, FinalizeResponsesChatStream
- // must be a no-op (prevents double finish_reason being sent to the client).
- state := NewResponsesEventToChatState()
- state.Model = "gpt-4o"
- state.IncludeUsage = true
- // Simulate response.completed
- chunks := ResponsesEventToChatChunks(&ResponsesStreamEvent{
- Type: "response.completed",
- Response: &ResponsesResponse{
- Status: "completed",
- Usage: &ResponsesUsage{
- InputTokens: 10,
- OutputTokens: 5,
- TotalTokens: 15,
- },
- },
- }, state)
- require.NotEmpty(t, chunks) // finish + usage chunks
- // Now FinalizeResponsesChatStream should return nil — already finalized.
- assert.Nil(t, FinalizeResponsesChatStream(state))
- }
- func TestChatChunkToSSE(t *testing.T) {
- chunk := ChatCompletionsChunk{
- ID: "chatcmpl-test",
- Object: "chat.completion.chunk",
- Created: 1700000000,
- Model: "gpt-4o",
- Choices: []ChatChunkChoice{
- {
- Index: 0,
- Delta: ChatDelta{Role: "assistant"},
- FinishReason: nil,
- },
- },
- }
- sse, err := ChatChunkToSSE(chunk)
- require.NoError(t, err)
- assert.Contains(t, sse, "data: ")
- assert.Contains(t, sse, "chatcmpl-test")
- assert.Contains(t, sse, "assistant")
- assert.True(t, len(sse) > 10)
- }
- // ---------------------------------------------------------------------------
- // Stream round-trip test
- // ---------------------------------------------------------------------------
- func TestChatCompletionsStreamRoundTrip(t *testing.T) {
- // Simulate: client sends chat completions request, upstream returns Responses SSE events.
- // Verify that the streaming state machine produces correct chat completions chunks.
- state := NewResponsesEventToChatState()
- state.Model = "gpt-4o"
- state.IncludeUsage = true
- var allChunks []ChatCompletionsChunk
- // 1. response.created
- chunks := ResponsesEventToChatChunks(&ResponsesStreamEvent{
- Type: "response.created",
- Response: &ResponsesResponse{ID: "resp_rt"},
- }, state)
- allChunks = append(allChunks, chunks...)
- // 2. text deltas
- for _, text := range []string{"Hello", ", ", "world", "!"} {
- chunks = ResponsesEventToChatChunks(&ResponsesStreamEvent{
- Type: "response.output_text.delta",
- Delta: text,
- }, state)
- allChunks = append(allChunks, chunks...)
- }
- // 3. response.completed
- chunks = ResponsesEventToChatChunks(&ResponsesStreamEvent{
- Type: "response.completed",
- Response: &ResponsesResponse{
- Status: "completed",
- Usage: &ResponsesUsage{
- InputTokens: 10,
- OutputTokens: 4,
- TotalTokens: 14,
- },
- },
- }, state)
- allChunks = append(allChunks, chunks...)
- // Verify: role chunk + 4 text chunks + finish chunk + usage chunk = 7
- require.Len(t, allChunks, 7)
- // First chunk has role
- assert.Equal(t, "assistant", allChunks[0].Choices[0].Delta.Role)
- // Text chunks
- var fullText string
- for i := 1; i <= 4; i++ {
- require.NotNil(t, allChunks[i].Choices[0].Delta.Content)
- fullText += *allChunks[i].Choices[0].Delta.Content
- }
- assert.Equal(t, "Hello, world!", fullText)
- // Finish chunk
- require.NotNil(t, allChunks[5].Choices[0].FinishReason)
- assert.Equal(t, "stop", *allChunks[5].Choices[0].FinishReason)
- // Usage chunk
- require.NotNil(t, allChunks[6].Usage)
- assert.Equal(t, 10, allChunks[6].Usage.PromptTokens)
- assert.Equal(t, 4, allChunks[6].Usage.CompletionTokens)
- // All chunks share the same ID
- for _, c := range allChunks {
- assert.Equal(t, "resp_rt", c.ID)
- }
- }
- // ---------------------------------------------------------------------------
- // BufferedResponseAccumulator tests
- // ---------------------------------------------------------------------------
- func TestBufferedResponseAccumulator_TextOnly(t *testing.T) {
- acc := NewBufferedResponseAccumulator()
- acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: "Hello"})
- acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: ", world!"})
- assert.True(t, acc.HasContent())
- output := acc.BuildOutput()
- require.Len(t, output, 1)
- assert.Equal(t, "message", output[0].Type)
- assert.Equal(t, "assistant", output[0].Role)
- require.Len(t, output[0].Content, 1)
- assert.Equal(t, "output_text", output[0].Content[0].Type)
- assert.Equal(t, "Hello, world!", output[0].Content[0].Text)
- }
- func TestBufferedResponseAccumulator_ToolCalls(t *testing.T) {
- acc := NewBufferedResponseAccumulator()
- // Add function call at output_index=1
- acc.ProcessEvent(&ResponsesStreamEvent{
- Type: "response.output_item.added",
- OutputIndex: 1,
- Item: &ResponsesOutput{
- Type: "function_call",
- CallID: "call_abc",
- Name: "get_weather",
- },
- })
- acc.ProcessEvent(&ResponsesStreamEvent{
- Type: "response.function_call_arguments.delta",
- OutputIndex: 1,
- Delta: `{"city":`,
- })
- acc.ProcessEvent(&ResponsesStreamEvent{
- Type: "response.function_call_arguments.delta",
- OutputIndex: 1,
- Delta: `"NYC"}`,
- })
- assert.True(t, acc.HasContent())
- output := acc.BuildOutput()
- require.Len(t, output, 1)
- assert.Equal(t, "function_call", output[0].Type)
- assert.Equal(t, "call_abc", output[0].CallID)
- assert.Equal(t, "get_weather", output[0].Name)
- assert.Equal(t, `{"city":"NYC"}`, output[0].Arguments)
- }
- func TestResponsesEventToChatChunks_FunctionArgumentsDoneWithoutDeltas(t *testing.T) {
- state := NewResponsesEventToChatState()
- state.Model = "gpt-5-codex"
- state.SentRole = true
- chunks := ResponsesEventToChatChunks(&ResponsesStreamEvent{
- Type: "response.output_item.added",
- OutputIndex: 1,
- Item: &ResponsesOutput{
- Type: "function_call",
- CallID: "call_lookup",
- Name: "lookup_item",
- Arguments: "",
- },
- }, state)
- require.Len(t, chunks, 1)
- chunks = ResponsesEventToChatChunks(&ResponsesStreamEvent{
- Type: "response.function_call_arguments.done",
- OutputIndex: 1,
- CallID: "call_lookup",
- Name: "lookup_item",
- Arguments: `{"id":1}`,
- }, state)
- require.Len(t, chunks, 1)
- require.Len(t, chunks[0].Choices[0].Delta.ToolCalls, 1)
- assert.Equal(t, `{"id":1}`, chunks[0].Choices[0].Delta.ToolCalls[0].Function.Arguments)
- }
- func TestResponsesEventToChatChunks_FunctionArgumentsDoneDoesNotDuplicateDeltas(t *testing.T) {
- state := NewResponsesEventToChatState()
- state.Model = "gpt-5-codex"
- state.SentRole = true
- ResponsesEventToChatChunks(&ResponsesStreamEvent{
- Type: "response.output_item.added",
- OutputIndex: 0,
- Item: &ResponsesOutput{Type: "function_call", CallID: "call_lookup", Name: "lookup_item"},
- }, state)
- chunks := ResponsesEventToChatChunks(&ResponsesStreamEvent{
- Type: "response.function_call_arguments.delta",
- OutputIndex: 0,
- Delta: `{"id":1}`,
- }, state)
- require.Len(t, chunks, 1)
- chunks = ResponsesEventToChatChunks(&ResponsesStreamEvent{
- Type: "response.function_call_arguments.done",
- OutputIndex: 0,
- Arguments: `{"id":1}`,
- }, state)
- assert.Empty(t, chunks)
- }
- func TestBufferedResponseAccumulator_FunctionArgumentsDoneWithoutDeltas(t *testing.T) {
- acc := NewBufferedResponseAccumulator()
- acc.ProcessEvent(&ResponsesStreamEvent{
- Type: "response.output_item.added",
- OutputIndex: 1,
- Item: &ResponsesOutput{Type: "function_call", CallID: "call_lookup", Name: "lookup_item"},
- })
- acc.ProcessEvent(&ResponsesStreamEvent{
- Type: "response.function_call_arguments.done",
- OutputIndex: 1,
- Arguments: `{"id":1}`,
- })
- resp := &ResponsesResponse{Output: []ResponsesOutput{{
- Type: "function_call",
- CallID: "call_lookup",
- Name: "lookup_item",
- Arguments: "",
- }}}
- acc.SupplementResponseOutput(resp)
- require.Len(t, resp.Output, 1)
- assert.Equal(t, `{"id":1}`, resp.Output[0].Arguments)
- }
- func TestBufferedResponseAccumulator_FunctionArgumentsDoneReplacesDeltas(t *testing.T) {
- acc := NewBufferedResponseAccumulator()
- acc.ProcessEvent(&ResponsesStreamEvent{
- Type: "response.output_item.added",
- OutputIndex: 0,
- Item: &ResponsesOutput{Type: "function_call", CallID: "call_lookup", Name: "lookup_item"},
- })
- acc.ProcessEvent(&ResponsesStreamEvent{
- Type: "response.function_call_arguments.delta",
- OutputIndex: 0,
- Delta: `{"id":1}`,
- })
- acc.ProcessEvent(&ResponsesStreamEvent{
- Type: "response.function_call_arguments.done",
- OutputIndex: 0,
- Arguments: `{"id":1}`,
- })
- output := acc.BuildOutput()
- require.Len(t, output, 1)
- assert.Equal(t, `{"id":1}`, output[0].Arguments)
- }
- func TestBufferedResponseAccumulator_Reasoning(t *testing.T) {
- acc := NewBufferedResponseAccumulator()
- acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.reasoning_summary_text.delta", Delta: "Step 1: "})
- acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.reasoning_summary_text.delta", Delta: "think about it"})
- assert.True(t, acc.HasContent())
- output := acc.BuildOutput()
- require.Len(t, output, 1)
- assert.Equal(t, "reasoning", output[0].Type)
- require.Len(t, output[0].Summary, 1)
- assert.Equal(t, "summary_text", output[0].Summary[0].Type)
- assert.Equal(t, "Step 1: think about it", output[0].Summary[0].Text)
- }
- func TestBufferedResponseAccumulator_Mixed(t *testing.T) {
- acc := NewBufferedResponseAccumulator()
- // Reasoning first
- acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.reasoning_summary_text.delta", Delta: "I thought about it."})
- // Then text
- acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: "The answer is 42."})
- // Then a tool call
- acc.ProcessEvent(&ResponsesStreamEvent{
- Type: "response.output_item.added",
- OutputIndex: 2,
- Item: &ResponsesOutput{
- Type: "function_call",
- CallID: "call_1",
- Name: "verify",
- },
- })
- acc.ProcessEvent(&ResponsesStreamEvent{
- Type: "response.function_call_arguments.delta",
- OutputIndex: 2,
- Delta: `{}`,
- })
- assert.True(t, acc.HasContent())
- output := acc.BuildOutput()
- // Order: reasoning → message → function_calls
- require.Len(t, output, 3)
- assert.Equal(t, "reasoning", output[0].Type)
- assert.Equal(t, "message", output[1].Type)
- assert.Equal(t, "function_call", output[2].Type)
- assert.Equal(t, "The answer is 42.", output[1].Content[0].Text)
- assert.Equal(t, "verify", output[2].Name)
- }
- func TestBufferedResponseAccumulator_SupplementEmptyOutput(t *testing.T) {
- acc := NewBufferedResponseAccumulator()
- acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: "Hello"})
- resp := &ResponsesResponse{
- ID: "resp_1",
- Status: "completed",
- Output: nil, // empty output
- Usage: &ResponsesUsage{InputTokens: 10, OutputTokens: 5},
- }
- acc.SupplementResponseOutput(resp)
- require.Len(t, resp.Output, 1)
- assert.Equal(t, "message", resp.Output[0].Type)
- assert.Equal(t, "Hello", resp.Output[0].Content[0].Text)
- // Usage should be untouched
- assert.Equal(t, 10, resp.Usage.InputTokens)
- }
- func TestBufferedResponseAccumulator_NoSupplementWhenOutputExists(t *testing.T) {
- acc := NewBufferedResponseAccumulator()
- acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: "from deltas"})
- resp := &ResponsesResponse{
- ID: "resp_2",
- Status: "completed",
- Output: []ResponsesOutput{
- {
- Type: "message",
- Content: []ResponsesContentPart{
- {Type: "output_text", Text: "from terminal event"},
- },
- },
- },
- }
- acc.SupplementResponseOutput(resp)
- // Output should NOT be overwritten
- require.Len(t, resp.Output, 1)
- assert.Equal(t, "from terminal event", resp.Output[0].Content[0].Text)
- }
- func TestBufferedResponseAccumulator_EmptyDeltas(t *testing.T) {
- acc := NewBufferedResponseAccumulator()
- // Process events with empty delta — should not accumulate
- acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: ""})
- acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.created"})
- assert.False(t, acc.HasContent())
- resp := &ResponsesResponse{ID: "resp_3", Status: "completed"}
- acc.SupplementResponseOutput(resp)
- assert.Nil(t, resp.Output)
- }
- func TestBufferedResponseAccumulator_IgnoresNonFunctionCallItems(t *testing.T) {
- acc := NewBufferedResponseAccumulator()
- // output_item.added with type "message" should be ignored
- acc.ProcessEvent(&ResponsesStreamEvent{
- Type: "response.output_item.added",
- OutputIndex: 0,
- Item: &ResponsesOutput{Type: "message"},
- })
- assert.False(t, acc.HasContent())
- }
|