From 25b54bb8597a26462052c736a5482b9f154e1c82 Mon Sep 17 00:00:00 2001 From: Jesse Sanford <108698+jessesanford@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:58:11 -0400 Subject: [PATCH] feat: add ai:apikeyheader config field for custom auth headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add optional ai:apikeyheader config field to allow enterprise LLM gateways (e.g., Apigee-fronted proxies) to use custom headers like x-api-key instead of the hardcoded Authorization: Bearer. Three-way conditional in buildChatHTTPRequest and buildOpenAIHTTPRequest: - Azure/AzureLegacy → api-key header (unchanged, takes precedence) - APIKeyHeader set →
: (new, no Bearer prefix) - Default → Authorization: Bearer (unchanged) Added APIKeyHeader to AIOptsType and AIModeConfigType, updated getWaveAISettings mapping, JSON schema, TypeScript types, docs, and unit tests (8 total, all passing). GitHub issue: https://github.com/wavetermdev/waveterm/issues/3453 --- docs/docs/waveai-modes.mdx | 24 ++++ frontend/types/gotypes.d.ts | 2 + pkg/aiusechat/openai/openai-convertmessage.go | 2 + .../openai/openai-convertmessage_test.go | 110 ++++++++++++++++++ .../openaichat/openaichat-convertmessage.go | 2 + .../openaichat-convertmessage_test.go | 110 ++++++++++++++++++ pkg/aiusechat/uctypes/uctypes.go | 1 + pkg/aiusechat/usechat.go | 1 + pkg/wconfig/settingsconfig.go | 45 +++---- schema/waveai.json | 4 + 10 files changed, 279 insertions(+), 22 deletions(-) create mode 100644 pkg/aiusechat/openai/openai-convertmessage_test.go create mode 100644 pkg/aiusechat/openaichat/openaichat-convertmessage_test.go diff --git a/docs/docs/waveai-modes.mdx b/docs/docs/waveai-modes.mdx index 93403db800..4b233ba923 100644 --- a/docs/docs/waveai-modes.mdx +++ b/docs/docs/waveai-modes.mdx @@ -228,6 +228,28 @@ If you provide only the baseurl, you are likely to get a 404 message. The `ai:apitokensecretname` should be the name of an environment variable that contains your API key. Set this environment variable before running Wave Terminal. +#### Enterprise Gateways (e.g. Apigee, x-api-key) + +Some enterprise LLM gateways (e.g. behind Apigee) require the API key in a custom header like `x-api-key` instead of the standard `Authorization: Bearer`. For these gateways, use the `ai:apikeyheader` field to override the header name: + +```json +{ + "my-apigee-gateway": { + "display:name": "Corporate LLM Gateway", + "display:order": 1, + "display:icon": "building", + "ai:apitype": "openai-chat", + "ai:model": "gpt-5.4", + "ai:endpoint": "https://gateway.corp.example/v1/chat/completions", + "ai:apitokensecretname": "CORP_LLM_KEY", + "ai:apikeyheader": "x-api-key", + "ai:capabilities": ["tools"] + } +} +``` + +When `ai:apikeyheader` is set, Wave sends the API token using the specified header name (e.g. `x-api-key: `) instead of `Authorization: Bearer `. This field is not used for Azure providers, which always use the `api-key` header. + ### OpenRouter @@ -517,6 +539,7 @@ If you get "model not found" errors: "ai:azureapiversion": "v1", "ai:apitoken": "your-token", "ai:apitokensecretname": "PROVIDER_KEY", + "ai:apikeyheader": "x-api-key", "ai:azureresourcename": "your-resource", "ai:azuredeployment": "your-deployment", "ai:capabilities": ["tools", "images", "pdfs"] @@ -540,6 +563,7 @@ If you get "model not found" errors: | `ai:azureapiversion` | No | Azure API version (for `azure-legacy` provider, defaults to `2025-04-01-preview`) | | `ai:apitoken` | No | API key/token (not recommended - use secrets instead) | | `ai:apitokensecretname` | No | Name of secret containing API token (auto-set by provider) | +| `ai:apikeyheader` | No | Custom header name for API token. When set, sends `
: ` instead of `Authorization: Bearer `. Not used for Azure providers (they always use `api-key`). Example: `"x-api-key"` | | `ai:azureresourcename` | No | Azure resource name (for Azure providers) | | `ai:azuredeployment` | No | Azure deployment name (for `azure-legacy` provider) | | `ai:capabilities` | No | Array of supported capabilities: `"tools"`, `"images"`, `"pdfs"` | diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index c5b870d7ed..74b17e3b35 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -31,6 +31,7 @@ declare global { "ai:apitokensecretname"?: string; "ai:azureresourcename"?: string; "ai:azuredeployment"?: string; + "ai:apikeyheader"?: string; "ai:capabilities"?: string[]; "ai:switchcompat"?: string[]; "waveai:cloud"?: boolean; @@ -1589,6 +1590,7 @@ declare global { "debug:panictype"?: string; "block:view"?: string; "block:controller"?: string; + "block:subblock"?: boolean; "ai:backendtype"?: string; "ai:local"?: boolean; "wsh:cmd"?: string; diff --git a/pkg/aiusechat/openai/openai-convertmessage.go b/pkg/aiusechat/openai/openai-convertmessage.go index 045cc65cf2..1dc8872041 100644 --- a/pkg/aiusechat/openai/openai-convertmessage.go +++ b/pkg/aiusechat/openai/openai-convertmessage.go @@ -299,6 +299,8 @@ func buildOpenAIHTTPRequest(ctx context.Context, inputs []any, chatOpts uctypes. // Azure OpenAI uses "api-key" header instead of "Authorization: Bearer" if opts.Provider == uctypes.AIProvider_Azure || opts.Provider == uctypes.AIProvider_AzureLegacy { req.Header.Set("api-key", opts.APIToken) + } else if opts.APIKeyHeader != "" { + req.Header.Set(opts.APIKeyHeader, opts.APIToken) } else { req.Header.Set("Authorization", "Bearer "+opts.APIToken) } diff --git a/pkg/aiusechat/openai/openai-convertmessage_test.go b/pkg/aiusechat/openai/openai-convertmessage_test.go new file mode 100644 index 0000000000..88dbf00542 --- /dev/null +++ b/pkg/aiusechat/openai/openai-convertmessage_test.go @@ -0,0 +1,110 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: MIT + +package openai + +import ( + "context" + "testing" + + "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" +) + +const testEndpoint = "https://test-gateway.example.com/v1/responses" + +func makeTestChatOpts(provider, endpoint, apiToken, apiKeyHeader string) uctypes.WaveChatOpts { + return uctypes.WaveChatOpts{ + ChatId: "test-chat-id", + ClientId: "test-client-id", + Config: uctypes.AIOptsType{ + Provider: provider, + APIType: uctypes.APIType_OpenAIResponses, + Model: "gpt-5-mini", + APIToken: apiToken, + Endpoint: endpoint, + Capabilities: []string{uctypes.AICapabilityTools}, + APIKeyHeader: apiKeyHeader, + }, + } +} + +// TestBuildOpenAIHTTPRequest_AuthHeader_CustomHeader verifies that when +// ai:apikeyheader is set, the API token is sent using that header name +// (without "Bearer " prefix) and no Authorization header is set. +func TestBuildOpenAIHTTPRequest_AuthHeader_CustomHeader(t *testing.T) { + chatOpts := makeTestChatOpts(uctypes.AIProvider_Custom, testEndpoint, "my-secret-key", "x-api-key") + + req, err := buildOpenAIHTTPRequest(context.Background(), []any{}, chatOpts, nil) + if err != nil { + t.Fatalf("buildOpenAIHTTPRequest returned error: %v", err) + } + + if req.Header.Get("x-api-key") != "my-secret-key" { + t.Errorf("expected x-api-key header to be 'my-secret-key', got %q", req.Header.Get("x-api-key")) + } + if req.Header.Get("Authorization") != "" { + t.Errorf("expected no Authorization header, got %q", req.Header.Get("Authorization")) + } + // Verify Content-Type is still set + if req.Header.Get("Content-Type") != "application/json" { + t.Errorf("expected Content-Type to be 'application/json', got %q", req.Header.Get("Content-Type")) + } +} + +// TestBuildOpenAIHTTPRequest_AuthHeader_Default verifies that the default path +// (no custom header) still sets "Authorization: Bearer ". +func TestBuildOpenAIHTTPRequest_AuthHeader_Default(t *testing.T) { + chatOpts := makeTestChatOpts(uctypes.AIProvider_Custom, testEndpoint, "my-secret-key", "") + + req, err := buildOpenAIHTTPRequest(context.Background(), []any{}, chatOpts, nil) + if err != nil { + t.Fatalf("buildOpenAIHTTPRequest returned error: %v", err) + } + + if req.Header.Get("Authorization") != "Bearer my-secret-key" { + t.Errorf("expected Authorization to be 'Bearer my-secret-key', got %q", req.Header.Get("Authorization")) + } + if req.Header.Get("x-api-key") != "" { + t.Errorf("expected no x-api-key header, got %q", req.Header.Get("x-api-key")) + } +} + +// TestBuildOpenAIHTTPRequest_AuthHeader_Azure verifies that Azure providers +// still use the "api-key" header regardless of APIKeyHeader setting. +func TestBuildOpenAIHTTPRequest_AuthHeader_Azure(t *testing.T) { + chatOpts := makeTestChatOpts(uctypes.AIProvider_Azure, testEndpoint, "my-azure-key", "x-api-key") + + req, err := buildOpenAIHTTPRequest(context.Background(), []any{}, chatOpts, nil) + if err != nil { + t.Fatalf("buildOpenAIHTTPRequest returned error: %v", err) + } + + if req.Header.Get("api-key") != "my-azure-key" { + t.Errorf("expected api-key header to be 'my-azure-key', got %q", req.Header.Get("api-key")) + } + if req.Header.Get("Authorization") != "" { + t.Errorf("expected no Authorization header for Azure, got %q", req.Header.Get("Authorization")) + } + // APIKeyHeader should be ignored for Azure providers + if req.Header.Get("x-api-key") != "" { + t.Errorf("expected APIKeyHeader to be ignored for Azure, got x-api-key=%q", req.Header.Get("x-api-key")) + } +} + +// TestBuildOpenAIHTTPRequest_AuthHeader_AzureLegacy verifies that Azure Legacy +// providers use the "api-key" header. +func TestBuildOpenAIHTTPRequest_AuthHeader_AzureLegacy(t *testing.T) { + chatOpts := makeTestChatOpts(uctypes.AIProvider_AzureLegacy, testEndpoint, "my-azure-legacy-key", "") + + req, err := buildOpenAIHTTPRequest(context.Background(), []any{}, chatOpts, nil) + if err != nil { + t.Fatalf("buildOpenAIHTTPRequest returned error: %v", err) + } + + if req.Header.Get("api-key") != "my-azure-legacy-key" { + t.Errorf("expected api-key header to be 'my-azure-legacy-key', got %q", req.Header.Get("api-key")) + } + if req.Header.Get("Authorization") != "" { + t.Errorf("expected no Authorization header for Azure Legacy, got %q", req.Header.Get("Authorization")) + } +} diff --git a/pkg/aiusechat/openaichat/openaichat-convertmessage.go b/pkg/aiusechat/openaichat/openaichat-convertmessage.go index 90fe8c9f1f..5c306c1318 100644 --- a/pkg/aiusechat/openaichat/openaichat-convertmessage.go +++ b/pkg/aiusechat/openaichat/openaichat-convertmessage.go @@ -145,6 +145,8 @@ func buildChatHTTPRequest(ctx context.Context, messages []ChatRequestMessage, ch // Azure OpenAI uses "api-key" header instead of "Authorization: Bearer" if opts.Provider == uctypes.AIProvider_Azure || opts.Provider == uctypes.AIProvider_AzureLegacy { req.Header.Set("api-key", opts.APIToken) + } else if opts.APIKeyHeader != "" { + req.Header.Set(opts.APIKeyHeader, opts.APIToken) } else { req.Header.Set("Authorization", "Bearer "+opts.APIToken) } diff --git a/pkg/aiusechat/openaichat/openaichat-convertmessage_test.go b/pkg/aiusechat/openaichat/openaichat-convertmessage_test.go new file mode 100644 index 0000000000..c2ee570548 --- /dev/null +++ b/pkg/aiusechat/openaichat/openaichat-convertmessage_test.go @@ -0,0 +1,110 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: MIT + +package openaichat + +import ( + "context" + "testing" + + "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" +) + +const testEndpoint = "https://test-gateway.example.com/v1/chat/completions" + +func makeTestChatOpts(provider, endpoint, apiToken, apiKeyHeader string) uctypes.WaveChatOpts { + return uctypes.WaveChatOpts{ + ChatId: "test-chat-id", + ClientId: "test-client-id", + Config: uctypes.AIOptsType{ + Provider: provider, + APIType: uctypes.APIType_OpenAIChat, + Model: "gpt-4o", + APIToken: apiToken, + Endpoint: endpoint, + Capabilities: []string{uctypes.AICapabilityTools}, + APIKeyHeader: apiKeyHeader, + }, + } +} + +// TestBuildChatHTTPRequest_AuthHeader_CustomHeader verifies that when +// ai:apikeyheader is set, the API token is sent using that header name +// (without "Bearer " prefix) and no Authorization header is set. +func TestBuildChatHTTPRequest_AuthHeader_CustomHeader(t *testing.T) { + chatOpts := makeTestChatOpts(uctypes.AIProvider_Custom, testEndpoint, "my-secret-key", "x-api-key") + + req, err := buildChatHTTPRequest(context.Background(), []ChatRequestMessage{}, chatOpts) + if err != nil { + t.Fatalf("buildChatHTTPRequest returned error: %v", err) + } + + if req.Header.Get("x-api-key") != "my-secret-key" { + t.Errorf("expected x-api-key header to be 'my-secret-key', got %q", req.Header.Get("x-api-key")) + } + if req.Header.Get("Authorization") != "" { + t.Errorf("expected no Authorization header, got %q", req.Header.Get("Authorization")) + } + // Verify Content-Type is still set + if req.Header.Get("Content-Type") != "application/json" { + t.Errorf("expected Content-Type to be 'application/json', got %q", req.Header.Get("Content-Type")) + } +} + +// TestBuildChatHTTPRequest_AuthHeader_Default verifies that the default path +// (no custom header) still sets "Authorization: Bearer ". +func TestBuildChatHTTPRequest_AuthHeader_Default(t *testing.T) { + chatOpts := makeTestChatOpts(uctypes.AIProvider_Custom, testEndpoint, "my-secret-key", "") + + req, err := buildChatHTTPRequest(context.Background(), []ChatRequestMessage{}, chatOpts) + if err != nil { + t.Fatalf("buildChatHTTPRequest returned error: %v", err) + } + + if req.Header.Get("Authorization") != "Bearer my-secret-key" { + t.Errorf("expected Authorization to be 'Bearer my-secret-key', got %q", req.Header.Get("Authorization")) + } + if req.Header.Get("x-api-key") != "" { + t.Errorf("expected no x-api-key header, got %q", req.Header.Get("x-api-key")) + } +} + +// TestBuildChatHTTPRequest_AuthHeader_Azure verifies that Azure providers +// still use the "api-key" header regardless of APIKeyHeader setting. +func TestBuildChatHTTPRequest_AuthHeader_Azure(t *testing.T) { + chatOpts := makeTestChatOpts(uctypes.AIProvider_Azure, testEndpoint, "my-azure-key", "x-api-key") + + req, err := buildChatHTTPRequest(context.Background(), []ChatRequestMessage{}, chatOpts) + if err != nil { + t.Fatalf("buildChatHTTPRequest returned error: %v", err) + } + + if req.Header.Get("api-key") != "my-azure-key" { + t.Errorf("expected api-key header to be 'my-azure-key', got %q", req.Header.Get("api-key")) + } + if req.Header.Get("Authorization") != "" { + t.Errorf("expected no Authorization header for Azure, got %q", req.Header.Get("Authorization")) + } + // APIKeyHeader should be ignored for Azure providers + if req.Header.Get("x-api-key") != "" { + t.Errorf("expected APIKeyHeader to be ignored for Azure, got x-api-key=%q", req.Header.Get("x-api-key")) + } +} + +// TestBuildChatHTTPRequest_AuthHeader_AzureLegacy verifies that Azure Legacy +// providers use the "api-key" header. +func TestBuildChatHTTPRequest_AuthHeader_AzureLegacy(t *testing.T) { + chatOpts := makeTestChatOpts(uctypes.AIProvider_AzureLegacy, testEndpoint, "my-azure-legacy-key", "") + + req, err := buildChatHTTPRequest(context.Background(), []ChatRequestMessage{}, chatOpts) + if err != nil { + t.Fatalf("buildChatHTTPRequest returned error: %v", err) + } + + if req.Header.Get("api-key") != "my-azure-legacy-key" { + t.Errorf("expected api-key header to be 'my-azure-legacy-key', got %q", req.Header.Get("api-key")) + } + if req.Header.Get("Authorization") != "" { + t.Errorf("expected no Authorization header for Azure Legacy, got %q", req.Header.Get("Authorization")) + } +} diff --git a/pkg/aiusechat/uctypes/uctypes.go b/pkg/aiusechat/uctypes/uctypes.go index a222eb5c9c..623ccf31cf 100644 --- a/pkg/aiusechat/uctypes/uctypes.go +++ b/pkg/aiusechat/uctypes/uctypes.go @@ -267,6 +267,7 @@ type AIOptsType struct { AIMode string `json:"aimode,omitempty"` Capabilities []string `json:"capabilities,omitempty"` WaveAIPremium bool `json:"waveaipremium,omitempty"` + APIKeyHeader string `json:"apikeyheader,omitempty"` // Custom header name for API token (e.g. "x-api-key"); when empty, defaults to "Authorization: Bearer " } func (opts AIOptsType) IsWaveProxy() bool { diff --git a/pkg/aiusechat/usechat.go b/pkg/aiusechat/usechat.go index d9de760afd..d695f122d0 100644 --- a/pkg/aiusechat/usechat.go +++ b/pkg/aiusechat/usechat.go @@ -126,6 +126,7 @@ func getWaveAISettings(premium bool, builderMode bool, rtInfo waveobj.ObjRTInfo, ProxyURL: config.ProxyURL, Capabilities: config.Capabilities, WaveAIPremium: config.WaveAIPremium, + APIKeyHeader: config.APIKeyHeader, } if apiToken != "" { opts.APIToken = apiToken diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go index 67118b1670..1c5a59a12f 100644 --- a/pkg/wconfig/settingsconfig.go +++ b/pkg/wconfig/settingsconfig.go @@ -90,28 +90,28 @@ type SettingsType struct { WaveAiShowCloudModes bool `json:"waveai:showcloudmodes,omitempty"` WaveAiDefaultMode string `json:"waveai:defaultmode,omitempty"` - TermClear bool `json:"term:*,omitempty"` - TermFontSize float64 `json:"term:fontsize,omitempty"` - TermFontFamily string `json:"term:fontfamily,omitempty"` - TermTheme string `json:"term:theme,omitempty"` - TermDisableWebGl bool `json:"term:disablewebgl,omitempty"` - TermLocalShellPath string `json:"term:localshellpath,omitempty"` - TermLocalShellOpts []string `json:"term:localshellopts,omitempty"` - TermGitBashPath string `json:"term:gitbashpath,omitempty"` - TermScrollback *int64 `json:"term:scrollback,omitempty"` - TermCopyOnSelect *bool `json:"term:copyonselect,omitempty"` - TermTransparency *float64 `json:"term:transparency,omitempty"` - TermAllowBracketedPaste *bool `json:"term:allowbracketedpaste,omitempty"` - TermShiftEnterNewline *bool `json:"term:shiftenternewline,omitempty"` - TermMacOptionIsMeta *bool `json:"term:macoptionismeta,omitempty"` - TermCursor string `json:"term:cursor,omitempty"` - TermCursorBlink *bool `json:"term:cursorblink,omitempty"` - TermBellSound *bool `json:"term:bellsound,omitempty"` - TermBellIndicator *bool `json:"term:bellindicator,omitempty"` - TermOsc52 string `json:"term:osc52,omitempty" jsonschema:"enum=focus,enum=always"` - TermDurable *bool `json:"term:durable,omitempty"` - TermShowSplitButtons bool `json:"term:showsplitbuttons,omitempty"` - TermTrimTrailingWhitespace *bool `json:"term:trimtrailingwhitespace,omitempty"` + TermClear bool `json:"term:*,omitempty"` + TermFontSize float64 `json:"term:fontsize,omitempty"` + TermFontFamily string `json:"term:fontfamily,omitempty"` + TermTheme string `json:"term:theme,omitempty"` + TermDisableWebGl bool `json:"term:disablewebgl,omitempty"` + TermLocalShellPath string `json:"term:localshellpath,omitempty"` + TermLocalShellOpts []string `json:"term:localshellopts,omitempty"` + TermGitBashPath string `json:"term:gitbashpath,omitempty"` + TermScrollback *int64 `json:"term:scrollback,omitempty"` + TermCopyOnSelect *bool `json:"term:copyonselect,omitempty"` + TermTransparency *float64 `json:"term:transparency,omitempty"` + TermAllowBracketedPaste *bool `json:"term:allowbracketedpaste,omitempty"` + TermShiftEnterNewline *bool `json:"term:shiftenternewline,omitempty"` + TermMacOptionIsMeta *bool `json:"term:macoptionismeta,omitempty"` + TermCursor string `json:"term:cursor,omitempty"` + TermCursorBlink *bool `json:"term:cursorblink,omitempty"` + TermBellSound *bool `json:"term:bellsound,omitempty"` + TermBellIndicator *bool `json:"term:bellindicator,omitempty"` + TermOsc52 string `json:"term:osc52,omitempty" jsonschema:"enum=focus,enum=always"` + TermDurable *bool `json:"term:durable,omitempty"` + TermShowSplitButtons bool `json:"term:showsplitbuttons,omitempty"` + TermTrimTrailingWhitespace *bool `json:"term:trimtrailingwhitespace,omitempty"` EditorMinimapEnabled bool `json:"editor:minimapenabled,omitempty"` EditorStickyScrollEnabled bool `json:"editor:stickyscrollenabled,omitempty"` @@ -301,6 +301,7 @@ type AIModeConfigType struct { APITokenSecretName string `json:"ai:apitokensecretname,omitempty"` AzureResourceName string `json:"ai:azureresourcename,omitempty"` AzureDeployment string `json:"ai:azuredeployment,omitempty"` + APIKeyHeader string `json:"ai:apikeyheader,omitempty"` Capabilities []string `json:"ai:capabilities,omitempty" jsonschema:"enum=pdfs,enum=images,enum=tools"` SwitchCompat []string `json:"ai:switchcompat,omitempty"` WaveAICloud bool `json:"waveai:cloud,omitempty"` diff --git a/schema/waveai.json b/schema/waveai.json index 7279777a4c..9278392121 100644 --- a/schema/waveai.json +++ b/schema/waveai.json @@ -72,6 +72,10 @@ "ai:apitokensecretname": { "type": "string" }, + "ai:apikeyheader": { + "type": "string", + "description": "Custom header name for the API token. When set, sends `
: ` instead of `Authorization: Bearer `. Not used for Azure providers (they always use api-key). Example: \"x-api-key\"" + }, "ai:azureresourcename": { "type": "string" },