Skip to content
Closed
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
24 changes: 24 additions & 0 deletions docs/docs/waveai-modes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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: <token>`) instead of `Authorization: Bearer <token>`. This field is not used for Azure providers, which always use the `api-key` header.


### OpenRouter

Expand Down Expand Up @@ -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"]
Expand All @@ -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 `<header>: <token>` instead of `Authorization: Bearer <token>`. 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"` |
Expand Down
2 changes: 2 additions & 0 deletions frontend/types/gotypes.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions pkg/aiusechat/openai/openai-convertmessage.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
110 changes: 110 additions & 0 deletions pkg/aiusechat/openai/openai-convertmessage_test.go
Original file line number Diff line number Diff line change
@@ -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 <token>".
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"))
}
}
2 changes: 2 additions & 0 deletions pkg/aiusechat/openaichat/openaichat-convertmessage.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
110 changes: 110 additions & 0 deletions pkg/aiusechat/openaichat/openaichat-convertmessage_test.go
Original file line number Diff line number Diff line change
@@ -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 <token>".
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"))
}
}
1 change: 1 addition & 0 deletions pkg/aiusechat/uctypes/uctypes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>"
}

func (opts AIOptsType) IsWaveProxy() bool {
Expand Down
1 change: 1 addition & 0 deletions pkg/aiusechat/usechat.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 23 additions & 22 deletions pkg/wconfig/settingsconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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"`
Expand Down
4 changes: 4 additions & 0 deletions schema/waveai.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@
"ai:apitokensecretname": {
"type": "string"
},
"ai:apikeyheader": {
"type": "string",
"description": "Custom header name for the API token. When set, sends `<header>: <token>` instead of `Authorization: Bearer <token>`. Not used for Azure providers (they always use api-key). Example: \"x-api-key\""
},
"ai:azureresourcename": {
"type": "string"
},
Expand Down