add openrouter appinfo
This commit is contained in:
parent
c36b06a901
commit
d68bce8a18
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"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -60,7 +61,8 @@ func NewProviderWithTimeout(apiKey, apiBase string, timeoutSeconds int) *Provide
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
apiBase: baseURL,
|
apiBase: baseURL,
|
||||||
httpClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
Timeout: timeout,
|
Timeout: timeout,
|
||||||
|
Transport: config.WrapTransportUserAgent(nil),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -208,7 +208,10 @@ func (p *Provider) Chat(
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Check for SSO token expiration errors and provide actionable guidance
|
// Check for SSO token expiration errors and provide actionable guidance
|
||||||
if isSSOTokenError(err) {
|
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)
|
return nil, fmt.Errorf("bedrock converse: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -583,13 +583,17 @@ func TestIsSSOTokenError(t *testing.T) {
|
||||||
expected: true,
|
expected: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "full SSO error message",
|
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,
|
expected: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SSO token file missing",
|
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,
|
expected: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -43,17 +44,18 @@ func NewHTTPClient(proxy string) *http.Client {
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
Timeout: DefaultRequestTimeout,
|
Timeout: DefaultRequestTimeout,
|
||||||
}
|
}
|
||||||
|
base := http.DefaultTransport
|
||||||
if proxy != "" {
|
if proxy != "" {
|
||||||
parsed, err := url.Parse(proxy)
|
parsed, err := url.Parse(proxy)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// Preserve http.DefaultTransport settings (TLS, HTTP/2, timeouts, etc.)
|
// Preserve http.DefaultTransport settings (TLS, HTTP/2, timeouts, etc.)
|
||||||
if base, ok := http.DefaultTransport.(*http.Transport); ok {
|
if tr, ok := http.DefaultTransport.(*http.Transport); ok {
|
||||||
tr := base.Clone()
|
clone := tr.Clone()
|
||||||
tr.Proxy = http.ProxyURL(parsed)
|
clone.Proxy = http.ProxyURL(parsed)
|
||||||
client.Transport = tr
|
base = clone
|
||||||
} else {
|
} else {
|
||||||
// Fallback: minimal transport if DefaultTransport is not *http.Transport.
|
// Fallback: minimal transport if DefaultTransport is not *http.Transport.
|
||||||
client.Transport = &http.Transport{
|
base = &http.Transport{
|
||||||
Proxy: http.ProxyURL(parsed),
|
Proxy: http.ProxyURL(parsed),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -61,6 +63,7 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
client.Transport = config.WrapTransportUserAgent(base)
|
||||||
return client
|
return client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -22,9 +23,10 @@ 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)
|
base := config.UnwrapUserAgent(client.Transport)
|
||||||
|
transport, ok := base.(*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", base)
|
||||||
}
|
}
|
||||||
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 +40,9 @@ 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 {
|
base := config.UnwrapUserAgent(client.Transport)
|
||||||
t.Errorf("expected nil transport without proxy, got %T", client.Transport)
|
if base == nil {
|
||||||
|
t.Fatal("expected non-nil base transport")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,9 +41,9 @@ func NewGitHubCopilotProvider(uri string, connectMode string, model string) (*Gi
|
||||||
}
|
}
|
||||||
|
|
||||||
session, err := client.CreateSession(context.Background(), &copilot.SessionConfig{
|
session, err := client.CreateSession(context.Background(), &copilot.SessionConfig{
|
||||||
Model: model,
|
Model: model,
|
||||||
OnPermissionRequest: copilot.PermissionHandler.ApproveAll,
|
OnPermissionRequest: copilot.PermissionHandler.ApproveAll,
|
||||||
Hooks: &copilot.SessionHooks{},
|
Hooks: &copilot.SessionHooks{},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
client.Stop()
|
client.Stop()
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers/common"
|
"github.com/sipeed/picoclaw/pkg/providers/common"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
||||||
)
|
)
|
||||||
|
|
@ -42,6 +43,63 @@ type Option func(*Provider)
|
||||||
|
|
||||||
const defaultRequestTimeout = common.DefaultRequestTimeout
|
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 cancelled 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 {
|
func WithMaxTokensField(maxTokensField string) Option {
|
||||||
return func(p *Provider) {
|
return func(p *Provider) {
|
||||||
p.maxTokensField = maxTokensField
|
p.maxTokensField = maxTokensField
|
||||||
|
|
@ -174,27 +232,56 @@ func (p *Provider) Chat(
|
||||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "POST", p.apiBase+"/chat/completions", bytes.NewReader(jsonData))
|
maxAttempts := 1
|
||||||
if err != nil {
|
if isOpenRouterHost(p.apiBase) {
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
maxAttempts = openRouter429MaxAttempts
|
||||||
}
|
}
|
||||||
|
resolvedModel := normalizeModel(model, p.apiBase)
|
||||||
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||||
if p.apiKey != "" {
|
req, err := http.NewRequestWithContext(ctx, "POST", p.apiBase+"/chat/completions", bytes.NewReader(jsonData))
|
||||||
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusOK {
|
||||||
|
out, readErr := common.ReadAndParseResponse(resp, p.apiBase)
|
||||||
|
resp.Body.Close()
|
||||||
|
return out, readErr
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
return common.ReadAndParseResponse(resp, p.apiBase)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChatStream implements streaming via OpenAI-compatible SSE (stream: true).
|
// ChatStream implements streaming via OpenAI-compatible SSE (stream: true).
|
||||||
|
|
@ -219,32 +306,59 @@ func (p *Provider) ChatStream(
|
||||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "POST", p.apiBase+"/chat/completions", bytes.NewReader(jsonData))
|
maxAttempts := 1
|
||||||
if err != nil {
|
if isOpenRouterHost(p.apiBase) {
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
maxAttempts = openRouter429MaxAttempts
|
||||||
}
|
}
|
||||||
|
resolvedModel := normalizeModel(model, p.apiBase)
|
||||||
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Accept", "text/event-stream")
|
|
||||||
if p.apiKey != "" {
|
|
||||||
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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}
|
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 {
|
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||||
return nil, common.HandleErrorResponse(resp, p.apiBase)
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
return parseStreamResponse(ctx, resp.Body, onChunk)
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "text/event-stream")
|
||||||
|
if p.apiKey != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||||||
|
}
|
||||||
|
applyOpenRouterAttributionHeaders(req.Header, p.apiBase)
|
||||||
|
|
||||||
|
resp, err := streamClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusOK {
|
||||||
|
out, streamErr := parseStreamResponse(ctx, resp.Body, onChunk)
|
||||||
|
resp.Body.Close()
|
||||||
|
return out, streamErr
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
// parseStreamResponse parses an OpenAI-compatible SSE stream.
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers/common"
|
"github.com/sipeed/picoclaw/pkg/providers/common"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
||||||
)
|
)
|
||||||
|
|
@ -519,9 +520,9 @@ 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)
|
transport, ok := config.UnwrapUserAgent(p.httpClient.Transport).(*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", config.UnwrapUserAgent(p.httpClient.Transport))
|
||||||
}
|
}
|
||||||
|
|
||||||
req := &http.Request{URL: &url.URL{Scheme: "https", Host: "api.example.com"}}
|
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")
|
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 != 3 {
|
||||||
|
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"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -75,11 +76,11 @@ func NewClawHubRegistry(cfg ClawHubConfig) *ClawHubRegistry {
|
||||||
maxResponseSize: maxResp,
|
maxResponseSize: maxResp,
|
||||||
client: &http.Client{
|
client: &http.Client{
|
||||||
Timeout: timeout,
|
Timeout: timeout,
|
||||||
Transport: &http.Transport{
|
Transport: config.WrapTransportUserAgent(&http.Transport{
|
||||||
MaxIdleConns: 5,
|
MaxIdleConns: 5,
|
||||||
IdleConnTimeout: 30 * time.Second,
|
IdleConnTimeout: 30 * time.Second,
|
||||||
TLSHandshakeTimeout: 10 * time.Second,
|
TLSHandshakeTimeout: 10 * time.Second,
|
||||||
},
|
}),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseGitHubRef(t *testing.T) {
|
func TestParseGitHubRef(t *testing.T) {
|
||||||
|
|
@ -224,9 +226,10 @@ func TestNewSkillInstaller_WithProxy(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the transport has proxy configured
|
// 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 {
|
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 {
|
if transport.Proxy == nil {
|
||||||
|
|
|
||||||
|
|
@ -6,20 +6,19 @@ import (
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CreateHTTPClient creates an HTTP client with optional proxy support.
|
// CreateHTTPClient creates an HTTP client with optional proxy support.
|
||||||
// If proxyURL is empty, it uses the system environment proxy settings.
|
// If proxyURL is empty, it uses the system environment proxy settings.
|
||||||
// Supported proxy schemes: http, https, socks5, socks5h.
|
// Supported proxy schemes: http, https, socks5, socks5h.
|
||||||
func CreateHTTPClient(proxyURL string, timeout time.Duration) (*http.Client, error) {
|
func CreateHTTPClient(proxyURL string, timeout time.Duration) (*http.Client, error) {
|
||||||
client := &http.Client{
|
tr := &http.Transport{
|
||||||
Timeout: timeout,
|
MaxIdleConns: 10,
|
||||||
Transport: &http.Transport{
|
IdleConnTimeout: 30 * time.Second,
|
||||||
MaxIdleConns: 10,
|
DisableCompression: false,
|
||||||
IdleConnTimeout: 30 * time.Second,
|
TLSHandshakeTimeout: 15 * time.Second,
|
||||||
DisableCompression: false,
|
|
||||||
TLSHandshakeTimeout: 15 * time.Second,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if proxyURL != "" {
|
if proxyURL != "" {
|
||||||
|
|
@ -39,10 +38,13 @@ func CreateHTTPClient(proxyURL string, timeout time.Duration) (*http.Client, err
|
||||||
if proxy.Host == "" {
|
if proxy.Host == "" {
|
||||||
return nil, fmt.Errorf("invalid proxy URL: missing host")
|
return nil, fmt.Errorf("invalid proxy URL: missing host")
|
||||||
}
|
}
|
||||||
client.Transport.(*http.Transport).Proxy = http.ProxyURL(proxy)
|
tr.Proxy = http.ProxyURL(proxy)
|
||||||
} else {
|
} 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"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCreateHTTPClient_ProxyConfigured(t *testing.T) {
|
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)
|
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 {
|
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 {
|
if tr.Proxy == nil {
|
||||||
t.Fatal("transport.Proxy is nil, want non-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)
|
t.Fatalf("createHTTPClient() error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
tr, ok := client.Transport.(*http.Transport)
|
base := config.UnwrapUserAgent(client.Transport)
|
||||||
|
tr, ok := base.(*http.Transport)
|
||||||
if !ok {
|
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)
|
req, err := http.NewRequest("GET", "https://example.com", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -92,9 +96,10 @@ func TestCreateHTTPClient_ProxyFromEnvironmentWhenConfigEmpty(t *testing.T) {
|
||||||
t.Fatalf("createHTTPClient() error: %v", err)
|
t.Fatalf("createHTTPClient() error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
tr, ok := client.Transport.(*http.Transport)
|
base := config.UnwrapUserAgent(client.Transport)
|
||||||
|
tr, ok := base.(*http.Transport)
|
||||||
if !ok {
|
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 {
|
if tr.Proxy == nil {
|
||||||
t.Fatal("transport.Proxy is nil, want proxy function from environment")
|
t.Fatal("transport.Proxy is nil, want proxy function from environment")
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue