fix config anthropic model, add log request provider
This commit is contained in:
parent
e38e2a3a63
commit
ff7095e88e
14 changed files with 493 additions and 350 deletions
|
|
@ -51,9 +51,9 @@ func hiddenValues(key string, value map[string]any, ch config.ChannelsConfig) {
|
|||
case "wecom":
|
||||
value["secret"] = ch.WeCom.Secret()
|
||||
case "dingtalk":
|
||||
value["secret"] = ch.QQ.AppSecret()
|
||||
case "qq":
|
||||
value["secret"] = ch.DingTalk.ClientSecret()
|
||||
case "qq":
|
||||
value["secret"] = ch.QQ.AppSecret()
|
||||
case "irc":
|
||||
value["password"] = ch.IRC.Password()
|
||||
value["serv_password"] = ch.IRC.NickServPassword()
|
||||
|
|
|
|||
|
|
@ -1739,17 +1739,35 @@ func SaveConfig(path string, cfg *Config) error {
|
|||
cfg.Version = CurrentVersion
|
||||
}
|
||||
names := toNameIndex(cfg.ModelList)
|
||||
newModelList := make(map[string]ModelSecurityEntry)
|
||||
|
||||
for i, m := range cfg.ModelList {
|
||||
if m.secDirty {
|
||||
if m.secModelName == "" {
|
||||
m.secModelName = names[i]
|
||||
}
|
||||
cfg.security.ModelList[m.secModelName] = ModelSecurityEntry{
|
||||
APIKeys: m.apiKeys,
|
||||
}
|
||||
m.secDirty = false
|
||||
if m.IsVirtual() {
|
||||
continue
|
||||
}
|
||||
|
||||
newName := names[i]
|
||||
oldName := m.secModelName
|
||||
|
||||
if m.secDirty {
|
||||
newModelList[newName] = ModelSecurityEntry{APIKeys: m.apiKeys}
|
||||
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 {
|
||||
cfg.security.Channels.Pico = &PicoSecurity{
|
||||
Token: cfg.Channels.Pico.Token(),
|
||||
|
|
|
|||
|
|
@ -229,6 +229,16 @@ func saveSecurityConfig(securityPath string, sec *SecurityConfig) error {
|
|||
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'.
|
||||
// This is used during config migration to preserve existing security data while adding new entries.
|
||||
func mergeSecurityConfig(existing, newer *SecurityConfig) *SecurityConfig {
|
||||
|
|
|
|||
|
|
@ -100,6 +100,9 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error
|
|||
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)
|
||||
|
||||
if debug {
|
||||
|
|
@ -204,6 +207,11 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error
|
|||
runningServices.reloading.Store(false)
|
||||
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)
|
||||
if err != nil {
|
||||
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")
|
||||
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")
|
||||
|
||||
select {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/providers/common"
|
||||
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
||||
)
|
||||
|
||||
|
|
@ -59,9 +60,7 @@ func NewProviderWithTimeout(apiKey, apiBase string, timeoutSeconds int) *Provide
|
|||
return &Provider{
|
||||
apiKey: apiKey,
|
||||
apiBase: baseURL,
|
||||
httpClient: &http.Client{
|
||||
Timeout: timeout,
|
||||
},
|
||||
httpClient: common.NewHTTPClientWithTimeout("", timeout),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import (
|
|||
|
||||
"github.com/sipeed/picoclaw/pkg/auth"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/providers/common"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -36,9 +37,7 @@ type AntigravityProvider struct {
|
|||
func NewAntigravityProvider() *AntigravityProvider {
|
||||
return &AntigravityProvider{
|
||||
tokenSource: createAntigravityTokenSource(),
|
||||
httpClient: &http.Client{
|
||||
Timeout: 120 * time.Second,
|
||||
},
|
||||
httpClient: common.NewHTTPClientWithTimeout("", 120*time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -633,7 +632,7 @@ func FetchAntigravityProjectID(accessToken string) (string, error) {
|
|||
req.Header.Set("User-Agent", antigravityUserAgent)
|
||||
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)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
|
@ -677,7 +676,7 @@ func FetchAntigravityModels(accessToken, projectID string) ([]AntigravityModelIn
|
|||
req.Header.Set("User-Agent", antigravityUserAgent)
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import (
|
|||
|
||||
"github.com/sipeed/picoclaw/pkg/auth"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/providers/common"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -35,6 +36,7 @@ func NewCodexProvider(token, accountID string) *CodexProvider {
|
|||
option.WithAPIKey(token),
|
||||
option.WithHeader("originator", "codex_cli_rs"),
|
||||
option.WithHeader("OpenAI-Beta", "responses=experimental"),
|
||||
option.WithHTTPClient(common.NewHTTPClientWithTimeout("", 0)),
|
||||
}
|
||||
if accountID != "" {
|
||||
opts = append(opts, option.WithHeader("Chatgpt-Account-Id", accountID))
|
||||
|
|
|
|||
|
|
@ -38,11 +38,19 @@ type (
|
|||
|
||||
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 {
|
||||
return NewHTTPClientWithTimeout(proxy, 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: DefaultRequestTimeout,
|
||||
Timeout: timeout,
|
||||
}
|
||||
|
||||
var baseTransport http.RoundTripper = http.DefaultTransport
|
||||
|
||||
if proxy != "" {
|
||||
parsed, err := url.Parse(proxy)
|
||||
if err == nil {
|
||||
|
|
@ -50,10 +58,10 @@ func NewHTTPClient(proxy string) *http.Client {
|
|||
if base, ok := http.DefaultTransport.(*http.Transport); ok {
|
||||
tr := base.Clone()
|
||||
tr.Proxy = http.ProxyURL(parsed)
|
||||
client.Transport = tr
|
||||
baseTransport = tr
|
||||
} else {
|
||||
// Fallback: minimal transport if DefaultTransport is not *http.Transport.
|
||||
client.Transport = &http.Transport{
|
||||
baseTransport = &http.Transport{
|
||||
Proxy: http.ProxyURL(parsed),
|
||||
}
|
||||
}
|
||||
|
|
@ -61,6 +69,9 @@ func NewHTTPClient(proxy string) *http.Client {
|
|||
log.Printf("common: invalid proxy URL %q: %v", proxy, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap with debug logger
|
||||
client.Transport = &LoggingRoundTripper{Proxied: baseTransport}
|
||||
return client
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,9 +22,13 @@ func TestNewHTTPClient_DefaultTimeout(t *testing.T) {
|
|||
|
||||
func TestNewHTTPClient_WithProxy(t *testing.T) {
|
||||
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 {
|
||||
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"}}
|
||||
gotProxy, err := transport.Proxy(req)
|
||||
|
|
@ -38,8 +42,12 @@ func TestNewHTTPClient_WithProxy(t *testing.T) {
|
|||
|
||||
func TestNewHTTPClient_NoProxy(t *testing.T) {
|
||||
client := NewHTTPClient("")
|
||||
if client.Transport != nil {
|
||||
t.Errorf("expected nil transport without proxy, got %T", client.Transport)
|
||||
lrt, ok := client.Transport.(*LoggingRoundTripper)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
65
pkg/providers/common/http_logger.go
Normal file
65
pkg/providers/common/http_logger.go
Normal 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
|
||||
}
|
||||
|
|
@ -240,14 +240,10 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
|||
if cfg.APIKey() == "" {
|
||||
return nil, "", fmt.Errorf("api_key is required for anthropic protocol (model: %s)", cfg.Model)
|
||||
}
|
||||
return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
|
||||
return anthropicmessages.NewProviderWithTimeout(
|
||||
cfg.APIKey(),
|
||||
apiBase,
|
||||
cfg.Proxy,
|
||||
cfg.MaxTokensField,
|
||||
cfg.RequestTimeout,
|
||||
cfg.ExtraBody,
|
||||
cfg.ExtraHeaders,
|
||||
), modelID, nil
|
||||
|
||||
case "anthropic-messages":
|
||||
|
|
|
|||
|
|
@ -519,9 +519,13 @@ func TestProvider_ProxyConfigured(t *testing.T) {
|
|||
proxyURL := "http://127.0.0.1:8080"
|
||||
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 {
|
||||
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"}}
|
||||
|
|
|
|||
|
|
@ -65,6 +65,14 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
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 {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
|
|
@ -154,12 +162,13 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// Restore security fields (tokens/keys) from the loaded config before validation,
|
||||
// because private fields are lost during JSON round-trip.
|
||||
// Restore security fields from existing config and merge explicitly provided overrides.
|
||||
newCfg.SecurityCopyFrom(cfg)
|
||||
if err := newCfg.ApplySecurity(); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to apply security config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
var incomingSec config.SecurityConfig
|
||||
if err := json.Unmarshal(patchBody, &incomingSec); err == nil {
|
||||
newCfg.MergeAndApplySecurity(&incomingSec)
|
||||
} else {
|
||||
newCfg.ApplySecurity()
|
||||
}
|
||||
|
||||
if errs := validateConfig(&newCfg); len(errs) > 0 {
|
||||
|
|
|
|||
632
web/frontend/pnpm-lock.yaml
generated
632
web/frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue