Skip to content

Commit b264e5e

Browse files
RaderCementZhang
andauthored
fix(aigateway): add backend upstream test endpoint to avoid frontend CORS (#1244)
Co-authored-by: cemeng <cemengzhang@gmail.com>
1 parent e6352d2 commit b264e5e

5 files changed

Lines changed: 660 additions & 0 deletions

File tree

_mocks/opencsg.com/csghub-server/component/mock_LLMServiceComponent.go

Lines changed: 59 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

common/types/llm_service.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,3 +172,31 @@ type UpdateUpstreamReq struct {
172172
Tags *map[string]string `json:"tags"`
173173
Metadata *map[string]any `json:"metadata"`
174174
}
175+
176+
// TestUpstreamReq is the request to test connectivity to an upstream endpoint.
177+
// The upstream is identified by its database ID; the backend fetches the
178+
// upstream's URL, model name and auth header from the database so that the
179+
// frontend never needs to send credentials or perform cross-origin requests.
180+
type TestUpstreamReq struct {
181+
ID int64 `json:"id" binding:"required"`
182+
}
183+
184+
// TestUpstreamResult is the result of an upstream connectivity test.
185+
// The request summary is masked so that sensitive header values (such as
186+
// API keys) are never leaked to the frontend.
187+
type TestUpstreamResult struct {
188+
// Request is the masked request summary (url, method, headers, body).
189+
Request string `json:"request"`
190+
// OK indicates whether the upstream returned a 2xx status code.
191+
OK bool `json:"ok"`
192+
// Status is the HTTP status code returned by the upstream.
193+
Status int `json:"status"`
194+
// StatusText is the HTTP status text returned by the upstream.
195+
StatusText string `json:"status_text"`
196+
// Content is the extracted text content from the upstream response.
197+
Content string `json:"content"`
198+
// ResponseBody is the raw response body from the upstream.
199+
ResponseBody string `json:"response_body"`
200+
// Error is the error message when the test fails (e.g. timeout, network error).
201+
Error string `json:"error,omitempty"`
202+
}

component/llm_service.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ type LLMServiceComponent interface {
3636
UpdateUpstream(ctx context.Context, req *types.UpdateUpstreamReq) (*types.UpstreamConfig, error)
3737
// DeleteUpstream deletes an upstream by ID.
3838
DeleteUpstream(ctx context.Context, id int64) error
39+
// TestUpstream tests connectivity to an upstream endpoint by ID. It fetches
40+
// the upstream config from the database, sends a simple "hi" prompt to the
41+
// upstream URL (supporting /chat/completions and /responses endpoints), and
42+
// returns the masked request summary, response status, body and content.
43+
TestUpstream(ctx context.Context, req *types.TestUpstreamReq) (*types.TestUpstreamResult, error)
3944
}
4045

4146
type llmServiceComponentImpl struct {

component/llm_service_upstream.go

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
2+
package component
3+
4+
import (
5+
"bytes"
6+
"context"
7+
"encoding/json"
8+
"fmt"
9+
"io"
10+
"log/slog"
11+
"net/http"
12+
"strings"
13+
"time"
14+
15+
"opencsg.com/csghub-server/common/errorx"
16+
"opencsg.com/csghub-server/common/types"
17+
)
18+
19+
// upstreamTestTimeout is the maximum duration allowed for an upstream
20+
// connectivity test request.
21+
const upstreamTestTimeout = 30 * time.Second
22+
23+
// maskedAuthSecret is the placeholder used to redact sensitive header values
24+
// in the request summary returned to the frontend.
25+
const maskedAuthSecret = "................."
26+
27+
// endpointChatCompletions and endpointResponses are the two supported
28+
// upstream endpoint path suffixes.
29+
const (
30+
endpointChatCompletions = "/chat/completions"
31+
endpointResponses = "/responses"
32+
)
33+
34+
// testEndpointKind describes which protocol the upstream URL speaks.
35+
type testEndpointKind int
36+
37+
const (
38+
endpointKindUnsupported testEndpointKind = iota
39+
endpointKindChatCompletions
40+
endpointKindResponses
41+
)
42+
43+
// detectEndpointKind inspects the upstream URL path and returns the
44+
// supported endpoint kind. Only /chat/completions and /responses are
45+
// supported; anything else returns endpointKindUnsupported.
46+
func detectEndpointKind(rawURL string) testEndpointKind {
47+
// Trim query string and fragment before checking the path suffix.
48+
u := rawURL
49+
if idx := strings.IndexAny(u, "?#"); idx >= 0 {
50+
u = u[:idx]
51+
}
52+
u = strings.TrimRight(u, "/")
53+
switch {
54+
case strings.HasSuffix(u, endpointChatCompletions):
55+
return endpointKindChatCompletions
56+
case strings.HasSuffix(u, endpointResponses):
57+
return endpointKindResponses
58+
default:
59+
return endpointKindUnsupported
60+
}
61+
}
62+
63+
// parseAuthHeader parses the upstream auth_header field into a map of
64+
// HTTP headers. The auth_header is either a plain "Bearer xxx" string or
65+
// a JSON object string like {"Authorization":"Bearer xxx"}.
66+
func parseAuthHeader(authHeader string) (map[string]string, error) {
67+
trimmed := strings.TrimSpace(authHeader)
68+
if trimmed == "" {
69+
return map[string]string{}, nil
70+
}
71+
72+
var parsed map[string]string
73+
if err := json.Unmarshal([]byte(trimmed), &parsed); err != nil {
74+
// Not a JSON object; treat as a bare Authorization value.
75+
return map[string]string{
76+
"Authorization": trimmed,
77+
}, nil
78+
}
79+
80+
headers := make(map[string]string, len(parsed))
81+
for k, v := range parsed {
82+
key := strings.TrimSpace(k)
83+
if key == "" {
84+
continue
85+
}
86+
headers[key] = v
87+
}
88+
return headers, nil
89+
}
90+
91+
// maskRequestHeaders returns a copy of the headers with sensitive values
92+
// redacted. Only Content-Type is preserved verbatim; all other values
93+
// (including Authorization / apikey) are masked.
94+
func maskRequestHeaders(headers map[string]string) map[string]string {
95+
masked := make(map[string]string, len(headers))
96+
for k, v := range headers {
97+
switch strings.ToLower(k) {
98+
case "content-type":
99+
masked[k] = v
100+
default:
101+
masked[k] = maskedAuthSecret
102+
}
103+
}
104+
return masked
105+
}
106+
107+
// buildTestRequestBody constructs the request body for the given endpoint
108+
// kind. A simple "hi" prompt is used for both protocols.
109+
func buildTestRequestBody(kind testEndpointKind, modelName string) (map[string]any, error) {
110+
switch kind {
111+
case endpointKindChatCompletions:
112+
return map[string]any{
113+
"model": modelName,
114+
"messages": []map[string]string{{"role": "user", "content": "hi"}},
115+
"stream": false,
116+
}, nil
117+
case endpointKindResponses:
118+
return map[string]any{
119+
"model": modelName,
120+
"input": "hi",
121+
"stream": false,
122+
}, nil
123+
default:
124+
return nil, fmt.Errorf("unsupported upstream endpoint, only %s and %s are supported", endpointChatCompletions, endpointResponses)
125+
}
126+
}
127+
128+
// requestSummary is the masked request representation sent to the frontend.
129+
type requestSummary struct {
130+
URL string `json:"url"`
131+
Method string `json:"method"`
132+
Headers map[string]string `json:"headers"`
133+
Body map[string]any `json:"body"`
134+
}
135+
136+
// doUpstreamTest performs the HTTP request against the upstream and returns
137+
// the test result. It is split from TestUpstream so it can be unit tested
138+
// with an injectable http.Client.
139+
func doUpstreamTest(ctx context.Context, client *http.Client, url string, kind testEndpointKind, modelName string, authHeaders map[string]string) (*types.TestUpstreamResult, error) {
140+
body, err := buildTestRequestBody(kind, modelName)
141+
if err != nil {
142+
return nil, err
143+
}
144+
145+
bodyBytes, err := json.Marshal(body)
146+
if err != nil {
147+
return nil, fmt.Errorf("failed to marshal request body: %w", err)
148+
}
149+
150+
requestHeaders := map[string]string{
151+
"Content-Type": "application/json",
152+
}
153+
for k, v := range authHeaders {
154+
requestHeaders[k] = v
155+
}
156+
157+
summary := requestSummary{
158+
URL: url,
159+
Method: http.MethodPost,
160+
Headers: maskRequestHeaders(requestHeaders),
161+
Body: body,
162+
}
163+
summaryBytes, _ := json.MarshalIndent(summary, "", " ")
164+
165+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyBytes))
166+
if err != nil {
167+
return &types.TestUpstreamResult{
168+
Request: string(summaryBytes),
169+
Error: err.Error(),
170+
}, nil
171+
}
172+
for k, v := range requestHeaders {
173+
req.Header.Set(k, v)
174+
}
175+
176+
resp, err := client.Do(req)
177+
if err != nil {
178+
return &types.TestUpstreamResult{
179+
Request: string(summaryBytes),
180+
Error: err.Error(),
181+
}, nil
182+
}
183+
defer resp.Body.Close()
184+
185+
rawBytes, err := io.ReadAll(resp.Body)
186+
if err != nil {
187+
return &types.TestUpstreamResult{
188+
Request: string(summaryBytes),
189+
OK: false,
190+
Status: resp.StatusCode,
191+
StatusText: resp.Status,
192+
ResponseBody: "",
193+
Error: fmt.Sprintf("failed to read response body: %v", err),
194+
}, nil
195+
}
196+
rawText := string(rawBytes)
197+
198+
var prettyBody string
199+
var jsonObj map[string]any
200+
if json.Unmarshal(rawBytes, &jsonObj) == nil {
201+
pretty, _ := json.MarshalIndent(jsonObj, "", " ")
202+
prettyBody = string(pretty)
203+
} else {
204+
prettyBody = rawText
205+
}
206+
207+
return &types.TestUpstreamResult{
208+
Request: string(summaryBytes),
209+
OK: resp.StatusCode >= 200 && resp.StatusCode < 300,
210+
Status: resp.StatusCode,
211+
StatusText: resp.Status,
212+
Content: rawText,
213+
ResponseBody: prettyBody,
214+
}, nil
215+
}
216+
217+
// TestUpstream tests connectivity to an upstream endpoint by ID.
218+
func (s *llmServiceComponentImpl) TestUpstream(ctx context.Context, req *types.TestUpstreamReq) (*types.TestUpstreamResult, error) {
219+
dbUp, err := s.upstreamStore.GetByID(ctx, req.ID)
220+
if err != nil {
221+
return nil, fmt.Errorf("upstream not found: %w", err)
222+
}
223+
224+
url := strings.TrimSpace(dbUp.URL)
225+
if url == "" {
226+
return nil, fmt.Errorf("upstream url is empty")
227+
}
228+
modelName := strings.TrimSpace(dbUp.ModelName)
229+
if modelName == "" {
230+
return nil, fmt.Errorf("upstream model_name is empty")
231+
}
232+
233+
kind := detectEndpointKind(url)
234+
if kind == endpointKindUnsupported {
235+
return nil, errorx.ReqParamInvalid(
236+
fmt.Errorf("unsupported upstream endpoint, only %s and %s are supported", endpointChatCompletions, endpointResponses),
237+
nil,
238+
)
239+
}
240+
241+
authHeaders, err := parseAuthHeader(dbUp.AuthHeader)
242+
if err != nil {
243+
return nil, fmt.Errorf("invalid auth_header: %w", err)
244+
}
245+
246+
testCtx, cancel := context.WithTimeout(ctx, upstreamTestTimeout)
247+
defer cancel()
248+
249+
client := &http.Client{Timeout: upstreamTestTimeout}
250+
251+
slog.InfoContext(ctx, "testing upstream connection",
252+
slog.Int64("upstream_id", dbUp.ID),
253+
slog.String("url", url),
254+
slog.String("endpoint_kind", endpointKindString(kind)),
255+
)
256+
257+
return doUpstreamTest(testCtx, client, url, kind, modelName, authHeaders)
258+
}
259+
260+
func endpointKindString(k testEndpointKind) string {
261+
switch k {
262+
case endpointKindChatCompletions:
263+
return endpointChatCompletions
264+
case endpointKindResponses:
265+
return endpointResponses
266+
default:
267+
return "unsupported"
268+
}
269+
}

0 commit comments

Comments
 (0)