Skip to content
Open
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
220 changes: 171 additions & 49 deletions frontend/src/components/ProviderFormDialog.tsx

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions frontend/src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,18 @@ export const api = {
}
},

testHelper: async (config: { command: string; args?: string[]; timeout_ms?: number; simple_mode?: boolean }): Promise<any> => {
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<string> => {
try {
const apiInstances = await getApiInstances();
Expand Down
12 changes: 12 additions & 0 deletions frontend/src/types/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
pass_env?: string[];
simple_mode?: boolean;
}

export interface OAuthDetail {
Expand Down
16 changes: 14 additions & 2 deletions internal/client/anthropic.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}

Expand Down Expand Up @@ -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}
Expand Down
16 changes: 14 additions & 2 deletions internal/client/google.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down Expand Up @@ -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)
Expand Down
30 changes: 26 additions & 4 deletions internal/client/openai.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")

Expand Down
7 changes: 6 additions & 1 deletion internal/server/codex_responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
53 changes: 53 additions & 0 deletions internal/server/provider_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
})
}
5 changes: 5 additions & 0 deletions internal/server/webui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
53 changes: 49 additions & 4 deletions internal/typ/type.go
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading