diff --git a/backend/internal/service/openai_compat_model_test.go b/backend/internal/service/openai_compat_model_test.go index b52a616f85..11383d6a61 100644 --- a/backend/internal/service/openai_compat_model_test.go +++ b/backend/internal/service/openai_compat_model_test.go @@ -753,7 +753,7 @@ func TestForwardAsAnthropic_PreviousResponseIDKeepsMultiToolCallContext(t *testi require.Equal(t, "continue", gjson.GetBytes(upstream.lastBody, "input.5.content.0.text").String()) } -func TestForwardAsAnthropic_ReplaysWithoutContinuationWhenPreviousResponseMissing(t *testing.T) { +func TestForwardAsAnthropic_ReplaysFullToolHistoryWhenPreviousResponseUnavailable(t *testing.T) { t.Parallel() gin.SetMode(gin.TestMode) @@ -775,12 +775,12 @@ func TestForwardAsAnthropic_ReplaysWithoutContinuationWhenPreviousResponseMissin } svc.bindOpenAICompatSessionResponseID(context.Background(), nil, account, "stable-cache-key", "resp_missing") - secondBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"},{"role":"assistant","content":"ok"},{"role":"user","content":"second"}],"stream":false}`) + secondBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"},{"role":"assistant","content":[{"type":"tool_use","id":"call_1","name":"lookup","input":{"q":"first"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"call_1","content":"found"},{"type":"text","text":"second"}]}],"tools":[{"name":"lookup","input_schema":{"type":"object"}}],"stream":false}`) upstream.responses = []*http.Response{ { StatusCode: http.StatusBadRequest, Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_prev_missing"}}, - Body: io.NopCloser(strings.NewReader(`{"error":{"code":"previous_response_not_found","message":"previous response not found"}}`)), + Body: io.NopCloser(strings.NewReader(`{"error":{"message":"previous_response_id is not available for this user","type":"invalid_request_error"}}`)), }, openAICompatSSECompletedResponse("resp_replayed", "gpt-5.3-codex"), } @@ -797,11 +797,54 @@ func TestForwardAsAnthropic_ReplaysWithoutContinuationWhenPreviousResponseMissin require.Len(t, upstream.requests, 2) require.Equal(t, "resp_missing", gjson.GetBytes(upstream.bodies[0], "previous_response_id").String()) require.False(t, gjson.GetBytes(upstream.bodies[1], "previous_response_id").Exists()) - require.Equal(t, int64(4), gjson.GetBytes(upstream.bodies[1], "input.#").Int()) + require.Equal(t, int64(5), gjson.GetBytes(upstream.bodies[1], "input.#").Int()) require.Equal(t, "developer", gjson.GetBytes(upstream.bodies[1], "input.0.role").String()) require.Contains(t, gjson.GetBytes(upstream.bodies[1], "input.0.content.0.text").String(), "") require.Equal(t, "first", gjson.GetBytes(upstream.bodies[1], "input.1.content.0.text").String()) - require.Equal(t, "second", gjson.GetBytes(upstream.bodies[1], "input.3.content.0.text").String()) + require.Equal(t, "function_call", gjson.GetBytes(upstream.bodies[1], "input.2.type").String()) + require.Equal(t, "call_1", gjson.GetBytes(upstream.bodies[1], "input.2.call_id").String()) + require.Equal(t, "function_call_output", gjson.GetBytes(upstream.bodies[1], "input.3.type").String()) + require.Equal(t, "call_1", gjson.GetBytes(upstream.bodies[1], "input.3.call_id").String()) + require.Equal(t, "found", gjson.GetBytes(upstream.bodies[1], "input.3.output").String()) + require.Equal(t, "second", gjson.GetBytes(upstream.bodies[1], "input.4.content.0.text").String()) +} + +func TestOpenAICompatPreviousResponseUnavailableRecognitionIsStrict(t *testing.T) { + t.Parallel() + body := []byte(`{"error":{"message":"previous_response_id is not available for this user","type":"invalid_request_error"}}`) + require.True(t, isOpenAICompatPreviousResponseNotFound(http.StatusBadRequest, "", body)) + require.False(t, isOpenAICompatPreviousResponseNotFound(http.StatusForbidden, "", body)) + require.False(t, isOpenAICompatPreviousResponseNotFound(http.StatusBadRequest, "permission is not available for this user", nil)) + require.False(t, isOpenAICompatPreviousResponseNotFound(http.StatusBadRequest, "previous_response_id is not available for this project", nil)) +} + +func TestForwardAsAnthropic_PreviousResponseUnavailableRetryFailureDoesNotLoop(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + unavailable := func() *http.Response { + return &http.Response{ + StatusCode: http.StatusBadRequest, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"error":{"message":"previous_response_id is not available for this user","type":"invalid_request_error"}}`)), + } + } + upstream := &httpUpstreamRecorder{responses: []*http.Response{unavailable(), unavailable()}} + svc := &OpenAIGatewayService{ + httpUpstream: upstream, + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + } + account := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Concurrency: 1, Credentials: map[string]any{"api_key": "sk-test", "base_url": "https://api.openai.com/v1"}} + svc.bindOpenAICompatSessionResponseID(context.Background(), nil, account, "stable-cache-key", "resp_missing") + body := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"stream":false}`) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body)) + + _, _ = svc.ForwardAsAnthropic(context.Background(), c, account, body, "stable-cache-key", "gpt-5.3-codex") + require.Len(t, upstream.requests, 2) + require.True(t, gjson.GetBytes(upstream.bodies[0], "previous_response_id").Exists()) + require.False(t, gjson.GetBytes(upstream.bodies[1], "previous_response_id").Exists()) } func TestForwardAsAnthropic_DisablesAPIKeyContinuationWhenUpstreamRequiresWebSocketV2(t *testing.T) { diff --git a/backend/internal/service/openai_messages_continuation.go b/backend/internal/service/openai_messages_continuation.go index b3c900fbc6..9beed51627 100644 --- a/backend/internal/service/openai_messages_continuation.go +++ b/backend/internal/service/openai_messages_continuation.go @@ -111,6 +111,7 @@ func isOpenAICompatPreviousResponseNotFound(statusCode int, upstreamMsg string, check := func(s string) bool { lower := strings.ToLower(strings.TrimSpace(s)) return strings.Contains(lower, "previous_response_not_found") || + lower == "previous_response_id is not available for this user" || (strings.Contains(lower, "previous response") && strings.Contains(lower, "not found")) || (strings.Contains(lower, "unsupported parameter") && strings.Contains(lower, "previous_response_id")) }