feat:add openrouter appinfo
This commit is contained in:
parent
d11f1bc064
commit
e89913a28b
14 changed files with 523 additions and 79 deletions
49
pkg/config/useragent_transport.go
Normal file
49
pkg/config/useragent_transport.go
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// HTTPUserAgent is the default User-Agent for outbound HTTP requests (e.g. "PicoClaw/0.2.4").
|
||||
func HTTPUserAgent() string {
|
||||
v := strings.TrimSpace(Version)
|
||||
if v == "" {
|
||||
v = "dev"
|
||||
}
|
||||
return "PicoClaw/" + v
|
||||
}
|
||||
|
||||
// userAgentTransport wraps an http.RoundTripper and sets User-Agent to HTTPUserAgent when
|
||||
// the request does not already specify one.
|
||||
type userAgentTransport struct {
|
||||
base http.RoundTripper
|
||||
ua string
|
||||
}
|
||||
|
||||
// WrapTransportUserAgent wraps base so requests without User-Agent receive HTTPUserAgent().
|
||||
// If base is nil, http.DefaultTransport is used.
|
||||
func WrapTransportUserAgent(base http.RoundTripper) http.RoundTripper {
|
||||
if base == nil {
|
||||
base = http.DefaultTransport
|
||||
}
|
||||
return &userAgentTransport{base: base, ua: HTTPUserAgent()}
|
||||
}
|
||||
|
||||
// UnwrapUserAgent returns the inner RoundTripper if rt was produced by WrapTransportUserAgent; otherwise rt.
|
||||
func UnwrapUserAgent(rt http.RoundTripper) http.RoundTripper {
|
||||
if t, ok := rt.(*userAgentTransport); ok {
|
||||
return t.base
|
||||
}
|
||||
return rt
|
||||
}
|
||||
|
||||
// RoundTrip implements http.RoundTripper.
|
||||
func (t *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if req.Header.Get("User-Agent") != "" {
|
||||
return t.base.RoundTrip(req)
|
||||
}
|
||||
r2 := req.Clone(req.Context())
|
||||
r2.Header.Set("User-Agent", t.ua)
|
||||
return t.base.RoundTrip(r2)
|
||||
}
|
||||
105
pkg/config/useragent_transport_test.go
Normal file
105
pkg/config/useragent_transport_test.go
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHTTPUserAgent_format(t *testing.T) {
|
||||
ua := HTTPUserAgent()
|
||||
if !strings.HasPrefix(ua, "PicoClaw/") {
|
||||
t.Fatalf("want PicoClaw/ prefix, got %q", ua)
|
||||
}
|
||||
suffix := strings.TrimPrefix(ua, "PicoClaw/")
|
||||
if strings.TrimSpace(suffix) == "" {
|
||||
t.Fatal("empty version suffix")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapTransportUserAgent_SetsDefaultUA(t *testing.T) {
|
||||
var gotUA string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotUA = r.Header.Get("User-Agent")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
client := &http.Client{Transport: WrapTransportUserAgent(http.DefaultTransport)}
|
||||
req, err := http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
want := HTTPUserAgent()
|
||||
if gotUA != want {
|
||||
t.Fatalf("User-Agent = %q, want %q", gotUA, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapTransportUserAgent_PreservesExplicitUA(t *testing.T) {
|
||||
var gotUA string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotUA = r.Header.Get("User-Agent")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
client := &http.Client{Transport: WrapTransportUserAgent(http.DefaultTransport)}
|
||||
req, err := http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "custom-agent/1")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if gotUA != "custom-agent/1" {
|
||||
t.Fatalf("User-Agent = %q, want custom-agent/1", gotUA)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapTransportUserAgent_DoesNotMutateOriginalRequest(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
rt := WrapTransportUserAgent(http.DefaultTransport)
|
||||
req, err := http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if req.Header.Get("User-Agent") != "" {
|
||||
t.Fatal("expected empty User-Agent on original request")
|
||||
}
|
||||
resp, err := rt.RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if req.Header.Get("User-Agent") != "" {
|
||||
t.Fatal("RoundTrip must not mutate the original request's headers")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnwrapUserAgent(t *testing.T) {
|
||||
inner := http.DefaultTransport
|
||||
wrapped := WrapTransportUserAgent(inner)
|
||||
if UnwrapUserAgent(wrapped) != inner {
|
||||
t.Fatal("UnwrapUserAgent should return inner transport")
|
||||
}
|
||||
if UnwrapUserAgent(inner) != inner {
|
||||
t.Fatal("UnwrapUserAgent on non-wrapped should return same")
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
||||
)
|
||||
|
||||
|
|
@ -61,6 +62,7 @@ func NewProviderWithTimeout(apiKey, apiBase string, timeoutSeconds int) *Provide
|
|||
apiBase: baseURL,
|
||||
httpClient: &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: config.WrapTransportUserAgent(nil),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -208,7 +208,10 @@ func (p *Provider) Chat(
|
|||
if err != nil {
|
||||
// Check for SSO token expiration errors and provide actionable guidance
|
||||
if isSSOTokenError(err) {
|
||||
return nil, fmt.Errorf("bedrock converse: AWS credentials may have expired. If using AWS SSO, run 'aws sso login' to refresh: %w", err)
|
||||
return nil, fmt.Errorf(
|
||||
"bedrock converse: AWS credentials may have expired. If using AWS SSO, run 'aws sso login' to refresh: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
return nil, fmt.Errorf("bedrock converse: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -584,12 +584,16 @@ func TestIsSSOTokenError(t *testing.T) {
|
|||
},
|
||||
{
|
||||
name: "full SSO error message",
|
||||
err: fmt.Errorf("get identity: get credentials: failed to refresh cached credentials, refresh cached SSO token failed, unable to refresh SSO token"),
|
||||
err: fmt.Errorf(
|
||||
"get identity: get credentials: failed to refresh cached credentials, refresh cached SSO token failed, unable to refresh SSO token",
|
||||
),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "SSO token file missing",
|
||||
err: fmt.Errorf("get identity: get credentials: failed to refresh cached credentials, failed to read cached SSO token file, open ~/.aws/sso/cache/abc123.json: no such file or directory"),
|
||||
err: fmt.Errorf(
|
||||
"get identity: get credentials: failed to refresh cached credentials, failed to read cached SSO token file, open ~/.aws/sso/cache/abc123.json: no such file or directory",
|
||||
),
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
||||
)
|
||||
|
||||
|
|
@ -43,17 +44,18 @@ func NewHTTPClient(proxy string) *http.Client {
|
|||
client := &http.Client{
|
||||
Timeout: DefaultRequestTimeout,
|
||||
}
|
||||
base := http.DefaultTransport
|
||||
if proxy != "" {
|
||||
parsed, err := url.Parse(proxy)
|
||||
if err == nil {
|
||||
// Preserve http.DefaultTransport settings (TLS, HTTP/2, timeouts, etc.)
|
||||
if base, ok := http.DefaultTransport.(*http.Transport); ok {
|
||||
tr := base.Clone()
|
||||
tr.Proxy = http.ProxyURL(parsed)
|
||||
client.Transport = tr
|
||||
if tr, ok := http.DefaultTransport.(*http.Transport); ok {
|
||||
clone := tr.Clone()
|
||||
clone.Proxy = http.ProxyURL(parsed)
|
||||
base = clone
|
||||
} else {
|
||||
// Fallback: minimal transport if DefaultTransport is not *http.Transport.
|
||||
client.Transport = &http.Transport{
|
||||
base = &http.Transport{
|
||||
Proxy: http.ProxyURL(parsed),
|
||||
}
|
||||
}
|
||||
|
|
@ -61,6 +63,7 @@ func NewHTTPClient(proxy string) *http.Client {
|
|||
log.Printf("common: invalid proxy URL %q: %v", proxy, err)
|
||||
}
|
||||
}
|
||||
client.Transport = config.WrapTransportUserAgent(base)
|
||||
return client
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
||||
)
|
||||
|
||||
|
|
@ -22,9 +23,10 @@ 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)
|
||||
base := config.UnwrapUserAgent(client.Transport)
|
||||
transport, ok := base.(*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", base)
|
||||
}
|
||||
req := &http.Request{URL: &url.URL{Scheme: "https", Host: "api.example.com"}}
|
||||
gotProxy, err := transport.Proxy(req)
|
||||
|
|
@ -38,8 +40,9 @@ 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)
|
||||
base := config.UnwrapUserAgent(client.Transport)
|
||||
if base == nil {
|
||||
t.Fatal("expected non-nil base transport")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/providers/common"
|
||||
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
||||
)
|
||||
|
|
@ -42,6 +43,63 @@ type Option func(*Provider)
|
|||
|
||||
const defaultRequestTimeout = common.DefaultRequestTimeout
|
||||
|
||||
// OpenRouter free-tier models often return transient HTTP 429; retry a few times before surfacing.
|
||||
const (
|
||||
openRouter429MaxAttempts = 10
|
||||
openRouter429Backoff = time.Second
|
||||
)
|
||||
|
||||
// OpenRouter app attribution (optional headers for rankings/analytics).
|
||||
// See https://openrouter.ai/docs/app-attribution
|
||||
const (
|
||||
openRouterAttributionReferer = "https://picoclaw.io/"
|
||||
openRouterAttributionTitle = "PicoClaw"
|
||||
openRouterAttributionCategories = "personal-agent,general-chat"
|
||||
)
|
||||
|
||||
func isOpenRouterHost(apiBase string) bool {
|
||||
return strings.Contains(strings.ToLower(strings.TrimSpace(apiBase)), "openrouter.ai")
|
||||
}
|
||||
|
||||
func openRouterLogKind(stream bool) string {
|
||||
if stream {
|
||||
return "ChatStream"
|
||||
}
|
||||
return "Chat"
|
||||
}
|
||||
|
||||
func logOpenRouter429Retry(stream bool, resolvedModel string, attempt, maxAttempts int) {
|
||||
logger.WarnC("agent",
|
||||
fmt.Sprintf("openai_compat OpenRouter %s: HTTP 429 (attempt %d/%d), backing off %v then retry (model=%q)",
|
||||
openRouterLogKind(stream), attempt, maxAttempts, openRouter429Backoff, resolvedModel,
|
||||
))
|
||||
}
|
||||
|
||||
func logOpenRouter429Exhausted(stream bool, resolvedModel string, maxAttempts int) {
|
||||
logger.WarnC("agent",
|
||||
fmt.Sprintf("openai_compat OpenRouter %s: HTTP 429 after %d attempts, giving up (model=%q)",
|
||||
openRouterLogKind(stream), maxAttempts, resolvedModel,
|
||||
))
|
||||
}
|
||||
|
||||
func logOpenRouter429BackoffCancelled(stream bool, resolvedModel string, attempt, maxAttempts int, cancelErr error) {
|
||||
log.Printf(
|
||||
"openai_compat OpenRouter %s: HTTP 429 retry cancel during backoff (attempt %d/%d, model=%q): %v",
|
||||
openRouterLogKind(stream), attempt, maxAttempts, resolvedModel, cancelErr,
|
||||
)
|
||||
}
|
||||
|
||||
// applyOpenRouterAttributionHeaders sets Referer, X-OpenRouter-Title, and X-OpenRouter-Categories
|
||||
// when the API base is OpenRouter. Uses the standard Referer header name (HTTP-Referer in OpenRouter docs).
|
||||
func applyOpenRouterAttributionHeaders(h http.Header, apiBase string) {
|
||||
if !isOpenRouterHost(apiBase) {
|
||||
return
|
||||
}
|
||||
h.Set("Referer", openRouterAttributionReferer)
|
||||
h.Set("X-Openrouter-Title", openRouterAttributionTitle)
|
||||
h.Set("X-Openrouter-Categories", openRouterAttributionCategories)
|
||||
}
|
||||
|
||||
func WithMaxTokensField(maxTokensField string) Option {
|
||||
return func(p *Provider) {
|
||||
p.maxTokensField = maxTokensField
|
||||
|
|
@ -174,6 +232,13 @@ func (p *Provider) Chat(
|
|||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
maxAttempts := 1
|
||||
if isOpenRouterHost(p.apiBase) {
|
||||
maxAttempts = openRouter429MaxAttempts
|
||||
}
|
||||
resolvedModel := normalizeModel(model, p.apiBase)
|
||||
|
||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", p.apiBase+"/chat/completions", bytes.NewReader(jsonData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
|
|
@ -183,18 +248,40 @@ func (p *Provider) Chat(
|
|||
if p.apiKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||||
}
|
||||
applyOpenRouterAttributionHeaders(req.Header, p.apiBase)
|
||||
|
||||
resp, err := p.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, common.HandleErrorResponse(resp, p.apiBase)
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
out, readErr := common.ReadAndParseResponse(resp, p.apiBase)
|
||||
resp.Body.Close()
|
||||
return out, readErr
|
||||
}
|
||||
|
||||
return common.ReadAndParseResponse(resp, p.apiBase)
|
||||
if resp.StatusCode == http.StatusTooManyRequests && isOpenRouterHost(p.apiBase) && attempt < maxAttempts {
|
||||
logOpenRouter429Retry(false, resolvedModel, attempt, maxAttempts)
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
logOpenRouter429BackoffCancelled(false, resolvedModel, attempt, maxAttempts, ctx.Err())
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(openRouter429Backoff):
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusTooManyRequests && isOpenRouterHost(p.apiBase) {
|
||||
logOpenRouter429Exhausted(false, resolvedModel, maxAttempts)
|
||||
}
|
||||
err = common.HandleErrorResponse(resp, p.apiBase)
|
||||
resp.Body.Close()
|
||||
return nil, err
|
||||
}
|
||||
return nil, fmt.Errorf("internal error: chat request loop exited without return")
|
||||
}
|
||||
|
||||
// ChatStream implements streaming via OpenAI-compatible SSE (stream: true).
|
||||
|
|
@ -219,6 +306,15 @@ func (p *Provider) ChatStream(
|
|||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
maxAttempts := 1
|
||||
if isOpenRouterHost(p.apiBase) {
|
||||
maxAttempts = openRouter429MaxAttempts
|
||||
}
|
||||
resolvedModel := normalizeModel(model, p.apiBase)
|
||||
|
||||
streamClient := &http.Client{Transport: p.httpClient.Transport}
|
||||
|
||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", p.apiBase+"/chat/completions", bytes.NewReader(jsonData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
|
|
@ -229,22 +325,40 @@ func (p *Provider) ChatStream(
|
|||
if p.apiKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||||
}
|
||||
applyOpenRouterAttributionHeaders(req.Header, p.apiBase)
|
||||
|
||||
// Use a client without Timeout for streaming — the http.Client.Timeout covers
|
||||
// the entire request lifecycle including body reads, which would kill long streams.
|
||||
// Context cancellation still provides the safety net.
|
||||
streamClient := &http.Client{Transport: p.httpClient.Transport}
|
||||
resp, err := streamClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, common.HandleErrorResponse(resp, p.apiBase)
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
out, streamErr := parseStreamResponse(ctx, resp.Body, onChunk)
|
||||
resp.Body.Close()
|
||||
return out, streamErr
|
||||
}
|
||||
|
||||
return parseStreamResponse(ctx, resp.Body, onChunk)
|
||||
if resp.StatusCode == http.StatusTooManyRequests && isOpenRouterHost(p.apiBase) && attempt < maxAttempts {
|
||||
logOpenRouter429Retry(true, resolvedModel, attempt, maxAttempts)
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
logOpenRouter429BackoffCancelled(true, resolvedModel, attempt, maxAttempts, ctx.Err())
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(openRouter429Backoff):
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusTooManyRequests && isOpenRouterHost(p.apiBase) {
|
||||
logOpenRouter429Exhausted(true, resolvedModel, maxAttempts)
|
||||
}
|
||||
err = common.HandleErrorResponse(resp, p.apiBase)
|
||||
resp.Body.Close()
|
||||
return nil, err
|
||||
}
|
||||
return nil, fmt.Errorf("internal error: chat stream loop exited without return")
|
||||
}
|
||||
|
||||
// parseStreamResponse parses an OpenAI-compatible SSE stream.
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/providers/common"
|
||||
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
||||
)
|
||||
|
|
@ -519,9 +520,9 @@ 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)
|
||||
transport, ok := config.UnwrapUserAgent(p.httpClient.Transport).(*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", config.UnwrapUserAgent(p.httpClient.Transport))
|
||||
}
|
||||
|
||||
req := &http.Request{URL: &url.URL{Scheme: "https", Host: "api.example.com"}}
|
||||
|
|
@ -1173,3 +1174,152 @@ func TestSerializeMessages_StripsSystemParts(t *testing.T) {
|
|||
t.Fatal("system_parts should not appear in serialized output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderChat_OpenRouter429RetriesThenSucceeds(t *testing.T) {
|
||||
var n int
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasSuffix(r.URL.Path, "/chat/completions") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
n++
|
||||
if n < 3 {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
_, _ = w.Write([]byte(`{"error":{"message":"rate limit"}}`))
|
||||
return
|
||||
}
|
||||
resp := map[string]any{
|
||||
"choices": []map[string]any{
|
||||
{"message": map[string]any{"content": "ok"}, "finish_reason": "stop"},
|
||||
},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
apiBase := server.URL + "/via-openrouter.ai"
|
||||
p := NewProvider("key", apiBase, "")
|
||||
start := time.Now()
|
||||
out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "m", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error = %v", err)
|
||||
}
|
||||
if out == nil || out.Content != "ok" {
|
||||
t.Fatalf("unexpected response: %+v", out)
|
||||
}
|
||||
if n != 3 {
|
||||
t.Fatalf("request count = %d, want 3", n)
|
||||
}
|
||||
if d := time.Since(start); d < 2*time.Second-100*time.Millisecond {
|
||||
t.Fatalf("expected ~2s backoff between retries, got %v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderChat_OpenRouter429Exhausted(t *testing.T) {
|
||||
var n int
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasSuffix(r.URL.Path, "/chat/completions") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
n++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
_, _ = w.Write([]byte(`{"error":{"message":"rate limit"}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
apiBase := server.URL + "/via-openrouter.ai"
|
||||
p := NewProvider("key", apiBase, "")
|
||||
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "m", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error after 429 exhaustion")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "429") {
|
||||
t.Fatalf("error should mention 429: %v", err)
|
||||
}
|
||||
if n != 10 {
|
||||
t.Fatalf("request count = %d, want 3", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderChat_OpenRouterSendsAttributionHeaders(t *testing.T) {
|
||||
var gotReferer, gotTitle, gotCat string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasSuffix(r.URL.Path, "/chat/completions") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
gotReferer = r.Header.Get("Referer")
|
||||
gotTitle = r.Header.Get("X-OpenRouter-Title")
|
||||
gotCat = r.Header.Get("X-OpenRouter-Categories")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"choices": []map[string]any{
|
||||
{"message": map[string]any{"content": "ok"}, "finish_reason": "stop"},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
apiBase := server.URL + "/via-openrouter.ai"
|
||||
p := NewProvider("key", apiBase, "")
|
||||
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "m", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat: %v", err)
|
||||
}
|
||||
if gotReferer != openRouterAttributionReferer {
|
||||
t.Errorf("Referer = %q, want %q", gotReferer, openRouterAttributionReferer)
|
||||
}
|
||||
if gotTitle != openRouterAttributionTitle {
|
||||
t.Errorf("X-OpenRouter-Title = %q, want %q", gotTitle, openRouterAttributionTitle)
|
||||
}
|
||||
if gotCat != openRouterAttributionCategories {
|
||||
t.Errorf("X-OpenRouter-Categories = %q, want %q", gotCat, openRouterAttributionCategories)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderChat_NonOpenRouterOmitsAttributionHeaders(t *testing.T) {
|
||||
var gotReferer string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotReferer = r.Header.Get("Referer")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"choices": []map[string]any{
|
||||
{"message": map[string]any{"content": "ok"}, "finish_reason": "stop"},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
p := NewProvider("key", server.URL, "")
|
||||
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "m", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat: %v", err)
|
||||
}
|
||||
if gotReferer != "" {
|
||||
t.Errorf("non-OpenRouter base should not set Referer, got %q", gotReferer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderChat_NonOpenRouterSingle429NoRetry(t *testing.T) {
|
||||
var n int
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
_, _ = w.Write([]byte(`{"error":{"message":"slow down"}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
p := NewProvider("key", server.URL, "")
|
||||
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "m", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("request count = %d, want 1 (no OpenRouter 429 retry)", n)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
)
|
||||
|
||||
|
|
@ -75,11 +76,11 @@ func NewClawHubRegistry(cfg ClawHubConfig) *ClawHubRegistry {
|
|||
maxResponseSize: maxResp,
|
||||
client: &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &http.Transport{
|
||||
Transport: config.WrapTransportUserAgent(&http.Transport{
|
||||
MaxIdleConns: 5,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import (
|
|||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func TestParseGitHubRef(t *testing.T) {
|
||||
|
|
@ -224,9 +226,10 @@ func TestNewSkillInstaller_WithProxy(t *testing.T) {
|
|||
}
|
||||
|
||||
// Verify the transport has proxy configured
|
||||
transport, ok := installer.client.Transport.(*http.Transport)
|
||||
base := config.UnwrapUserAgent(installer.client.Transport)
|
||||
transport, ok := base.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatal("client.Transport is not *http.Transport")
|
||||
t.Fatalf("client base transport is not *http.Transport, got %T", base)
|
||||
}
|
||||
|
||||
if transport.Proxy == nil {
|
||||
|
|
|
|||
|
|
@ -6,20 +6,19 @@ import (
|
|||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
// CreateHTTPClient creates an HTTP client with optional proxy support.
|
||||
// If proxyURL is empty, it uses the system environment proxy settings.
|
||||
// Supported proxy schemes: http, https, socks5, socks5h.
|
||||
func CreateHTTPClient(proxyURL string, timeout time.Duration) (*http.Client, error) {
|
||||
client := &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &http.Transport{
|
||||
tr := &http.Transport{
|
||||
MaxIdleConns: 10,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
DisableCompression: false,
|
||||
TLSHandshakeTimeout: 15 * time.Second,
|
||||
},
|
||||
}
|
||||
|
||||
if proxyURL != "" {
|
||||
|
|
@ -39,10 +38,13 @@ func CreateHTTPClient(proxyURL string, timeout time.Duration) (*http.Client, err
|
|||
if proxy.Host == "" {
|
||||
return nil, fmt.Errorf("invalid proxy URL: missing host")
|
||||
}
|
||||
client.Transport.(*http.Transport).Proxy = http.ProxyURL(proxy)
|
||||
tr.Proxy = http.ProxyURL(proxy)
|
||||
} else {
|
||||
client.Transport.(*http.Transport).Proxy = http.ProxyFromEnvironment
|
||||
tr.Proxy = http.ProxyFromEnvironment
|
||||
}
|
||||
|
||||
return client, nil
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: config.WrapTransportUserAgent(tr),
|
||||
}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import (
|
|||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func TestCreateHTTPClient_ProxyConfigured(t *testing.T) {
|
||||
|
|
@ -16,9 +18,10 @@ func TestCreateHTTPClient_ProxyConfigured(t *testing.T) {
|
|||
t.Fatalf("client.Timeout = %v, want %v", client.Timeout, 12*time.Second)
|
||||
}
|
||||
|
||||
tr, ok := client.Transport.(*http.Transport)
|
||||
base := config.UnwrapUserAgent(client.Transport)
|
||||
tr, ok := base.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("client.Transport type = %T, want *http.Transport", client.Transport)
|
||||
t.Fatalf("base transport type = %T, want *http.Transport", base)
|
||||
}
|
||||
if tr.Proxy == nil {
|
||||
t.Fatal("transport.Proxy is nil, want non-nil")
|
||||
|
|
@ -50,9 +53,10 @@ func TestCreateHTTPClient_Socks5ProxyConfigured(t *testing.T) {
|
|||
t.Fatalf("createHTTPClient() error: %v", err)
|
||||
}
|
||||
|
||||
tr, ok := client.Transport.(*http.Transport)
|
||||
base := config.UnwrapUserAgent(client.Transport)
|
||||
tr, ok := base.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("client.Transport type = %T, want *http.Transport", client.Transport)
|
||||
t.Fatalf("base transport type = %T, want *http.Transport", base)
|
||||
}
|
||||
req, err := http.NewRequest("GET", "https://example.com", nil)
|
||||
if err != nil {
|
||||
|
|
@ -92,9 +96,10 @@ func TestCreateHTTPClient_ProxyFromEnvironmentWhenConfigEmpty(t *testing.T) {
|
|||
t.Fatalf("createHTTPClient() error: %v", err)
|
||||
}
|
||||
|
||||
tr, ok := client.Transport.(*http.Transport)
|
||||
base := config.UnwrapUserAgent(client.Transport)
|
||||
tr, ok := base.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("client.Transport type = %T, want *http.Transport", client.Transport)
|
||||
t.Fatalf("base transport type = %T, want *http.Transport", base)
|
||||
}
|
||||
if tr.Proxy == nil {
|
||||
t.Fatal("transport.Proxy is nil, want proxy function from environment")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue