-
Notifications
You must be signed in to change notification settings - Fork 51
feat(vanity-gateway): serve LLM Gateway routes on configured hosts #1022
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -26,16 +26,38 @@ import ( | |
| "time" | ||
| ) | ||
|
|
||
| func healthManager(nvcfApiHost string, transport http.RoundTripper) (*health.Health, error) { | ||
| // healthManager probes the NVCF API, plus the LLM Gateway when any vanity route | ||
| // targets it. The LLM Gateway serves /healthz rather than /health. | ||
| func healthManager(nvcfApiHost string, llmGatewayEndpoint string, transport http.RoundTripper) (*health.Health, error) { | ||
| client := http.Client{Timeout: 5 * time.Second, Transport: transport} | ||
| healthUrl, err := url.JoinPath(nvcfApiHost, "/health") | ||
|
|
||
| nvcfCheck, err := upstreamHealthCheck(client, "nvcf api", nvcfApiHost, "/health") | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| return health.New(health.WithComponent(health.Component{ | ||
| options := []health.Option{health.WithChecks(nvcfCheck)} | ||
|
|
||
| if llmGatewayEndpoint != "" { | ||
| llmCheck, err := upstreamHealthCheck(client, "llm api gateway", llmGatewayEndpoint, "/healthz") | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| options = append(options, health.WithChecks(llmCheck)) | ||
|
Comment on lines
+40
to
+45
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win Add a health-path regression test. The added tests do not invoke the new LLM Gateway health check. Add coverage that verifies the NVCF endpoint receives As per coding guidelines, "Code changes must include tests." As per path instructions, "include tests for code changes." 🤖 Prompt for AI AgentsSources: Coding guidelines, Path instructions |
||
| } | ||
|
|
||
| options = append(options, health.WithComponent(health.Component{ | ||
| Name: "vanity gateway", | ||
| }), health.WithChecks(health.Config{ | ||
| Name: "nvcf api", | ||
| })) | ||
| return health.New(options...) | ||
| } | ||
|
|
||
| func upstreamHealthCheck(client http.Client, name string, endpoint string, path string) (health.Config, error) { | ||
| healthUrl, err := url.JoinPath(endpoint, path) | ||
| if err != nil { | ||
| return health.Config{}, err | ||
| } | ||
| return health.Config{ | ||
| Name: name, | ||
| Timeout: 5 * time.Second, | ||
| Check: func(ctx context.Context) error { | ||
| request, err := http.NewRequestWithContext(ctx, http.MethodGet, healthUrl, nil) | ||
|
|
@@ -50,7 +72,7 @@ func healthManager(nvcfApiHost string, transport http.RoundTripper) (*health.Hea | |
| if resp.StatusCode == 200 { | ||
| return nil | ||
| } | ||
| return fmt.Errorf("invalid nvcf api health response %d", resp.StatusCode) | ||
| return fmt.Errorf("invalid %s health response %d", name, resp.StatusCode) | ||
| }, | ||
| })) | ||
| }, nil | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| /* | ||
| SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| Licensed under the Apache License, Version 2.0 (the "License"); | ||
| you may not use this file except in compliance with the License. | ||
| You may obtain a copy of the License at | ||
|
|
||
| http://www.apache.org/licenses/LICENSE-2.0 | ||
|
|
||
| Unless required by applicable law or agreed to in writing, software | ||
| distributed under the License is distributed on an "AS IS" BASIS, | ||
| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| See the License for the specific language governing permissions and | ||
| limitations under the License. | ||
| */ | ||
|
|
||
| package gateway | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "net" | ||
| "net/http" | ||
| "net/http/httputil" | ||
| "net/url" | ||
| "time" | ||
|
|
||
| config "ai-api-gateway-service/gateway_config" | ||
|
|
||
| "go.opentelemetry.io/otel/trace" | ||
| ) | ||
|
|
||
| // LLMGatewayDirector proxies vanity routes to the LLM Gateway without altering | ||
| // the request body. The LLM Gateway resolves the target function from the model | ||
| // field the client already supplies, so the gateway forwards the request as-is | ||
| // rather than stamping function-id headers or rewriting the path. | ||
| type LLMGatewayDirector struct { | ||
| rp *httputil.ReverseProxy | ||
| host string | ||
| scheme string | ||
| } | ||
|
|
||
| type LLMGatewayRequest struct { | ||
| CustomHeaders config.CustomHeaders | ||
| EOL time.Time | ||
| OfflineMessage string | ||
| } | ||
|
|
||
| func NewLLMGatewayDirector(endpoint string, transport http.RoundTripper) (*LLMGatewayDirector, error) { | ||
| endpointUrl, err := url.Parse(endpoint) | ||
| if err != nil || endpointUrl.Scheme == "" || endpointUrl.Host == "" { | ||
| return nil, fmt.Errorf("invalid LLM Gateway endpoint: %s", endpoint) | ||
| } | ||
| return &LLMGatewayDirector{ | ||
| rp: newGatewayReverseProxy(transport), | ||
| host: endpointUrl.Host, | ||
| scheme: endpointUrl.Scheme, | ||
| }, nil | ||
|
Comment on lines
+49
to
+58
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- relevant files ---'
git ls-files | rg '(^|/)(AGENTS\.md|llm_gateway_director\.go|.*health.*|.*gateway.*test.*)$' | head -200
printf '%s\n' '--- director outline ---'
ast-grep outline src/invocation-plane-services/vanity-gateway/gateway/llm_gateway_director.go
printf '%s\n' '--- director source ---'
cat -n src/invocation-plane-services/vanity-gateway/gateway/llm_gateway_director.go | sed -n '1,180p'
printf '%s\n' '--- related symbols ---'
rg -n -C 4 'NewLLMGatewayDirector|newGatewayReverseProxy|ServeProxy|healthManager|LLM_GATEWAY_ENDPOINT|JoinPath' src/invocation-plane-services/vanity-gatewayRepository: NVIDIA/nvcf Length of output: 44604 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- repository guidance ---'
cat -n AGENTS.md | sed -n '1,220p'
if [ -f src/invocation-plane-services/vanity-gateway/AGENTS.md ]; then
cat -n src/invocation-plane-services/vanity-gateway/AGENTS.md | sed -n '1,220p'
fi
if [ -f src/invocation-plane-services/vanity-gateway/gateway/AGENTS.md ]; then
cat -n src/invocation-plane-services/vanity-gateway/gateway/AGENTS.md | sed -n '1,220p'
fi
printf '%s\n' '--- health helper ---'
cat -n src/invocation-plane-services/vanity-gateway/gateway/health.go | sed -n '29,90p'
printf '%s\n' '--- director tests ---'
cat -n src/invocation-plane-services/vanity-gateway/gateway/llm_gateway_director_test.go | sed -n '1,230p'
printf '%s\n' '--- reverse proxy helper ---'
cat -n src/invocation-plane-services/vanity-gateway/gateway/vanity_director.go | sed -n '160,215p'Repository: NVIDIA/nvcf Length of output: 25474 🏁 Script executed: #!/bin/bash
set -eu
cat >/tmp/verify_llm_gateway_path.go <<'EOF'
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"net/http/httputil"
"net/url"
)
func main() {
// Model the director: it stores only scheme and host, then mutates the
// incoming request before using the repository's no-op proxy director.
endpoint, _ := url.Parse("http://llm-gateway.example/llm")
incoming, _ := http.NewRequest(http.MethodPost, "http://vanity.example/v1/chat/completions", nil)
incoming.URL.Scheme = endpoint.Scheme
incoming.URL.Host = endpoint.Host
incoming.Host = ""
var got string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = r.URL.RequestURI()
w.WriteHeader(http.StatusNoContent)
}))
defer upstream.Close()
upstreamURL, _ := url.Parse(upstream.URL)
incoming.URL.Scheme = upstreamURL.Scheme
incoming.URL.Host = upstreamURL.Host
proxy := &httputil.ReverseProxy{
Director: func(*http.Request) {},
Transport: http.DefaultTransport,
}
proxy.ServeHTTP(httptest.NewRecorder(), incoming)
fmt.Printf("configured endpoint path: %q\n", endpoint.Path)
fmt.Printf("proxied request URI: %q\n", got)
for _, path := range []string{"/llm", "/llm/", "/llm/api"} {
got = ""
incoming, _ = http.NewRequest(http.MethodPost, "http://vanity.example/v1/chat/completions", nil)
incoming.URL.Scheme = upstreamURL.Scheme
incoming.URL.Host = upstreamURL.Host
proxy.ServeHTTP(httptest.NewRecorder(), incoming)
fmt.Printf("base path %q would still proxy as %q\n", path, got)
}
}
EOF
go run /tmp/verify_llm_gateway_path.goRepository: NVIDIA/nvcf Length of output: 3791 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- repository guidance ---'
cat -n AGENTS.md | sed -n '1,220p'
if [ -f src/invocation-plane-services/vanity-gateway/AGENTS.md ]; then
cat -n src/invocation-plane-services/vanity-gateway/AGENTS.md | sed -n '1,220p'
fi
if [ -f src/invocation-plane-services/vanity-gateway/AGENTS.md ]; then
cat -n src/invocation-plane-services/vanity-gateway/AGENTS.md | sed -n '1,220p'
fi
printf '%s\n' '--- health helper ---'
cat -n src/invocation-plane-services/vanity-gateway/gateway/health.go | sed -n '29,90p'
printf '%s\n' '--- director tests ---'
cat -n src/invocation-plane-services/vanity-gateway/gateway/llm_gateway_director_test.go | sed -n '1,230p'
printf '%s\n' '--- reverse proxy helper ---'
cat -n src/invocation-plane-services/vanity-gateway/gateway/vanity_director.go | sed -n '160,215p'Repository: NVIDIA/nvcf Length of output: 25474 🏁 Script executed: #!/bin/bash
set -eu
cat >/tmp/verify_llm_gateway_path.go <<'EOF'
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"net/http/httputil"
"net/url"
)
func main() {
endpoint, _ := url.Parse("http://llm-gateway.example/llm")
incoming, _ := http.NewRequest(http.MethodPost, "http://vanity.example/v1/chat/completions", nil)
incoming.URL.Scheme = endpoint.Scheme
incoming.URL.Host = endpoint.Host
incoming.Host = ""
var got string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = r.URL.RequestURI()
w.WriteHeader(http.StatusNoContent)
}))
defer upstream.Close()
upstreamURL, _ := url.Parse(upstream.URL)
incoming.URL.Scheme = upstreamURL.Scheme
incoming.URL.Host = upstreamURL.Host
proxy := &httputil.ReverseProxy{
Director: func(*http.Request) {},
Transport: http.DefaultTransport,
}
proxy.ServeHTTP(httptest.NewRecorder(), incoming)
fmt.Printf("configured endpoint path: %q\n", endpoint.Path)
fmt.Printf("proxied request URI: %q\n", got)
}
EOF
go run /tmp/verify_llm_gateway_path.goRepository: NVIDIA/nvcf Length of output: 3791 Preserve the configured endpoint path. If 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| // UpstreamHostname is the LLM Gateway host without its port, used to reject a | ||
| // configured host that would make the gateway proxy to itself. | ||
| func (d *LLMGatewayDirector) UpstreamHostname() string { | ||
| return hostWithoutPort(d.host) | ||
| } | ||
|
|
||
| func hostWithoutPort(host string) string { | ||
| if hostname, _, err := net.SplitHostPort(host); err == nil { | ||
| return hostname | ||
| } | ||
| return host | ||
| } | ||
|
|
||
| func (d *LLMGatewayDirector) ServeProxy(target LLMGatewayRequest, writer http.ResponseWriter, request *http.Request) error { | ||
| span := trace.SpanFromContext(request.Context()) | ||
| span.SetAttributes(traceAttrEndpointType.String(traceAttrValueEndpointLLMGateway)) | ||
|
|
||
| if writeFunctionStatusError(writer, target.OfflineMessage, target.EOL, "") { | ||
| return nil | ||
| } | ||
|
|
||
| request.URL.Host = d.host | ||
| request.URL.Scheme = d.scheme | ||
| request.Host = "" | ||
| applyCustomHeaders(request, target.CustomHeaders) | ||
|
|
||
| if !target.EOL.IsZero() { | ||
| writer.Header().Set("Deprecation", target.EOL.Format(time.RFC3339)) | ||
| } | ||
|
|
||
| var proxyErr error | ||
| rp := *d.rp | ||
| rp.ErrorHandler = func(writer http.ResponseWriter, request *http.Request, err error) { | ||
| proxyErr = err | ||
| writeProxyError(writer, request, err) | ||
| } | ||
| rp.ServeHTTP(writer, request) | ||
| return proxyErr | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: NVIDIA/nvcf
Length of output: 50368
🏁 Script executed:
Repository: NVIDIA/nvcf
Length of output: 29072
🏁 Script executed:
Repository: NVIDIA/nvcf
Length of output: 333
Explicitly discard the handled proxy error.
ServeProxywrites proxy errors through itsErrorHandlerand returns the recorded error. Use_ = llmGatewayDirector.ServeProxy(target, writer, request)to satisfyerrcheck.🧰 Tools
🪛 golangci-lint (2.12.2)
[error] 189-189: Error return value of
llmGatewayDirector.ServeProxyis not checked(errcheck)
🤖 Prompt for AI Agents
Source: Linters/SAST tools