Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/user/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -991,11 +991,19 @@ LLM model updates can also be provided in the input file:
--model-name dummy-model \
--inference-url /v1/embeddings \
--request-body '{"input":"NVCF embeddings check"}'

# Invoke through a Vanity Gateway mapping (exact host header)
./nvcf-cli function invoke \
--vanity-host vanity.example.com \
--path /v1/chat/completions \
--request-body '{"messages":[{"role":"user","content":"Hello"}]}'
```

Note: The CLI `function invoke` command detects LLM functions automatically.
For LLM functions, `--model-name` and `--inference-url` are required. The CLI uses the LLM invocation route and sets the OpenAI `model` value to `<function-id>/<model-name>`.

For Vanity Gateway invocation, use `--vanity-host` with `--path` (or `--inference-url`). This sends the request to the exact configured host, without prefixing it with the function ID, and is REST-only (not supported with `--grpc`). The saved function API key and existing authentication handling still apply.

For LLM Gateway endpoint behavior, routing, and session stickiness details, see [LLM Gateway](./llm-gateway.md).

For raw HTTP invocation, HTTP streaming, gRPC metadata, and invocation error
Expand All @@ -1011,6 +1019,8 @@ Additional `function invoke` flags:
| `--grpc-method` | gRPC method name |
| `--grpc-plaintext` | Use plaintext (insecure) gRPC |
| `--inference-url` | Function path, or OpenAI-compatible path for LLM functions (required for LLM) |
| `--path` | Mapped request path for Vanity Gateway invocation (alternative to `--inference-url`) |
| `--vanity-host` | Exact Vanity Gateway host header (preserves host without prefixing function ID) |
| `--model-name` | OpenAI model name for LLM functions |
| `--timeout` | Request timeout in seconds (default: 60) |
| `--poll-duration` | Invocation hold-open duration in seconds (default: 5) |
Expand Down
1 change: 1 addition & 0 deletions src/clis/nvcf-cli/cmd/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ go_test(
"deploy_test.go",
"exit_code_error_test.go",
"function_create_test.go",
"function_invoke_test.go",
"function_llm_model_test.go",
"function_request_priority_test.go",
"main_test.go",
Expand Down
52 changes: 46 additions & 6 deletions src/clis/nvcf-cli/cmd/function.go
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,9 @@ Examples:
# Using saved function context (from create/deploy)
nvcf-cli function invoke --request-body '{"input": "test"}'

# Vanity Gateway invocation (preserves exact host without function ID prefix)
nvcf-cli function invoke --vanity-host vanity.localhost --path /bdd/echo --request-body '{"input": "test"}'

# Using JSON configuration file
nvcf-cli function invoke --input-file invoke-config.json`,
RunE: runInvoke,
Expand Down Expand Up @@ -420,9 +423,11 @@ type DeleteConfig struct {

// InvokeConfig represents the JSON configuration for invoke command
type InvokeConfig struct {
FunctionID string `json:"functionId"`
VersionID string `json:"versionId"`
FunctionID string `json:"functionId,omitempty"`
VersionID string `json:"versionId,omitempty"`
InferenceURL string `json:"inferenceUrl,omitempty"` // Function path, or OpenAI-compatible path for LLM functions.
Path string `json:"path,omitempty"` // Mapped request path for Vanity Gateway invocation.
VanityHost string `json:"vanityHost,omitempty"` // Exact Vanity Gateway host header.
ModelName string `json:"modelName,omitempty"` // OpenAI model name for LLM functions.
RequestBody map[string]interface{} `json:"requestBody"`
Timeout int `json:"timeout,omitempty"`
Expand Down Expand Up @@ -526,6 +531,8 @@ var invokeFlags struct {
functionID string
versionID string
inferenceURL string
path string
vanityHost string
modelName string
requestBody string
timeout int
Expand Down Expand Up @@ -617,6 +624,8 @@ func init() {
invokeCmd.Flags().StringVar(&invokeFlags.functionID, "function-id", "", "Function ID (required)")
invokeCmd.Flags().StringVar(&invokeFlags.versionID, "version-id", "", "Version ID (required)")
invokeCmd.Flags().StringVar(&invokeFlags.inferenceURL, "inference-url", "", "Function path, or OpenAI-compatible path for LLM functions (required for LLM)")
invokeCmd.Flags().StringVar(&invokeFlags.path, "path", "", "Mapped request path for Vanity Gateway invocation (alternative to --inference-url)")
invokeCmd.Flags().StringVar(&invokeFlags.vanityHost, "vanity-host", "", "Exact Vanity Gateway host header (preserves host without prefixing function ID)")
invokeCmd.Flags().StringVar(&invokeFlags.modelName, "model-name", "", "OpenAI model name for LLM functions (required for LLM)")
invokeCmd.Flags().StringVar(&invokeFlags.requestBody, "request-body", "", "JSON request body (required)")
invokeCmd.Flags().IntVar(&invokeFlags.timeout, "timeout", 60, "Request timeout in seconds")
Expand Down Expand Up @@ -1526,6 +1535,12 @@ func loadInvokeConfig(cmd *cobra.Command) (*InvokeConfig, error) {
if cmd.Flags().Changed("inference-url") {
config.InferenceURL = invokeFlags.inferenceURL
}
if cmd.Flags().Changed("path") {
config.Path = invokeFlags.path
}
if cmd.Flags().Changed("vanity-host") {
config.VanityHost = invokeFlags.vanityHost
}
if cmd.Flags().Changed("model-name") {
config.ModelName = invokeFlags.modelName
}
Expand Down Expand Up @@ -2252,7 +2267,7 @@ func runInvoke(cmd *cobra.Command, args []string) error {
// Use saved function context if function ID/version not specified
currentState := GetCurrentState()
applySavedInvokeContext(config, currentState)
if err := validateInvokeConfig(config); err != nil {
if err := validateInvokeConfig(config, invokeFlags.useGRPC); err != nil {
return err
}

Expand Down Expand Up @@ -2295,21 +2310,44 @@ func isSavedAPIKeyExpired(currentState *state.State) bool {
time.Now().After(currentState.APIKeyExpiration)
}

func validateInvokeConfig(config *InvokeConfig) error {
func validateInvokeConfig(config *InvokeConfig, useGRPC bool) error {
if config.VanityHost != "" {
if useGRPC {
return fmt.Errorf("--vanity-host is not supported with --grpc; Vanity Gateway invocation is REST-only")
}
reqPath := config.Path
if reqPath == "" {
reqPath = config.InferenceURL
}
if reqPath == "" {
return fmt.Errorf("path (or --inference-url) is required when using --vanity-host")
}
if config.RequestBody == nil {
return fmt.Errorf("request body is required (use --request-body or specify in JSON file)")
}
return nil
}
if config.FunctionID == "" {
return fmt.Errorf("function ID is required (use --function-id, specify in JSON file, or create a function first)")
}
if config.VersionID == "" {
return fmt.Errorf("version ID is required (use --version-id, specify in JSON file, or create a function first)")
}
if config.Path != "" {
return fmt.Errorf("--path is only supported with --vanity-host (use --inference-url otherwise)")
}
if config.RequestBody == nil {
return fmt.Errorf("request body is required (use --request-body or specify in JSON file)")
}
return nil
}

func invokeViaREST(ctx context.Context, nvcfClient *client.Client, config *InvokeConfig) error {
logging.Info("Using direct REST invocation for function %s (version %s)...", config.FunctionID, config.VersionID)
if config.VanityHost != "" {
logging.Info("Using Vanity Gateway invocation (host: %s)...", config.VanityHost)
} else {
logging.Info("Using direct REST invocation for function %s (version %s)...", config.FunctionID, config.VersionID)
}

// Invoke function via direct REST
resp, err := nvcfClient.InvokeFunctionWithOptions(
Expand All @@ -2327,11 +2365,13 @@ func invokeViaREST(ctx context.Context, nvcfClient *client.Client, config *Invok
}

func invokeOptionsFromConfig(config *InvokeConfig) *client.InvokeFunctionOptions {
if config.InferenceURL == "" && config.ModelName == "" && config.PollDurationSeconds <= 0 {
if config.InferenceURL == "" && config.Path == "" && config.VanityHost == "" && config.ModelName == "" && config.PollDurationSeconds <= 0 {
return nil
}
return &client.InvokeFunctionOptions{
InferenceURL: config.InferenceURL,
Path: config.Path,
VanityHost: config.VanityHost,
ModelName: config.ModelName,
PollDurationSeconds: config.PollDurationSeconds,
}
Expand Down
220 changes: 220 additions & 0 deletions src/clis/nvcf-cli/cmd/function_invoke_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
/*
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 cmd

import (
"encoding/json"
"strings"
"testing"

"github.com/spf13/cobra"
)

func TestInvokeConfigParsesVanityGatewayFromJSON(t *testing.T) {
t.Parallel()

rawJSON := `{
"vanityHost": "vanity.localhost",
"path": "/bdd/echo",
"requestBody": {"message": "test"}
}`

var config InvokeConfig
if err := json.Unmarshal([]byte(rawJSON), &config); err != nil {
t.Fatalf("unmarshal invoke config: %v", err)
}

if config.VanityHost != "vanity.localhost" {
t.Fatalf("vanityHost = %q, want %q", config.VanityHost, "vanity.localhost")
}
if config.Path != "/bdd/echo" {
t.Fatalf("path = %q, want %q", config.Path, "/bdd/echo")
}
}

func TestValidateInvokeConfigVanityGateway(t *testing.T) {
t.Parallel()

tests := []struct {
name string
config *InvokeConfig
useGRPC bool
wantErrText string
}{
{
name: "valid vanity gateway config with path",
config: &InvokeConfig{
VanityHost: "vanity.localhost",
Path: "/bdd/echo",
RequestBody: map[string]interface{}{"message": "test"},
},
wantErrText: "",
},
{
name: "vanity gateway with grpc fails",
config: &InvokeConfig{
VanityHost: "vanity.localhost",
Path: "/bdd/echo",
RequestBody: map[string]interface{}{"message": "test"},
},
useGRPC: true,
wantErrText: "--vanity-host is not supported with --grpc",
},
{
name: "valid vanity gateway config with inference-url fallback",
config: &InvokeConfig{
VanityHost: "llama.api.myorg.com",
InferenceURL: "/v1/chat/completions",
RequestBody: map[string]interface{}{"model": "llama-3"},
},
wantErrText: "",
},
{
name: "vanity gateway without path or inference-url fails",
config: &InvokeConfig{
VanityHost: "vanity.localhost",
RequestBody: map[string]interface{}{"message": "test"},
},
wantErrText: "path (or --inference-url) is required when using --vanity-host",
},
{
name: "vanity gateway without request body fails",
config: &InvokeConfig{
VanityHost: "vanity.localhost",
Path: "/bdd/echo",
},
wantErrText: "request body is required",
},
{
name: "path without vanity-host fails",
config: &InvokeConfig{
FunctionID: "func-123",
VersionID: "ver-123",
Path: "/bdd/echo",
RequestBody: map[string]interface{}{"message": "test"},
},
wantErrText: "--path is only supported with --vanity-host",
},
{
name: "standard invoke requires function ID",
config: &InvokeConfig{
VersionID: "ver-123",
RequestBody: map[string]interface{}{"message": "test"},
},
wantErrText: "function ID is required",
},
{
name: "standard invoke requires version ID",
config: &InvokeConfig{
FunctionID: "func-123",
RequestBody: map[string]interface{}{"message": "test"},
},
wantErrText: "version ID is required",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateInvokeConfig(tt.config, tt.useGRPC)
if tt.wantErrText == "" {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), tt.wantErrText) {
t.Fatalf("validateInvokeConfig() error = %v, want containing %q", err, tt.wantErrText)
}
})
}
}

func TestInvokeOptionsFromConfigVanityGateway(t *testing.T) {
t.Parallel()

config := &InvokeConfig{
VanityHost: "vanity.localhost",
Path: "/bdd/echo",
PollDurationSeconds: 15,
}

opts := invokeOptionsFromConfig(config)
if opts == nil {
t.Fatal("expected invokeOptionsFromConfig to return non-nil options")
}
if opts.VanityHost != "vanity.localhost" {
t.Fatalf("opts.VanityHost = %q, want %q", opts.VanityHost, "vanity.localhost")
}
if opts.Path != "/bdd/echo" {
t.Fatalf("opts.Path = %q, want %q", opts.Path, "/bdd/echo")
}
// InferenceURL must stay scoped to config.InferenceURL: the client's
// Vanity Gateway branch falls back from Path to InferenceURL on its own,
// and letting --path leak into InferenceURL here would let a plain REST
// invocation (no --vanity-host) silently route to --path instead of the
// function's configured endpoint.
if opts.InferenceURL != "" {
t.Fatalf("opts.InferenceURL = %q, want empty (Path must not populate InferenceURL)", opts.InferenceURL)
}
if opts.PollDurationSeconds != 15 {
t.Fatalf("opts.PollDurationSeconds = %d, want 15", opts.PollDurationSeconds)
}
}

func TestInvokeOptionsFromConfigDoesNotLeakPathIntoInferenceURLForStandardInvoke(t *testing.T) {
t.Parallel()

// Regression test: --path must never override the function's inference
// URL for a standard (non-Vanity-Gateway) invocation.
config := &InvokeConfig{
FunctionID: "func-123",
VersionID: "ver-456",
InferenceURL: "/v1/chat/completions",
}

opts := invokeOptionsFromConfig(config)
if opts == nil {
t.Fatal("expected invokeOptionsFromConfig to return non-nil options")
}
if opts.InferenceURL != "/v1/chat/completions" {
t.Fatalf("opts.InferenceURL = %q, want %q", opts.InferenceURL, "/v1/chat/completions")
}
}

func TestLoadInvokeConfigVanityGatewayFlags(t *testing.T) {
cmd := &cobra.Command{}
cmd.Flags().StringVar(&invokeFlags.vanityHost, "vanity-host", "", "")
cmd.Flags().StringVar(&invokeFlags.path, "path", "", "")
cmd.Flags().StringVar(&invokeFlags.requestBody, "request-body", "", "")

args := []string{"--vanity-host", "vanity.localhost", "--path", "/bdd/echo", "--request-body", `{"key":"val"}`}
if err := cmd.ParseFlags(args); err != nil {
t.Fatalf("ParseFlags failed: %v", err)
}

config, err := loadInvokeConfig(cmd)
if err != nil {
t.Fatalf("loadInvokeConfig failed: %v", err)
}
if config.VanityHost != "vanity.localhost" {
t.Fatalf("config.VanityHost = %q, want %q", config.VanityHost, "vanity.localhost")
}
if config.Path != "/bdd/echo" {
t.Fatalf("config.Path = %q, want %q", config.Path, "/bdd/echo")
}
}
Loading
Loading