diff --git a/frontend/src/components/ProviderFormDialog.tsx b/frontend/src/components/ProviderFormDialog.tsx index 62a0d3e86..74ecbc920 100644 --- a/frontend/src/components/ProviderFormDialog.tsx +++ b/frontend/src/components/ProviderFormDialog.tsx @@ -1,4 +1,4 @@ -import { WarningAmber, Close } from '@mui/icons-material'; +import { WarningAmber, Close, VpnKey, Terminal } from '@mui/icons-material'; import { Alert, Autocomplete, @@ -31,6 +31,15 @@ export interface EnhancedProviderFormData { noKeyRequired?: boolean; enabled?: boolean; proxyUrl?: string; + credentialSource?: 'direct' | 'helper'; + helperConfig?: { + command: string; + args?: string[]; + timeout_ms?: number; + env?: Record; + pass_env?: string[]; + simple_mode?: boolean; + }; } interface PresetProviderFormDialogProps { @@ -431,59 +440,61 @@ const ProviderFormDialog = ({ }} /> - {/* API Key Field */} - - { - onChange('token', e.target.value); - // Clear verification result when token changes - setVerificationResult(null); - }} - required={!noApiKey} - placeholder={mode === 'add' ? t('providerDialog.apiKey.placeholderAdd') : t('providerDialog.apiKey.placeholderEdit')} - helperText={mode === 'edit' && t('providerDialog.apiKey.helperEdit')} - disabled={noApiKey} - slotProps={{ - input: { - sx: { pr: 12 }, - }, - }} - /> - e.stopPropagation()} - > - - No Key - - - + e.stopPropagation()} + > + + No Key + + { + setNoApiKey(e.target.checked); + onChange('noKeyRequired', e.target.checked); + setVerificationResult(null); + if (e.target.checked) { + onChange('token', ''); + } + }} + /> + + + )} {/* Proxy URL Field */} + {/* Credential Source Selector */} + {!noApiKey && ( + + + Credential Source + + + { + onChange('credentialSource', 'direct'); + onChange('helperConfig', undefined); + setVerificationResult(null); + }} + sx={{ + flex: 1, + border: 2, + borderColor: (data.credentialSource || 'direct') === 'direct' ? 'primary.main' : 'divider', + borderRadius: 1, + p: 1.5, + cursor: 'pointer', + transition: 'all 0.2s', + bgcolor: (data.credentialSource || 'direct') === 'direct' ? 'primary.50' : 'background.paper', + '&:hover': { borderColor: 'primary.light' }, + }} + > + + + + Stored + API key stored in Tingly Box + + + + { + onChange('credentialSource', 'helper'); + onChange('token', ''); + setVerificationResult(null); + if (!data.helperConfig) { + onChange('helperConfig', { command: '', simple_mode: true, timeout_ms: 5000 }); + } + }} + sx={{ + flex: 1, + border: 2, + borderColor: data.credentialSource === 'helper' ? 'primary.main' : 'divider', + borderRadius: 1, + p: 1.5, + cursor: 'pointer', + transition: 'all 0.2s', + bgcolor: data.credentialSource === 'helper' ? 'primary.50' : 'background.paper', + '&:hover': { borderColor: 'primary.light' }, + }} + > + + + + Token Helper + External command + + + + + + {/* Helper Configuration */} + {data.credentialSource === 'helper' && data.helperConfig && ( + + onChange('helperConfig', { ...data.helperConfig, command: e.target.value })} + required + helperText="Absolute path to helper executable" + /> + onChange('helperConfig', { ...data.helperConfig, args: e.target.value ? e.target.value.split(' ') : undefined })} + helperText="Space-separated arguments" + /> + + onChange('helperConfig', { ...data.helperConfig, simple_mode: e.target.checked })} + /> + } + label={Simple Mode} + /> + onChange('helperConfig', { ...data.helperConfig, timeout_ms: parseInt(e.target.value) || 5000 })} + sx={{ width: 120 }} + /> + + + )} + + )} + {/* Verification Result */} {verificationResult && ( => { + try { + const response = await fetchUIAPI('/providers/helper/test', { + method: 'POST', + body: JSON.stringify(config), + }); + return response; + } catch (error: any) { + return { success: false, error: error.message }; + } + }, + getVersion: async (): Promise => { try { const apiInstances = await getApiInstances(); diff --git a/frontend/src/types/provider.ts b/frontend/src/types/provider.ts index 7b582d8c9..283a8bb63 100644 --- a/frontend/src/types/provider.ts +++ b/frontend/src/types/provider.ts @@ -9,6 +9,18 @@ export interface Provider { auth_type?: "api_key" | "oauth"; // "api_key" or "oauth" oauth_detail?: OAuthDetail; proxy_url?: string; + // Credential source + credential_source?: "direct" | "helper"; + helper_config?: HelperConfig; +} + +export interface HelperConfig { + command: string; + args?: string[]; + timeout_ms?: number; + env?: Record; + pass_env?: string[]; + simple_mode?: boolean; } export interface OAuthDetail { diff --git a/internal/client/anthropic.go b/internal/client/anthropic.go index d360bf51e..c90546539 100644 --- a/internal/client/anthropic.go +++ b/internal/client/anthropic.go @@ -37,8 +37,13 @@ func defaultNewAnthropicClient(provider *typ.Provider) (*AnthropicClient, error) apiBase = strings.TrimSuffix(apiBase, "/v1") } + token, err := provider.GetAccessToken(context.Background()) + if err != nil { + return nil, fmt.Errorf("failed to get access token: %w", err) + } + options := []anthropicOption.RequestOption{ - anthropicOption.WithAPIKey(provider.GetAccessToken()), + anthropicOption.WithAPIKey(token), anthropicOption.WithBaseURL(apiBase), } @@ -292,7 +297,14 @@ func (c *AnthropicClient) ProbeOptionsEndpoint(ctx context.Context) ProbeResult } // Set authentication headers - req.Header.Set("x-api-key", c.provider.GetAccessToken()) + accessToken, err := c.provider.GetAccessToken(ctx) + if err != nil { + return ProbeResult{ + Success: false, + ErrorMessage: fmt.Sprintf("Failed to get access token: %v", err), + } + } + req.Header.Set("x-api-key", accessToken) req.Header.Set("anthropic-version", "2023-06-01") client := &http.Client{Timeout: 5 * time.Second} diff --git a/internal/client/google.go b/internal/client/google.go index 2f840aa30..a2f4ffc79 100644 --- a/internal/client/google.go +++ b/internal/client/google.go @@ -50,8 +50,13 @@ func NewGoogleClient(provider *typ.Provider) (*GoogleClient, error) { BaseURL: provider.APIBase, } + token, err := provider.GetAccessToken(context.Background()) + if err != nil { + return nil, fmt.Errorf("failed to get access token: %w", err) + } + config := &genai.ClientConfig{ - APIKey: provider.GetAccessToken(), + APIKey: token, HTTPOptions: httpOptions, HTTPClient: httpClient, } @@ -227,7 +232,14 @@ func (c *GoogleClient) ProbeOptionsEndpoint(ctx context.Context) ProbeResult { } // Set authentication header - req.Header.Set("x-goog-api-key", c.provider.GetAccessToken()) + accessToken, err := c.provider.GetAccessToken(ctx) + if err != nil { + return ProbeResult{ + Success: false, + ErrorMessage: fmt.Sprintf("Failed to get access token: %v", err), + } + } + req.Header.Set("x-goog-api-key", accessToken) client := &http.Client{Timeout: 5 * time.Second} resp, err := client.Do(req) diff --git a/internal/client/openai.go b/internal/client/openai.go index e167795b0..b36b236f4 100644 --- a/internal/client/openai.go +++ b/internal/client/openai.go @@ -35,8 +35,13 @@ type OpenAIClient struct { // defaultNewOpenAIClient creates a new OpenAI client wrapper func defaultNewOpenAIClient(provider *typ.Provider) (*OpenAIClient, error) { + token, err := provider.GetAccessToken(context.Background()) + if err != nil { + return nil, fmt.Errorf("failed to get access token: %w", err) + } + options := []option.RequestOption{ - option.WithAPIKey(provider.GetAccessToken()), + option.WithAPIKey(token), option.WithBaseURL(provider.APIBase), } @@ -191,7 +196,10 @@ func (c *OpenAIClient) ListModels(ctx context.Context) ([]string, error) { } // Set headers based on provider style and auth type - accessToken := c.provider.GetAccessToken() + accessToken, err := c.provider.GetAccessToken(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get access token: %w", err) + } if c.provider.APIStyle == protocol.APIStyleAnthropic { // Add OAuth custom headers if applicable if c.provider.AuthType == typ.AuthTypeOAuth && c.provider.OAuthDetail != nil { @@ -396,7 +404,14 @@ func (c *OpenAIClient) ProbeOptionsEndpoint(ctx context.Context) ProbeResult { } // Set authentication header - req.Header.Set("Authorization", "Bearer "+c.provider.GetAccessToken()) + accessToken, err := c.provider.GetAccessToken(ctx) + if err != nil { + return ProbeResult{ + Success: false, + ErrorMessage: fmt.Sprintf("Failed to get access token: %v", err), + } + } + req.Header.Set("Authorization", "Bearer "+accessToken) client := &http.Client{Timeout: 5 * time.Second} resp, err := client.Do(req) @@ -473,7 +488,14 @@ func (c *OpenAIClient) probeResponsesEndpoint(ctx context.Context, model string) // Set required headers req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+c.provider.GetAccessToken()) + accessToken, err := c.provider.GetAccessToken(ctx) + if err != nil { + return ProbeResult{ + Success: false, + ErrorMessage: fmt.Sprintf("Failed to get access token: %v", err), + } + } + req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("OpenAI-Beta", "responses=experimental") req.Header.Set("originator", "tingly-box") diff --git a/internal/server/codex_responses.go b/internal/server/codex_responses.go index d90c86377..fcdf6250d 100644 --- a/internal/server/codex_responses.go +++ b/internal/server/codex_responses.go @@ -207,7 +207,12 @@ func (s *Server) makeChatGPTBackendRequest(wrapper *client.OpenAIClient, provide // Set required headers for ChatGPT backend API req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+provider.GetAccessToken()) + accessToken, err := provider.GetAccessToken(ctx) + if err != nil { + cancel() + return nil, nil, fmt.Errorf("failed to get access token: %w", err) + } + req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("OpenAI-Beta", "responses=experimental") req.Header.Set("originator", "tingly-box") diff --git a/internal/server/provider_handler.go b/internal/server/provider_handler.go index 8d1aa6fda..4373ccafb 100644 --- a/internal/server/provider_handler.go +++ b/internal/server/provider_handler.go @@ -10,6 +10,7 @@ import ( "github.com/tingly-dev/tingly-box/internal/obs" "github.com/tingly-dev/tingly-box/internal/protocol" "github.com/tingly-dev/tingly-box/internal/typ" + "github.com/tingly-dev/tingly-box/pkg/helper" ) // maskProviderForResponse masks sensitive data and returns a safe ProviderResponse @@ -488,3 +489,55 @@ func (s *Server) GetProviderModelsByUUID(c *gin.Context) { c.JSON(http.StatusOK, response) } + +// TestHelperRequest is the request body for testing a helper command +type TestHelperRequest struct { + Command string `json:"command"` + Args []string `json:"args,omitempty"` + TimeoutMs int `json:"timeout_ms,omitempty"` + Env map[string]string `json:"env,omitempty"` + PassEnv []string `json:"pass_env,omitempty"` + SimpleMode bool `json:"simple_mode,omitempty"` +} + +// TestHelperResponse is the response for helper test +type TestHelperResponse struct { + Success bool `json:"success"` + Preview string `json:"preview,omitempty"` + Error string `json:"error,omitempty"` +} + +// TestHelper tests a helper command and returns a masked preview +func (s *Server) TestHelper(c *gin.Context) { + var req TestHelperRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "error": err.Error(), + }) + return + } + + if req.Command == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "error": "command is required", + }) + return + } + + result := helper.TestHelper(c.Request.Context(), helper.TestConfig{ + Command: req.Command, + Args: req.Args, + TimeoutMs: req.TimeoutMs, + Env: req.Env, + PassEnv: req.PassEnv, + SimpleMode: req.SimpleMode, + }) + + c.JSON(http.StatusOK, TestHelperResponse{ + Success: result.Success, + Preview: result.Preview, + Error: result.Error, + }) +} diff --git a/internal/server/webui.go b/internal/server/webui.go index bcb88a494..d8a33a28e 100644 --- a/internal/server/webui.go +++ b/internal/server/webui.go @@ -726,6 +726,11 @@ func useV2Provider(s *Server, api *swagger.RouteGroup) { swagger.WithResponseModel(DeleteProviderResponse{}), ) + api.POST("/providers/helper/test", s.TestHelper, + swagger.WithDescription("Test a token helper command"), + swagger.WithTags("providers"), + ) + // Provider template endpoints api.GET("/provider-templates", s.GetProviderTemplates, swagger.WithDescription("Get all provider templates"), diff --git a/internal/typ/type.go b/internal/typ/type.go index 8b0d30c98..69f7b3acc 100644 --- a/internal/typ/type.go +++ b/internal/typ/type.go @@ -1,11 +1,13 @@ package typ import ( + "context" "time" "github.com/tingly-dev/tingly-box/internal/loadbalance" "github.com/tingly-dev/tingly-box/internal/protocol" smartrouting "github.com/tingly-dev/tingly-box/internal/smart_routing" + "github.com/tingly-dev/tingly-box/pkg/helper" ) // RuleScenario represents the scenario for a routing rule @@ -59,6 +61,24 @@ const ( AuthTypeOAuth AuthType = "oauth" ) +// CredentialSource represents how credentials are obtained +type CredentialSource string + +const ( + CredentialSourceDirect CredentialSource = "direct" // Stored directly in Token field + CredentialSourceHelper CredentialSource = "helper" // Execute external helper +) + +// HelperConfig contains configuration for token helper execution +type HelperConfig struct { + Command string `json:"command"` // Helper command path (required) + Args []string `json:"args,omitempty"` // Additional arguments + TimeoutMs int `json:"timeout_ms,omitempty"` // Timeout in ms (default: 5000) + Env map[string]string `json:"env,omitempty"` // Environment variables to set + PassEnv []string `json:"pass_env,omitempty"` // Environment variables to inherit + SimpleMode bool `json:"simple_mode,omitempty"` // Return plain text instead of JSON +} + // OAuthDetail contains OAuth-specific authentication information type OAuthDetail struct { AccessToken string `json:"access_token"` // OAuth access token @@ -239,20 +259,45 @@ type Provider struct { OAuthDetail *OAuthDetail `json:"oauth_detail,omitempty"` // OAuth credentials (only for oauth auth type) ToolInterceptor *ToolInterceptorConfig `json:"tool_interceptor,omitempty"` // Provider-level tool interceptor config ToolInterceptorOverride *ToolInterceptorOverride `json:"tool_interceptor_override,omitempty"` // Provider-level override for tool interceptor + + // Credential source configuration + CredentialSource CredentialSource `json:"credential_source,omitempty"` // direct or helper + HelperConfig *HelperConfig `json:"helper_config,omitempty"` // Helper config (only for helper source) } // GetAccessToken returns the access token based on auth type -func (p *Provider) GetAccessToken() string { +func (p *Provider) GetAccessToken(ctx context.Context) (string, error) { switch p.AuthType { case AuthTypeOAuth: if p.OAuthDetail != nil { - return p.OAuthDetail.AccessToken + return p.OAuthDetail.AccessToken, nil } case AuthTypeAPIKey, "": // Default to api_key for backward compatibility - return p.Token + if p.CredentialSource == CredentialSourceHelper && p.HelperConfig != nil { + return p.getHelperToken(ctx) + } + return p.Token, nil } - return "" + return "", nil +} + +// GetHelperToken fetches the token from the configured helper command +func (p *Provider) getHelperToken(ctx context.Context) (string, error) { + if p.HelperConfig == nil { + return "", nil + } + + executor := helper.NewExecutor(helper.Config{ + Command: p.HelperConfig.Command, + Args: p.HelperConfig.Args, + TimeoutMs: p.HelperConfig.TimeoutMs, + Env: p.HelperConfig.Env, + PassEnv: p.HelperConfig.PassEnv, + SimpleMode: p.HelperConfig.SimpleMode, + }) + + return executor.Fetch(ctx, p.APIBase) } // IsOAuthExpired checks if the OAuth token is expired (only valid for oauth auth type) diff --git a/pkg/helper/executor.go b/pkg/helper/executor.go new file mode 100644 index 000000000..cc45386ac --- /dev/null +++ b/pkg/helper/executor.go @@ -0,0 +1,176 @@ +package helper + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" + "time" +) + +// Request sent to helper via stdin +type Request struct { + ProtocolVersion int `json:"protocolVersion"` + Provider string `json:"provider"` + IDs []string `json:"ids"` +} + +// Response received from helper via stdout +type Response struct { + ProtocolVersion int `json:"protocolVersion"` + Values map[string]string `json:"values"` + Errors map[string]Error `json:"errors,omitempty"` +} + +// Error represents an error from the helper +type Error struct { + Message string `json:"message"` +} + +// Config for helper execution +type Config struct { + Command string + Args []string + TimeoutMs int + Env map[string]string + PassEnv []string + SimpleMode bool +} + +// Executor runs external helper commands to fetch credentials +type Executor struct { + config Config +} + +// NewExecutor creates a new helper executor +func NewExecutor(config Config) *Executor { + if config.TimeoutMs == 0 { + config.TimeoutMs = 5000 + } + return &Executor{config: config} +} + +// Fetch retrieves credentials from the helper +func (e *Executor) Fetch(ctx context.Context, provider string) (string, error) { + timeout := time.Duration(e.config.TimeoutMs) * time.Millisecond + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + // Build command + cmd := exec.CommandContext(ctx, e.config.Command, e.config.Args...) + + // Build environment + cmd.Env = os.Environ() + for _, key := range e.config.PassEnv { + if val, ok := os.LookupEnv(key); ok { + cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", key, val)) + } + } + for key, val := range e.config.Env { + cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", key, val)) + } + + // Build request + req := Request{ + ProtocolVersion: 1, + Provider: provider, + IDs: []string{"token"}, + } + reqJSON, _ := json.Marshal(req) + cmd.Stdin = strings.NewReader(string(reqJSON)) + + // Execute + output, err := cmd.Output() + if err != nil { + if ctx.Err() == context.DeadlineExceeded { + return "", fmt.Errorf("helper timed out after %dms", e.config.TimeoutMs) + } + if exitErr, ok := err.(*exec.ExitError); ok { + return "", fmt.Errorf("helper execution failed (exit %d): %s", exitErr.ExitCode(), string(exitErr.Stderr)) + } + return "", fmt.Errorf("helper execution failed: %w", err) + } + + // Parse response + if e.config.SimpleMode { + // Plain text response + return strings.TrimSpace(string(output)), nil + } + + // JSON response + var resp Response + if err := json.Unmarshal(output, &resp); err != nil { + return "", fmt.Errorf("invalid helper response: %w", err) + } + + if resp.ProtocolVersion != 1 { + return "", fmt.Errorf("unsupported protocol version: %d", resp.ProtocolVersion) + } + + if errDetail, ok := resp.Errors["token"]; ok { + return "", fmt.Errorf("helper error: %s", errDetail.Message) + } + + token, ok := resp.Values["token"] + if !ok { + return "", fmt.Errorf("helper did not return token") + } + + return token, nil +} + +// TestConfig contains configuration for testing a helper +type TestConfig struct { + Command string + Args []string + TimeoutMs int + Env map[string]string + PassEnv []string + SimpleMode bool +} + +// TestResult contains the result of a helper test +type TestResult struct { + Success bool `json:"success"` + Preview string `json:"preview"` // Masked token preview + Error string `json:"error,omitempty"` +} + +// TestHelper executes a helper command and returns a masked preview +func TestHelper(ctx context.Context, config TestConfig) TestResult { + executor := NewExecutor(Config{ + Command: config.Command, + Args: config.Args, + TimeoutMs: config.TimeoutMs, + Env: config.Env, + PassEnv: config.PassEnv, + SimpleMode: config.SimpleMode, + }) + + token, err := executor.Fetch(ctx, "test") + if err != nil { + return TestResult{ + Success: false, + Error: err.Error(), + } + } + + // Mask token for preview + preview := maskToken(token) + + return TestResult{ + Success: true, + Preview: preview, + } +} + +// maskToken creates a masked preview of a token +func maskToken(token string) string { + if len(token) <= 8 { + return "***" + } + // Show first 4 and last 4 characters + return token[:4] + "..." + token[len(token)-4:] +} \ No newline at end of file