Add OAuth provider runtime and providers UI

This commit is contained in:
lpf
2026-03-11 15:47:49 +08:00
parent d9872c3da7
commit 1c0e463d07
52 changed files with 9772 additions and 901 deletions

View File

@@ -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,

View File

@@ -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":

View File

@@ -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)
}
}