mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-05-07 09:47:35 +08:00
Add OAuth provider runtime and providers UI
This commit is contained in:
@@ -289,9 +289,30 @@ type ProviderConfig struct {
|
||||
SupportsResponsesCompact bool `json:"supports_responses_compact" env:"CLAWGO_PROVIDERS_{{.Name}}_SUPPORTS_RESPONSES_COMPACT"`
|
||||
Auth string `json:"auth" env:"CLAWGO_PROVIDERS_{{.Name}}_AUTH"`
|
||||
TimeoutSec int `json:"timeout_sec" env:"CLAWGO_PROVIDERS_PROXY_TIMEOUT_SEC"`
|
||||
RuntimePersist bool `json:"runtime_persist,omitempty"`
|
||||
RuntimeHistoryFile string `json:"runtime_history_file,omitempty"`
|
||||
RuntimeHistoryMax int `json:"runtime_history_max,omitempty"`
|
||||
OAuth ProviderOAuthConfig `json:"oauth,omitempty"`
|
||||
Responses ProviderResponsesConfig `json:"responses"`
|
||||
}
|
||||
|
||||
type ProviderOAuthConfig struct {
|
||||
Provider string `json:"provider,omitempty"`
|
||||
CredentialFile string `json:"credential_file,omitempty"`
|
||||
CredentialFiles []string `json:"credential_files,omitempty"`
|
||||
CallbackPort int `json:"callback_port,omitempty"`
|
||||
ClientID string `json:"client_id,omitempty"`
|
||||
ClientSecret string `json:"client_secret,omitempty"`
|
||||
AuthURL string `json:"auth_url,omitempty"`
|
||||
TokenURL string `json:"token_url,omitempty"`
|
||||
RedirectURL string `json:"redirect_url,omitempty"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
HybridPriority string `json:"hybrid_priority,omitempty"`
|
||||
CooldownSec int `json:"cooldown_sec,omitempty"`
|
||||
RefreshScanSec int `json:"refresh_scan_sec,omitempty"`
|
||||
RefreshLeadSec int `json:"refresh_lead_sec,omitempty"`
|
||||
}
|
||||
|
||||
type ProviderResponsesConfig struct {
|
||||
WebSearchEnabled bool `json:"web_search_enabled"`
|
||||
WebSearchContextSize string `json:"web_search_context_size"`
|
||||
@@ -419,6 +440,7 @@ type SentinelConfig struct {
|
||||
AutoHeal bool `json:"auto_heal" env:"CLAWGO_SENTINEL_AUTO_HEAL"`
|
||||
NotifyChannel string `json:"notify_channel" env:"CLAWGO_SENTINEL_NOTIFY_CHANNEL"`
|
||||
NotifyChatID string `json:"notify_chat_id" env:"CLAWGO_SENTINEL_NOTIFY_CHAT_ID"`
|
||||
WebhookURL string `json:"webhook_url" env:"CLAWGO_SENTINEL_WEBHOOK_URL"`
|
||||
}
|
||||
|
||||
type MemoryConfig struct {
|
||||
@@ -659,6 +681,7 @@ func DefaultConfig() *Config {
|
||||
AutoHeal: true,
|
||||
NotifyChannel: "",
|
||||
NotifyChatID: "",
|
||||
WebhookURL: "",
|
||||
},
|
||||
Memory: MemoryConfig{
|
||||
Layered: true,
|
||||
|
||||
@@ -2,6 +2,7 @@ package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
@@ -203,6 +204,18 @@ func Validate(cfg *Config) []error {
|
||||
if cfg.Sentinel.Enabled && cfg.Sentinel.IntervalSec <= 0 {
|
||||
errs = append(errs, fmt.Errorf("sentinel.interval_sec must be > 0 when sentinel.enabled=true"))
|
||||
}
|
||||
if raw := strings.TrimSpace(cfg.Sentinel.WebhookURL); raw != "" {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u == nil || u.Host == "" {
|
||||
errs = append(errs, fmt.Errorf("sentinel.webhook_url must be a valid http/https URL"))
|
||||
} else {
|
||||
switch strings.ToLower(strings.TrimSpace(u.Scheme)) {
|
||||
case "http", "https":
|
||||
default:
|
||||
errs = append(errs, fmt.Errorf("sentinel.webhook_url must use http or https"))
|
||||
}
|
||||
}
|
||||
}
|
||||
if cfg.Memory.RecentDays <= 0 {
|
||||
errs = append(errs, fmt.Errorf("memory.recent_days must be > 0"))
|
||||
}
|
||||
@@ -523,15 +536,42 @@ func containsString(items []string, target string) bool {
|
||||
|
||||
func validateProviderConfig(path string, p ProviderConfig) []error {
|
||||
var errs []error
|
||||
authMode := strings.ToLower(strings.TrimSpace(p.Auth))
|
||||
if p.APIBase == "" {
|
||||
errs = append(errs, fmt.Errorf("%s.api_base is required", path))
|
||||
}
|
||||
if p.TimeoutSec <= 0 {
|
||||
errs = append(errs, fmt.Errorf("%s.timeout_sec must be > 0", path))
|
||||
}
|
||||
if len(p.Models) == 0 {
|
||||
switch authMode {
|
||||
case "", "bearer", "oauth", "none", "hybrid":
|
||||
default:
|
||||
errs = append(errs, fmt.Errorf("%s.auth must be one of: bearer, oauth, hybrid, none", path))
|
||||
}
|
||||
if len(p.Models) == 0 && authMode != "oauth" && authMode != "hybrid" {
|
||||
errs = append(errs, fmt.Errorf("%s.models must contain at least one model", path))
|
||||
}
|
||||
if authMode == "oauth" && strings.TrimSpace(p.OAuth.Provider) == "" {
|
||||
errs = append(errs, fmt.Errorf("%s.oauth.provider is required when auth=oauth", path))
|
||||
}
|
||||
if authMode == "hybrid" {
|
||||
if strings.TrimSpace(p.APIKey) == "" && strings.TrimSpace(p.OAuth.Provider) == "" {
|
||||
errs = append(errs, fmt.Errorf("%s.hybrid auth requires api_key or oauth.provider", path))
|
||||
}
|
||||
if strings.TrimSpace(p.OAuth.Provider) == "" {
|
||||
errs = append(errs, fmt.Errorf("%s.oauth.provider is required when auth=hybrid", path))
|
||||
}
|
||||
}
|
||||
if p.OAuth.HybridPriority != "" {
|
||||
switch strings.ToLower(strings.TrimSpace(p.OAuth.HybridPriority)) {
|
||||
case "api_first", "oauth_first":
|
||||
default:
|
||||
errs = append(errs, fmt.Errorf("%s.oauth.hybrid_priority must be one of: api_first, oauth_first", path))
|
||||
}
|
||||
}
|
||||
if p.OAuth.CooldownSec < 0 {
|
||||
errs = append(errs, fmt.Errorf("%s.oauth.cooldown_sec must be >= 0", path))
|
||||
}
|
||||
if p.Responses.WebSearchContextSize != "" {
|
||||
switch p.Responses.WebSearchContextSize {
|
||||
case "low", "medium", "high":
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultConfigGeneratesGatewayToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -204,6 +207,30 @@ func TestValidateGatewayNodeDispatchRejectsEmptyAllowNodeKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSentinelWebhookURLRejectsInvalidScheme(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.Sentinel.WebhookURL = "ftp://example.com/hook"
|
||||
|
||||
if errs := Validate(cfg); len(errs) == 0 {
|
||||
t.Fatalf("expected validation errors")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSentinelWebhookURLAllowsHTTPS(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.Sentinel.WebhookURL = "https://example.com/hook"
|
||||
|
||||
for _, err := range Validate(cfg) {
|
||||
if strings.Contains(err.Error(), "sentinel.webhook_url") {
|
||||
t.Fatalf("unexpected webhook validation error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultConfigSetsNodeArtifactRetentionDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -244,3 +271,106 @@ func TestValidateNodeArtifactRetentionRejectsNegativeRetainDays(t *testing.T) {
|
||||
t.Fatalf("expected validation errors")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateProviderOAuthAllowsEmptyModelsBeforeLogin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.Providers.Proxy.Auth = "oauth"
|
||||
cfg.Providers.Proxy.Models = nil
|
||||
cfg.Providers.Proxy.OAuth = ProviderOAuthConfig{Provider: "codex"}
|
||||
|
||||
if errs := Validate(cfg); len(errs) != 0 {
|
||||
t.Fatalf("expected oauth provider config to be valid before model sync, got %v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateProviderOAuthRequiresProviderName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.Providers.Proxy.Auth = "oauth"
|
||||
cfg.Providers.Proxy.Models = nil
|
||||
cfg.Providers.Proxy.OAuth = ProviderOAuthConfig{}
|
||||
|
||||
errs := Validate(cfg)
|
||||
if len(errs) == 0 {
|
||||
t.Fatalf("expected validation errors")
|
||||
}
|
||||
found := false
|
||||
for _, err := range errs {
|
||||
if strings.Contains(err.Error(), "providers.proxy.oauth.provider") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected oauth.provider validation error, got %v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateProviderHybridAllowsEmptyModels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.Providers.Proxy.Auth = "hybrid"
|
||||
cfg.Providers.Proxy.APIKey = "sk-test"
|
||||
cfg.Providers.Proxy.Models = nil
|
||||
cfg.Providers.Proxy.OAuth = ProviderOAuthConfig{Provider: "codex"}
|
||||
|
||||
if errs := Validate(cfg); len(errs) != 0 {
|
||||
t.Fatalf("expected hybrid provider config to be valid before model sync, got %v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateProviderHybridRequiresOAuthProvider(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.Providers.Proxy.Auth = "hybrid"
|
||||
cfg.Providers.Proxy.APIKey = "sk-test"
|
||||
cfg.Providers.Proxy.Models = nil
|
||||
cfg.Providers.Proxy.OAuth = ProviderOAuthConfig{}
|
||||
|
||||
errs := Validate(cfg)
|
||||
if len(errs) == 0 {
|
||||
t.Fatalf("expected validation errors")
|
||||
}
|
||||
found := false
|
||||
for _, err := range errs {
|
||||
if strings.Contains(err.Error(), "providers.proxy.oauth.provider") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected oauth.provider validation error, got %v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateProviderHybridPriorityRejectsInvalidValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.Providers.Proxy.Auth = "hybrid"
|
||||
cfg.Providers.Proxy.APIKey = "sk-test"
|
||||
cfg.Providers.Proxy.OAuth = ProviderOAuthConfig{
|
||||
Provider: "codex",
|
||||
HybridPriority: "random_first",
|
||||
}
|
||||
|
||||
errs := Validate(cfg)
|
||||
if len(errs) == 0 {
|
||||
t.Fatalf("expected validation errors")
|
||||
}
|
||||
found := false
|
||||
for _, err := range errs {
|
||||
if strings.Contains(err.Error(), "oauth.hybrid_priority") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected oauth.hybrid_priority validation error, got %v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user