fix config anthropic model, add log request provider

This commit is contained in:
tanln 2026-03-27 15:58:36 +07:00
parent e38e2a3a63
commit ff7095e88e
14 changed files with 493 additions and 350 deletions

View file

@ -51,9 +51,9 @@ func hiddenValues(key string, value map[string]any, ch config.ChannelsConfig) {
case "wecom": case "wecom":
value["secret"] = ch.WeCom.Secret() value["secret"] = ch.WeCom.Secret()
case "dingtalk": case "dingtalk":
value["secret"] = ch.QQ.AppSecret()
case "qq":
value["secret"] = ch.DingTalk.ClientSecret() value["secret"] = ch.DingTalk.ClientSecret()
case "qq":
value["secret"] = ch.QQ.AppSecret()
case "irc": case "irc":
value["password"] = ch.IRC.Password() value["password"] = ch.IRC.Password()
value["serv_password"] = ch.IRC.NickServPassword() value["serv_password"] = ch.IRC.NickServPassword()

View file

@ -1739,17 +1739,35 @@ func SaveConfig(path string, cfg *Config) error {
cfg.Version = CurrentVersion cfg.Version = CurrentVersion
} }
names := toNameIndex(cfg.ModelList) names := toNameIndex(cfg.ModelList)
newModelList := make(map[string]ModelSecurityEntry)
for i, m := range cfg.ModelList { for i, m := range cfg.ModelList {
if m.IsVirtual() {
continue
}
newName := names[i]
oldName := m.secModelName
if m.secDirty { if m.secDirty {
if m.secModelName == "" { newModelList[newName] = ModelSecurityEntry{APIKeys: m.apiKeys}
m.secModelName = names[i]
}
cfg.security.ModelList[m.secModelName] = ModelSecurityEntry{
APIKeys: m.apiKeys,
}
m.secDirty = false m.secDirty = false
} else {
if oldName == "" {
oldName = newName
}
if entry, ok := cfg.security.ModelList[oldName]; ok {
newModelList[newName] = entry
} else {
// Fallback to storing plaintext if not found, will trigger encrypt later if enabled
newModelList[newName] = ModelSecurityEntry{APIKeys: m.apiKeys}
} }
} }
m.secModelName = newName
}
cfg.security.ModelList = newModelList
if cfg.Channels.Pico.secDirty { if cfg.Channels.Pico.secDirty {
cfg.security.Channels.Pico = &PicoSecurity{ cfg.security.Channels.Pico = &PicoSecurity{
Token: cfg.Channels.Pico.Token(), Token: cfg.Channels.Pico.Token(),

View file

@ -229,6 +229,16 @@ func saveSecurityConfig(securityPath string, sec *SecurityConfig) error {
return fileutil.WriteFileAtomic(securityPath, buf.Bytes(), 0o600) return fileutil.WriteFileAtomic(securityPath, buf.Bytes(), 0o600)
} }
// MergeAndApplySecurity merges explicitly provided security tokens from JSON API payloads
// into the current configuration's security state, preventing them from being dropped
// when json.Unmarshal ignores the unexported Token fields.
func (c *Config) MergeAndApplySecurity(newer *SecurityConfig) error {
if newer == nil {
return c.ApplySecurity()
}
c.security = mergeSecurityConfig(c.security, newer)
return applySecurityConfig(c, c.security)
}
// mergeSecurityConfig merges two SecurityConfig instances, preferring non-empty values from 'newer'. // mergeSecurityConfig merges two SecurityConfig instances, preferring non-empty values from 'newer'.
// This is used during config migration to preserve existing security data while adding new entries. // This is used during config migration to preserve existing security data while adding new entries.
func mergeSecurityConfig(existing, newer *SecurityConfig) *SecurityConfig { func mergeSecurityConfig(existing, newer *SecurityConfig) *SecurityConfig {

View file

@ -100,6 +100,9 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error
return fmt.Errorf("error loading config: %w", err) return fmt.Errorf("error loading config: %w", err)
} }
if err = cfg.ApplySecurity(); err != nil {
return fmt.Errorf("error applying security config: %w", err)
}
logger.SetLevelFromString(cfg.Gateway.LogLevel) logger.SetLevelFromString(cfg.Gateway.LogLevel)
if debug { if debug {
@ -204,6 +207,11 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error
runningServices.reloading.Store(false) runningServices.reloading.Store(false)
continue continue
} }
if err = newCfg.ApplySecurity(); err != nil {
logger.Errorf("Failed to apply security config: %v", err)
runningServices.reloading.Store(false)
continue
}
err = executeReload(ctx, agentLoop, newCfg, &provider, runningServices, msgBus, allowEmptyStartup) err = executeReload(ctx, agentLoop, newCfg, &provider, runningServices, msgBus, allowEmptyStartup)
if err != nil { if err != nil {
logger.Errorf("Manual reload failed: %v", err) logger.Errorf("Manual reload failed: %v", err)
@ -581,7 +589,11 @@ func setupConfigWatcherPolling(configPath string, debug bool) (chan *config.Conf
logger.Warn(" Using previous valid config") logger.Warn(" Using previous valid config")
continue continue
} }
if err := newCfg.ApplySecurity(); err != nil {
logger.Errorf(" ⚠ Failed to apply security to new config: %v", err)
logger.Warn(" Using previous valid config")
continue
}
logger.Info("✓ Config file validated and loaded") logger.Info("✓ Config file validated and loaded")
select { select {

View file

@ -16,6 +16,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/sipeed/picoclaw/pkg/providers/common"
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes" "github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
) )
@ -59,9 +60,7 @@ func NewProviderWithTimeout(apiKey, apiBase string, timeoutSeconds int) *Provide
return &Provider{ return &Provider{
apiKey: apiKey, apiKey: apiKey,
apiBase: baseURL, apiBase: baseURL,
httpClient: &http.Client{ httpClient: common.NewHTTPClientWithTimeout("", timeout),
Timeout: timeout,
},
} }
} }

View file

@ -14,6 +14,7 @@ import (
"github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers/common"
) )
const ( const (
@ -36,9 +37,7 @@ type AntigravityProvider struct {
func NewAntigravityProvider() *AntigravityProvider { func NewAntigravityProvider() *AntigravityProvider {
return &AntigravityProvider{ return &AntigravityProvider{
tokenSource: createAntigravityTokenSource(), tokenSource: createAntigravityTokenSource(),
httpClient: &http.Client{ httpClient: common.NewHTTPClientWithTimeout("", 120*time.Second),
Timeout: 120 * time.Second,
},
} }
} }
@ -633,7 +632,7 @@ func FetchAntigravityProjectID(accessToken string) (string, error) {
req.Header.Set("User-Agent", antigravityUserAgent) req.Header.Set("User-Agent", antigravityUserAgent)
req.Header.Set("X-Goog-Api-Client", antigravityXGoogClient) req.Header.Set("X-Goog-Api-Client", antigravityXGoogClient)
client := &http.Client{Timeout: 15 * time.Second} client := common.NewHTTPClientWithTimeout("", 15*time.Second)
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return "", err return "", err
@ -677,7 +676,7 @@ func FetchAntigravityModels(accessToken, projectID string) ([]AntigravityModelIn
req.Header.Set("User-Agent", antigravityUserAgent) req.Header.Set("User-Agent", antigravityUserAgent)
req.Header.Set("X-Goog-Api-Client", antigravityXGoogClient) req.Header.Set("X-Goog-Api-Client", antigravityXGoogClient)
client := &http.Client{Timeout: 15 * time.Second} client := common.NewHTTPClientWithTimeout("", 15*time.Second)
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return nil, err return nil, err

View file

@ -13,6 +13,7 @@ import (
"github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers/common"
) )
const ( const (
@ -35,6 +36,7 @@ func NewCodexProvider(token, accountID string) *CodexProvider {
option.WithAPIKey(token), option.WithAPIKey(token),
option.WithHeader("originator", "codex_cli_rs"), option.WithHeader("originator", "codex_cli_rs"),
option.WithHeader("OpenAI-Beta", "responses=experimental"), option.WithHeader("OpenAI-Beta", "responses=experimental"),
option.WithHTTPClient(common.NewHTTPClientWithTimeout("", 0)),
} }
if accountID != "" { if accountID != "" {
opts = append(opts, option.WithHeader("Chatgpt-Account-Id", accountID)) opts = append(opts, option.WithHeader("Chatgpt-Account-Id", accountID))

View file

@ -38,11 +38,19 @@ type (
const DefaultRequestTimeout = 120 * time.Second const DefaultRequestTimeout = 120 * time.Second
// NewHTTPClient creates an *http.Client with an optional proxy and the default timeout. // NewHTTPClient creates an *http.Client with an optional proxy, the default timeout, and debug logging installed.
func NewHTTPClient(proxy string) *http.Client { func NewHTTPClient(proxy string) *http.Client {
client := &http.Client{ return NewHTTPClientWithTimeout(proxy, DefaultRequestTimeout)
Timeout: DefaultRequestTimeout,
} }
// NewHTTPClientWithTimeout creates an *http.Client with an optional proxy, a custom timeout, and debug logging installed.
func NewHTTPClientWithTimeout(proxy string, timeout time.Duration) *http.Client {
client := &http.Client{
Timeout: timeout,
}
var baseTransport http.RoundTripper = http.DefaultTransport
if proxy != "" { if proxy != "" {
parsed, err := url.Parse(proxy) parsed, err := url.Parse(proxy)
if err == nil { if err == nil {
@ -50,10 +58,10 @@ func NewHTTPClient(proxy string) *http.Client {
if base, ok := http.DefaultTransport.(*http.Transport); ok { if base, ok := http.DefaultTransport.(*http.Transport); ok {
tr := base.Clone() tr := base.Clone()
tr.Proxy = http.ProxyURL(parsed) tr.Proxy = http.ProxyURL(parsed)
client.Transport = tr baseTransport = tr
} else { } else {
// Fallback: minimal transport if DefaultTransport is not *http.Transport. // Fallback: minimal transport if DefaultTransport is not *http.Transport.
client.Transport = &http.Transport{ baseTransport = &http.Transport{
Proxy: http.ProxyURL(parsed), Proxy: http.ProxyURL(parsed),
} }
} }
@ -61,6 +69,9 @@ func NewHTTPClient(proxy string) *http.Client {
log.Printf("common: invalid proxy URL %q: %v", proxy, err) log.Printf("common: invalid proxy URL %q: %v", proxy, err)
} }
} }
// Wrap with debug logger
client.Transport = &LoggingRoundTripper{Proxied: baseTransport}
return client return client
} }

View file

@ -22,9 +22,13 @@ func TestNewHTTPClient_DefaultTimeout(t *testing.T) {
func TestNewHTTPClient_WithProxy(t *testing.T) { func TestNewHTTPClient_WithProxy(t *testing.T) {
client := NewHTTPClient("http://127.0.0.1:8080") client := NewHTTPClient("http://127.0.0.1:8080")
transport, ok := client.Transport.(*http.Transport) lrt, ok := client.Transport.(*LoggingRoundTripper)
if !ok || lrt == nil {
t.Fatalf("expected *LoggingRoundTripper, got %T", client.Transport)
}
transport, ok := lrt.Proxied.(*http.Transport)
if !ok || transport == nil { if !ok || transport == nil {
t.Fatalf("expected http.Transport with proxy, got %T", client.Transport) t.Fatalf("expected http.Transport with proxy, got %T", lrt.Proxied)
} }
req := &http.Request{URL: &url.URL{Scheme: "https", Host: "api.example.com"}} req := &http.Request{URL: &url.URL{Scheme: "https", Host: "api.example.com"}}
gotProxy, err := transport.Proxy(req) gotProxy, err := transport.Proxy(req)
@ -38,8 +42,12 @@ func TestNewHTTPClient_WithProxy(t *testing.T) {
func TestNewHTTPClient_NoProxy(t *testing.T) { func TestNewHTTPClient_NoProxy(t *testing.T) {
client := NewHTTPClient("") client := NewHTTPClient("")
if client.Transport != nil { lrt, ok := client.Transport.(*LoggingRoundTripper)
t.Errorf("expected nil transport without proxy, got %T", client.Transport) if !ok || lrt == nil {
t.Fatalf("expected *LoggingRoundTripper, got %T", client.Transport)
}
if lrt.Proxied != http.DefaultTransport {
t.Errorf("expected Proxied to be http.DefaultTransport without proxy, got %T", lrt.Proxied)
} }
} }

View file

@ -0,0 +1,65 @@
package common
import (
"bytes"
"io"
"net/http"
"strings"
"github.com/sipeed/picoclaw/pkg/logger"
)
// LoggingRoundTripper wraps an http.RoundTripper and logs HTTP requests and responses
// when PicoClaw is configured in DEBUG mode.
type LoggingRoundTripper struct {
Proxied http.RoundTripper
}
func (lrt *LoggingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
if logger.GetLevel() <= logger.DEBUG {
logger.DebugCF("http_client", "Req: "+req.Method+" "+req.URL.String(), nil)
// Log Headers (redact sensitive)
headers := make(map[string]string)
for k, v := range req.Header {
lowerK := strings.ToLower(k)
if lowerK == "authorization" || lowerK == "api-key" || lowerK == "x-api-key" {
headers[k] = "[REDACTED]"
} else {
headers[k] = strings.Join(v, ", ")
}
}
logger.DebugCF("http_client", "Req Headers", map[string]any{"headers": headers})
// Log Body
if req.Body != nil {
bodyBytes, err := io.ReadAll(req.Body)
if err == nil {
req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
logger.DebugCF("http_client", "Req Body", map[string]any{"body": string(bodyBytes)})
}
}
}
res, err := lrt.Proxied.RoundTrip(req)
if logger.GetLevel() <= logger.DEBUG && res != nil {
logger.DebugCF("http_client", "Res Status: "+res.Status, nil)
// Optional: Log Response Body
if res.Body != nil {
bodyBytes, err := io.ReadAll(res.Body)
if err == nil {
res.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
// Truncate response body if too long to avoid flooding logs
respStr := string(bodyBytes)
if len(respStr) > 4000 {
respStr = respStr[:4000] + "... [TRUNCATED]"
}
logger.DebugCF("http_client", "Res Body", map[string]any{"body": respStr})
}
}
}
return res, err
}

View file

@ -240,14 +240,10 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
if cfg.APIKey() == "" { if cfg.APIKey() == "" {
return nil, "", fmt.Errorf("api_key is required for anthropic protocol (model: %s)", cfg.Model) return nil, "", fmt.Errorf("api_key is required for anthropic protocol (model: %s)", cfg.Model)
} }
return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( return anthropicmessages.NewProviderWithTimeout(
cfg.APIKey(), cfg.APIKey(),
apiBase, apiBase,
cfg.Proxy,
cfg.MaxTokensField,
cfg.RequestTimeout, cfg.RequestTimeout,
cfg.ExtraBody,
cfg.ExtraHeaders,
), modelID, nil ), modelID, nil
case "anthropic-messages": case "anthropic-messages":

View file

@ -519,9 +519,13 @@ func TestProvider_ProxyConfigured(t *testing.T) {
proxyURL := "http://127.0.0.1:8080" proxyURL := "http://127.0.0.1:8080"
p := NewProvider("key", "https://example.com", proxyURL) p := NewProvider("key", "https://example.com", proxyURL)
transport, ok := p.httpClient.Transport.(*http.Transport) lrt, ok := p.httpClient.Transport.(*common.LoggingRoundTripper)
if !ok || lrt == nil {
t.Fatalf("expected *common.LoggingRoundTripper, got %T", p.httpClient.Transport)
}
transport, ok := lrt.Proxied.(*http.Transport)
if !ok || transport == nil { if !ok || transport == nil {
t.Fatalf("expected http transport with proxy, got %T", p.httpClient.Transport) t.Fatalf("expected http transport with proxy, got %T", lrt.Proxied)
} }
req := &http.Request{URL: &url.URL{Scheme: "https", Host: "api.example.com"}} req := &http.Request{URL: &url.URL{Scheme: "https", Host: "api.example.com"}}

View file

@ -65,6 +65,14 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
} }
cfg.SecurityCopyFrom(oldCfg) cfg.SecurityCopyFrom(oldCfg)
// Intercept explicitly provided security tokens from JSON payload that json.Unmarshal drops.
var incomingSec config.SecurityConfig
if err := json.Unmarshal(body, &incomingSec); err == nil {
cfg.MergeAndApplySecurity(&incomingSec)
} else {
cfg.ApplySecurity()
}
if errs := validateConfig(&cfg); len(errs) > 0 { if errs := validateConfig(&cfg); len(errs) > 0 {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
@ -154,12 +162,13 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
return return
} }
// Restore security fields (tokens/keys) from the loaded config before validation, // Restore security fields from existing config and merge explicitly provided overrides.
// because private fields are lost during JSON round-trip.
newCfg.SecurityCopyFrom(cfg) newCfg.SecurityCopyFrom(cfg)
if err := newCfg.ApplySecurity(); err != nil { var incomingSec config.SecurityConfig
http.Error(w, fmt.Sprintf("Failed to apply security config: %v", err), http.StatusInternalServerError) if err := json.Unmarshal(patchBody, &incomingSec); err == nil {
return newCfg.MergeAndApplySecurity(&incomingSec)
} else {
newCfg.ApplySecurity()
} }
if errs := validateConfig(&newCfg); len(errs) > 0 { if errs := validateConfig(&newCfg); len(errs) > 0 {

File diff suppressed because it is too large Load diff