feat(provider): support per-source proxy_url for geo-blocked upstreams

Upstreams like justwoker/tabitoken sit behind Cloudflare geo/IP blocks and
only respond through a proxy. A global env proxy is wrong (intranet sources
trae/localzen must stay direct), so add an explicit per-source proxy_url
that overrides http.ProxyFromEnvironment for just that source. Sources
without proxy_url keep the existing env/direct behavior.

Also accepts a User-Agent header per source (config already supported
headers) so CF-fronted resellers can be reached.

Verified e2e: tabitoken and justwoker now return tool_calls through
127.0.0.1:7890 (clash); trae stays direct. Full go test -tags luajit
passes.
This commit is contained in:
JianFeeeee
2026-09-05 09:14:39 +08:00
parent 4d197e4bd3
commit 868ac59692
2 changed files with 41 additions and 17 deletions

View File

@ -56,6 +56,10 @@ type Source struct {
ImageEndpoint string `yaml:"image_endpoint" json:"image_endpoint,omitempty"` // image endpoint override ImageEndpoint string `yaml:"image_endpoint" json:"image_endpoint,omitempty"` // image endpoint override
Models []Model `yaml:"models" json:"models"` Models []Model `yaml:"models" json:"models"`
Headers map[string]string `yaml:"headers" json:"headers,omitempty"` Headers map[string]string `yaml:"headers" json:"headers,omitempty"`
// ProxyURL routes this source's HTTP(S) traffic through an explicit proxy
// (e.g. http://127.0.0.1:7890). Empty = direct connection. Needed for
// upstreams behind geo/IP blocks that only respond through a proxy.
ProxyURL string `yaml:"proxy_url" json:"proxy_url,omitempty"`
Meta map[string]interface{} `yaml:"meta" json:"meta,omitempty"` Meta map[string]interface{} `yaml:"meta" json:"meta,omitempty"`
Temperature float64 `yaml:"temperature" json:"temperature,omitempty"` Temperature float64 `yaml:"temperature" json:"temperature,omitempty"`
MaxTokens int `yaml:"max_tokens" json:"max_tokens,omitempty"` MaxTokens int `yaml:"max_tokens" json:"max_tokens,omitempty"`
@ -335,6 +339,8 @@ type SourceTemplate struct {
ImageEndpoint string `json:"image_endpoint,omitempty"` ImageEndpoint string `json:"image_endpoint,omitempty"`
Models []Model `json:"models"` Models []Model `json:"models"`
Headers map[string]string `json:"headers,omitempty"` Headers map[string]string `json:"headers,omitempty"`
// ProxyURL routes this source's HTTP(S) traffic through an explicit proxy.
ProxyURL string `json:"proxy_url,omitempty"`
Meta map[string]interface{} `json:"meta,omitempty"` Meta map[string]interface{} `json:"meta,omitempty"`
Temperature float64 `json:"temperature,omitempty"` Temperature float64 `json:"temperature,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"` MaxTokens int `json:"max_tokens,omitempty"`

View File

@ -13,6 +13,7 @@ import (
"log" "log"
"net" "net"
"net/http" "net/http"
"net/url"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@ -24,6 +25,20 @@ import (
"llmsproxy/internal/types" "llmsproxy/internal/types"
) )
// proxyFor picks the transport proxy for one source. An explicit per-source
// proxy_url wins (e.g. clash on 127.0.0.1:7890 for a geo-blocked upstream);
// otherwise fall back to the process-wide env proxy, which is direct by
// default.
func proxyFor(cfg config.Source) func(*http.Request) (*url.URL, error) {
if cfg.ProxyURL != "" {
proxyURL, err := url.Parse(cfg.ProxyURL)
if err == nil {
return http.ProxyURL(proxyURL)
}
}
return http.ProxyFromEnvironment
}
// ---- per-(source,model) scheduling state ---- // ---- per-(source,model) scheduling state ----
// ModelState is the scheduling state of one (source, model) pair: a soft // ModelState is the scheduling state of one (source, model) pair: a soft
@ -388,8 +403,11 @@ func New(cfg config.Source, vm *lua.VM) *Provider {
// Shared transport: ResponseHeaderTimeout bounds how long we wait for the // Shared transport: ResponseHeaderTimeout bounds how long we wait for the
// first response byte (applies to both paths); the stream client has no // first response byte (applies to both paths); the stream client has no
// client-level Timeout so the SSE body can run past the header timeout. // client-level Timeout so the SSE body can run past the header timeout.
// A per-source ProxyURL (e.g. clash on 127.0.0.1:7890) overrides the
// process-wide env proxy for upstreams that are geo/IP-blocked; sources
// without one keep http.ProxyFromEnvironment (direct by default).
tr := &http.Transport{ tr := &http.Transport{
Proxy: http.ProxyFromEnvironment, Proxy: proxyFor(cfg),
DialContext: (&net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}).DialContext, DialContext: (&net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
ForceAttemptHTTP2: true, ForceAttemptHTTP2: true,
MaxIdleConns: 100, MaxIdleConns: 100,