Preserve multi-tool context in OpenAI messages continuation
Claude Code can send one assistant turn with multiple tool_use blocks followed by a user turn containing matching tool_result blocks. The OpenAI /v1/messages compatibility path trimmed continuation input to the last user turn plus adjacent tool outputs, which could leave a function_call_output without its earlier function_call when previous_response_id was attached. This keeps all function_call items needed by retained function_call_output entries so the upstream Responses API can resolve every call_id. Constraint: Applies only to the OpenAI /v1/messages -> Responses compatibility continuation path. Rejected: Disable previous_response_id for all tool outputs | loses continuation and cache benefits for valid turns. Confidence: high Scope-risk: narrow Directive: Do not trim function_call_output entries without preserving their matching function_call call_id context. Tested: go test ./internal/service -run 'TestForwardAsAnthropic_(PreviousResponseIDKeepsMultiToolCallContext|AttachesPreviousResponseIDForCompatContinuation|OAuthPreservesClaudeCodeToolCallID)' -count=1 Tested: go test ./internal/service -run 'TestForwardAsAnthropic|TestApplyAnthropicCompatFullReplayGuard|TestOpenAICompat|Test.*ToolContinuation' -count=1 Tested: go test ./internal/pkg/apicompat -count=1 Related: #2337
This commit is contained in:
@@ -480,6 +480,61 @@ func TestForwardAsAnthropic_AttachesPreviousResponseIDForCompatContinuation(t *t
|
||||
require.Equal(t, "second", gjson.GetBytes(upstream.lastBody, "input.1.content.0.text").String())
|
||||
}
|
||||
|
||||
func TestForwardAsAnthropic_PreviousResponseIDKeepsMultiToolCallContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
upstream := &httpUpstreamRecorder{}
|
||||
svc := &OpenAIGatewayService{
|
||||
httpUpstream: upstream,
|
||||
cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
|
||||
}
|
||||
account := &Account{
|
||||
ID: 1,
|
||||
Name: "openai-apikey",
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "sk-test",
|
||||
"base_url": "https://api.openai.com/v1",
|
||||
},
|
||||
}
|
||||
|
||||
firstBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"inspect files"}],"stream":false}`)
|
||||
upstream.resp = openAICompatSSECompletedResponse("resp_first_tools", "gpt-5.3-codex")
|
||||
firstRec := httptest.NewRecorder()
|
||||
firstCtx, _ := gin.CreateTestContext(firstRec)
|
||||
firstCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(firstBody))
|
||||
firstCtx.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
firstResult, err := svc.ForwardAsAnthropic(context.Background(), firstCtx, account, firstBody, "stable-cache-key", "gpt-5.3-codex")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, firstResult)
|
||||
|
||||
secondBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"inspect files"},{"role":"assistant","content":[{"type":"tool_use","id":"call_one","name":"Read","input":{"file_path":"a.go"}},{"type":"tool_use","id":"call_two","name":"Read","input":{"file_path":"b.go"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"call_one","content":"package a"},{"type":"tool_result","tool_use_id":"call_two","content":"package b"},{"type":"text","text":"continue"}]}],"tools":[{"name":"Read","description":"read a file","input_schema":{"type":"object","properties":{"file_path":{"type":"string"}}}}],"stream":false}`)
|
||||
upstream.resp = openAICompatSSECompletedResponse("resp_second_tools", "gpt-5.3-codex")
|
||||
secondRec := httptest.NewRecorder()
|
||||
secondCtx, _ := gin.CreateTestContext(secondRec)
|
||||
secondCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(secondBody))
|
||||
secondCtx.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
secondResult, err := svc.ForwardAsAnthropic(context.Background(), secondCtx, account, secondBody, "stable-cache-key", "gpt-5.3-codex")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, secondResult)
|
||||
require.Equal(t, "resp_first_tools", gjson.GetBytes(upstream.lastBody, "previous_response_id").String())
|
||||
|
||||
require.Equal(t, "function_call", gjson.GetBytes(upstream.lastBody, "input.1.type").String())
|
||||
require.Equal(t, "call_one", gjson.GetBytes(upstream.lastBody, "input.1.call_id").String())
|
||||
require.Equal(t, "function_call", gjson.GetBytes(upstream.lastBody, "input.2.type").String())
|
||||
require.Equal(t, "call_two", gjson.GetBytes(upstream.lastBody, "input.2.call_id").String())
|
||||
require.Equal(t, "function_call_output", gjson.GetBytes(upstream.lastBody, "input.3.type").String())
|
||||
require.Equal(t, "call_one", gjson.GetBytes(upstream.lastBody, "input.3.call_id").String())
|
||||
require.Equal(t, "function_call_output", gjson.GetBytes(upstream.lastBody, "input.4.type").String())
|
||||
require.Equal(t, "call_two", gjson.GetBytes(upstream.lastBody, "input.4.call_id").String())
|
||||
require.Equal(t, "continue", gjson.GetBytes(upstream.lastBody, "input.5.content.0.text").String())
|
||||
}
|
||||
|
||||
func TestForwardAsAnthropic_ReplaysWithoutContinuationWhenPreviousResponseMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
Reference in New Issue
Block a user