Skip to content
This repository was archived by the owner on Sep 30, 2024. It is now read-only.

Commit 1f9e72c

Browse files
authored
test(cody): Add unit tests for the Completions API (#63434)
The [Server-side Cody Model Selection](https://linear.app/sourcegraph/project/server-side-cody-model-selection-cca47c48da6d) project requires refactoring a lot of how the Completions API endpoint works for the Sourcegraph backend. It will need to update any place where we interact with the site config as it relates to LLMs, or any time we figure out which models can-or-can-not be used. In order to safely land those refactoring without breaking existing users, who will be using the "older config format", we need tests. LOTS and LOTS of tests. This PR adds the necessary infrastructure for writing unit tests against the Completions API, and adds a few basic ones for the Anthropic API provider. I wouldn't say it's as easy as we'd like to write these tests, but at least now it is _possible_. And we can further streamline things from here. ## Overview The bulk of functionality is in `entconfig_base_test.go`. That file contains some basic unit tests for how the enterprise configuration data is loaded, and provides the mocks and test infrastructure. The crux of which is the `apiProviderTestInfra` struct. It will bundle mocks and the HTTP handlers to test, and provides high-level methods for invoking the completion API. ```go type apiProviderTestInfra struct {} func (ti *apiProviderTestInfra) PushGetModelResult(model string, err error) func (ti *apiProviderTestInfra) SetSiteConfig(siteConfig schema.SiteConfiguration) func (ti *apiProviderTestInfra) CallChatCompletionAPI(t *testing.T, reqObj types.CodyCompletionRequestParameters) (int, string) func (ti *apiProviderTestInfra) CallCodeCompletionAPI(t *testing.T, reqObj types.CodyCompletionRequestParameters) (int, string) func (ti *apiProviderTestInfra) AssertCompletionsResponse(t *testing.T, rawResponseJSON string, wantResponse types.CompletionResponse) ``` What gets trick however, is how we actually hook into the implementation details of the completions API endpoint. You see, the user makes an HTTP request to the Sourcegraph instance. But we then figure out which LLM model to use, and then build an HTTP request to send to the specific LLM API Provider. So the test infrastructure allows you to mock out that "middle part". The `AssertCodyGatewayReceivesRequestWithResponse`/`AssertGenericExternalAPIRequestWithResponse` functions will: 1. Verify that the Sourcegraph instance is making an API call to the LLM provider that matches the format we expect. (e.g. using the correct Anthropic API request, etc.) 2. The outbound HTTP request looks like it should, that it contains the right authorization headers, URL path, etc. (e.g. is it using the API key from the site config?) 3. Finally, it returns the HTTP response from the API provider. (i.e. whatever Anthropic or Cody Gateway would have returned.) ```go type assertLLMRequestOptions struct { // WantRequestObj is what we expect the outbound HTTP request's JSON body // to be equal to. Required. WantRequestObj any // OutResponseObj is serialized to JSON and sent to the caller, i.e. our // LLM API Provider which is making the API request. Required. OutResponseObj any // WantRequestPath is the URL Path we expect in the outbound HTTP request. // No check is done if empty. WantRequestPath string // WantHeaders are HTTP header key/value pairs that must be present. WantHeaders map[string]string } func (ti *apiProviderTestInfra) AssertCodyGatewayReceivesRequestWithResponse( t *testing.T, opts assertLLMRequestOptions) func (ti *apiProviderTestInfra) AssertGenericExternalAPIRequestWithResponse( t *testing.T, opts assertLLMRequestOptions) ``` Unfortunately it's super gnarly because we aren't really exposing the API data types from the API providers in a useful way. (e.g. perhaps we should just use the standard Anthropic Golang API client library.) So generating the test data relies on a lot of `map[string]any` to make it easier to construct arbitrary data types that can serialize to JSON the way that we need them to. Anyways, with all of this test infrastructure in-place, you can write API provider tests like the following. Here's one such test from `entconfig_anthropic_test.go` which confirms that various aspects of the site-configuration are honored correctly when configured to use BYOK mode. I've added "ℹ️ comments" to highlight some of the tricker parts. ```go t.Run("ViaBYOK", func(t *testing.T) { const ( anthropicAPIKeyInConfig = "secret-api-key" anthropicAPIEndpointInConfig = "https://byok.anthropic.com/path/from/config" chatModelInConfig = "anthropic/claude-3-opus" codeModelInConfig = "anthropic/claude-3-haiku" ) infra.SetSiteConfig(schema.SiteConfiguration{ CodyEnabled: pointers.Ptr(true), CodyPermissions: pointers.Ptr(false), CodyRestrictUsersFeatureFlag: pointers.Ptr(false), // LicenseKey is required in order to use Cody. LicenseKey: "license-key", Completions: &schema.Completions{ Provider: "anthropic", AccessToken: anthropicAPIKeyInConfig, Endpoint: anthropicAPIEndpointInConfig, ChatModel: chatModelInConfig, CompletionModel: codeModelInConfig, }, }) t.Run("ChatModel", func(t *testing.T) { // ℹ️ Generating the "wantAnthropicRequest" and "outAnthropicResponse" // data is super-tedious. So we instead have a single function // that returns "a valid set", that we then customize. // So here, we just update the model we expect to see in the API // call to Anthropic. // Start with the stock test data, but customize it to reflect // what we expect to see based on the site configuration. testData := getValidTestData() testData.OutboundAnthropicRequest["model"] = "anthropic/claude-3-opus" // Register our hook to verify Cody Gateway got called with // the requested data. infra.AssertGenericExternalAPIRequestWithResponse( t, assertLLMRequestOptions{ WantRequestPath: "/path/from/config", WantRequestObj: &testData.OutboundAnthropicRequest, OutResponseObj: &testData.InboundAnthropicRequest, WantHeaders: map[string]string{ // Yes, Anthropic's API uses "X-Api-Key" rather than the "Authorization" header. 🤷 "X-Api-Key": anthropicAPIKeyInConfig, }, }) // ℹ️ This `PushGetModelResult` is just a quirk of how the code // under test works. We mock out the `getModelFn` that is invoked // to resolve the _actual_ LLM model to use. (And not necessarily // use the one from the HTTP request.) infra.PushGetModelResult(chatModelInConfig, nil) status, responseBody := infra.CallChatCompletionAPI(t, testData.InitialCompletionRequest) assert.Equal(t, http.StatusOK, status) infra.AssertCompletionsResponse(t, responseBody, types.CompletionResponse{ // ℹ️ The "totally rewrite it in Rust!" is coming from the // fake Anthropic response, from `getValidTestData`. Completion: "you should totally rewrite it in Rust!", StopReason: "max_tokens", Logprobs: nil, }) }) }) ``` ## Next steps Once this gets checked-in, my plan is to carefully add new unit tests for the existing functionality before DISMANTLING the code to write through an entirely different site configuration object. 🤞 this will allow me to do so in such a way that I can confirm my changes won't alter any existing Sourcegraph instances that are using the older configuration format. ## Test plan Adds more tests. ## Changelog NA. Just trivial changes and adding more tests.
1 parent 1ff74b0 commit 1f9e72c

File tree

9 files changed

+803
-8
lines changed

9 files changed

+803
-8
lines changed

cmd/frontend/internal/httpapi/completions/BUILD.bazel

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,15 +51,35 @@ go_library(
5151

5252
go_test(
5353
name = "completions_test",
54-
srcs = ["handler_test.go"],
54+
srcs = [
55+
"entconfig_anthropic_test.go",
56+
"entconfig_base_test.go",
57+
"get_model_test.go",
58+
"handler_test.go",
59+
],
5560
embed = [":completions"],
56-
tags = [TAG_CODY_CORE],
61+
tags = [
62+
TAG_CODY_CORE,
63+
# Test indirectly localhost Redis.
64+
"requires-network",
65+
],
5766
deps = [
67+
"//internal/actor",
5868
"//internal/completions/types",
5969
"//internal/conf",
70+
"//internal/conf/conftypes",
6071
"//internal/database/dbmocks",
6172
"//internal/featureflag",
73+
"//internal/httpcli",
74+
"//internal/licensing",
75+
"//internal/rcache",
76+
"//internal/telemetry",
77+
"//internal/telemetry/telemetrytest",
78+
"//lib/errors",
79+
"//lib/pointers",
6280
"//schema",
81+
"@com_github_sourcegraph_log//logtest",
82+
"@com_github_stretchr_testify//assert",
6383
"@com_github_stretchr_testify//require",
6484
],
6585
)
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
package completions
2+
3+
import (
4+
"net/http"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
9+
"github.com/sourcegraph/sourcegraph/internal/completions/types"
10+
"github.com/sourcegraph/sourcegraph/lib/pointers"
11+
"github.com/sourcegraph/sourcegraph/schema"
12+
)
13+
14+
func testAPIProviderAnthropic(t *testing.T, infra *apiProviderTestInfra) {
15+
// validAnthropicRequestData bundles the messages sent between major
16+
// components of the Completions API.
17+
type validAnthropicRequestData struct {
18+
// InitialCompletionRequest is the request sent by the user to the
19+
// Sourcegraph instance.
20+
InitialCompletionRequest types.CodyCompletionRequestParameters
21+
22+
// OutboundAnthropicRequest is the data sent from this Sourcegraph
23+
// instance to the API Provider (e.g. Anthropic, Cody Gateway, AWS
24+
// Bedrock, etc.)
25+
OutboundAnthropicRequest map[string]any
26+
27+
// InboundAnthropicRequest is the response from the API Provider
28+
// to the Sourcegraph instance.
29+
InboundAnthropicRequest map[string]any
30+
}
31+
32+
// getValidTestData returns a valid set of request data.
33+
getValidTestData := func() validAnthropicRequestData {
34+
initialCompletionRequest := types.CodyCompletionRequestParameters{
35+
CompletionRequestParameters: types.CompletionRequestParameters{
36+
Messages: []types.Message{
37+
{
38+
Speaker: "human",
39+
Text: "please make this code better",
40+
},
41+
},
42+
Stream: pointers.Ptr(false),
43+
},
44+
}
45+
46+
// Anthropic-specific request object we expect to see sent to Cody Gateway.
47+
// See `anthropicRequestParameters`.
48+
outboundAnthropicRequest := map[string]any{
49+
"model": "claude-2.0",
50+
"messages": []map[string]any{
51+
{
52+
"role": "user",
53+
"content": []map[string]any{
54+
{
55+
"type": "text",
56+
"text": "please make this code better",
57+
},
58+
},
59+
},
60+
},
61+
}
62+
63+
// Stock response we would receive from Anthropic.
64+
//
65+
// The expected output is found also defined in the Anthropic completion provider codebase,
66+
// as `anthropicNonStreamingResponse`.` But it's easier to keep those types unexported.
67+
inboundAnthropicRequest := map[string]any{
68+
"content": []map[string]string{
69+
{
70+
"speak": "user",
71+
"text": "you should totally rewrite it in Rust!",
72+
},
73+
},
74+
"usage": map[string]int{
75+
"input_token": 100,
76+
"output_tokens": 200,
77+
},
78+
"stop_reason": "max_tokens",
79+
}
80+
81+
return validAnthropicRequestData{
82+
InitialCompletionRequest: initialCompletionRequest,
83+
OutboundAnthropicRequest: outboundAnthropicRequest,
84+
InboundAnthropicRequest: inboundAnthropicRequest,
85+
}
86+
}
87+
88+
t.Run("WithDefaultConfig", func(t *testing.T) {
89+
infra.SetSiteConfig(schema.SiteConfiguration{
90+
CodyEnabled: pointers.Ptr(true),
91+
CodyPermissions: pointers.Ptr(false),
92+
CodyRestrictUsersFeatureFlag: pointers.Ptr(false),
93+
94+
// LicenseKey is required in order to use Cody, but other than
95+
// that we don't provide any "completions" configuration.
96+
// This will default to Anthropic models.
97+
LicenseKey: "license-key",
98+
Completions: nil,
99+
})
100+
101+
// Confirm that the default configuration `Completions: nil` will use
102+
// Cody Gateway as the LLM API Provider for the Anthropic models.
103+
t.Run("ViaCodyGateway", func(t *testing.T) {
104+
// The Model isn't included in the CompletionRequestParameters, so we have the getModelFn callback
105+
// return claude-2. The Site Configuration will then route this to Cody Gateway (and not BYOK Anthropic),
106+
// and we sanity check the request to Cody Gateway matches what is expected, and we serve a valid response.
107+
infra.PushGetModelResult("anthropic/claude-2", nil)
108+
109+
// Generate some basic test data and confirm that the completions handler
110+
// code works as expected.
111+
testData := getValidTestData()
112+
113+
// Register our hook to verify Cody Gateway got called with
114+
// the requested data.
115+
infra.AssertCodyGatewayReceivesRequestWithResponse(
116+
t, assertLLMRequestOptions{
117+
WantRequestPath: "/v1/completions/anthropic-messages",
118+
WantRequestObj: &testData.OutboundAnthropicRequest,
119+
OutResponseObj: &testData.InboundAnthropicRequest,
120+
})
121+
122+
status, responseBody := infra.CallChatCompletionAPI(t, testData.InitialCompletionRequest)
123+
124+
assert.Equal(t, http.StatusOK, status)
125+
infra.AssertCompletionsResponse(t, responseBody, types.CompletionResponse{
126+
Completion: "you should totally rewrite it in Rust!",
127+
StopReason: "max_tokens",
128+
Logprobs: nil,
129+
})
130+
})
131+
})
132+
133+
t.Run("ViaBYOK", func(t *testing.T) {
134+
const (
135+
anthropicAPIKeyInConfig = "secret-api-key"
136+
anthropicAPIEndpointInConfig = "https://byok.anthropic.com/path/from/config"
137+
chatModelInConfig = "anthropic/claude-3-opus"
138+
codeModelInConfig = "anthropic/claude-3-haiku"
139+
)
140+
141+
infra.SetSiteConfig(schema.SiteConfiguration{
142+
CodyEnabled: pointers.Ptr(true),
143+
CodyPermissions: pointers.Ptr(false),
144+
CodyRestrictUsersFeatureFlag: pointers.Ptr(false),
145+
146+
// LicenseKey is required in order to use Cody.
147+
LicenseKey: "license-key",
148+
Completions: &schema.Completions{
149+
Provider: "anthropic",
150+
AccessToken: anthropicAPIKeyInConfig,
151+
Endpoint: anthropicAPIEndpointInConfig,
152+
153+
ChatModel: chatModelInConfig,
154+
CompletionModel: codeModelInConfig,
155+
},
156+
})
157+
158+
t.Run("ChatModel", func(t *testing.T) {
159+
// Start with the stock test data, but customize it to reflect
160+
// what we expect to see based on the site configuration.
161+
testData := getValidTestData()
162+
testData.OutboundAnthropicRequest["model"] = "anthropic/claude-3-opus"
163+
164+
// Register our hook to verify Cody Gateway got called with
165+
// the requested data.
166+
infra.AssertGenericExternalAPIRequestWithResponse(
167+
t, assertLLMRequestOptions{
168+
WantRequestPath: "/path/from/config",
169+
WantRequestObj: &testData.OutboundAnthropicRequest,
170+
OutResponseObj: &testData.InboundAnthropicRequest,
171+
WantHeaders: map[string]string{
172+
// Yes, Anthropic's API uses "X-Api-Key" rather than the "Authorization" header. 🤷
173+
"X-Api-Key": anthropicAPIKeyInConfig,
174+
},
175+
})
176+
177+
infra.PushGetModelResult(chatModelInConfig, nil)
178+
status, responseBody := infra.CallChatCompletionAPI(t, testData.InitialCompletionRequest)
179+
180+
assert.Equal(t, http.StatusOK, status)
181+
infra.AssertCompletionsResponse(t, responseBody, types.CompletionResponse{
182+
Completion: "you should totally rewrite it in Rust!",
183+
StopReason: "max_tokens",
184+
Logprobs: nil,
185+
})
186+
})
187+
})
188+
}

0 commit comments

Comments
 (0)