diff --git a/internal/config/config.go b/internal/config/config.go index 87be1c7..7fbdfe4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -47,15 +47,19 @@ type Model struct { // Source describes a single upstream LLM provider. type Source struct { - Name string `yaml:"name" json:"name"` - BaseURL string `yaml:"base_url" json:"base_url"` - APIKey string `yaml:"api_key" json:"api_key"` - APIKeyEnv string `yaml:"api_key_env,omitempty" json:"-"` // reference to an env var holding the key (overrides api_key) - Adapter string `yaml:"adapter" json:"adapter"` - Endpoint string `yaml:"endpoint" json:"endpoint,omitempty"` // chat endpoint override - ImageEndpoint string `yaml:"image_endpoint" json:"image_endpoint,omitempty"` // image endpoint override - Models []Model `yaml:"models" json:"models"` - Headers map[string]string `yaml:"headers" json:"headers,omitempty"` + Name string `yaml:"name" json:"name"` + BaseURL string `yaml:"base_url" json:"base_url"` + APIKey string `yaml:"api_key" json:"api_key"` + APIKeyEnv string `yaml:"api_key_env,omitempty" json:"-"` // reference to an env var holding the key (overrides api_key) + Adapter string `yaml:"adapter" json:"adapter"` + Endpoint string `yaml:"endpoint" json:"endpoint,omitempty"` // chat endpoint override + ImageEndpoint string `yaml:"image_endpoint" json:"image_endpoint,omitempty"` // image endpoint override + Models []Model `yaml:"models" json:"models"` + 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"` Temperature float64 `yaml:"temperature" json:"temperature,omitempty"` MaxTokens int `yaml:"max_tokens" json:"max_tokens,omitempty"` @@ -328,13 +332,15 @@ type RuntimeConfig struct { // name and api_key) so the WebUI can spin up multiple key-bearing sources // from one shared template. type SourceTemplate struct { - Name string `json:"name"` - BaseURL string `json:"base_url"` - Adapter string `json:"adapter"` - Endpoint string `json:"endpoint,omitempty"` - ImageEndpoint string `json:"image_endpoint,omitempty"` - Models []Model `json:"models"` - Headers map[string]string `json:"headers,omitempty"` + Name string `json:"name"` + BaseURL string `json:"base_url"` + Adapter string `json:"adapter"` + Endpoint string `json:"endpoint,omitempty"` + ImageEndpoint string `json:"image_endpoint,omitempty"` + Models []Model `json:"models"` + 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"` Temperature float64 `json:"temperature,omitempty"` MaxTokens int `json:"max_tokens,omitempty"` diff --git a/internal/lua/adapters/trae.lua b/internal/lua/adapters/trae.lua index d05b45a..45949cc 100644 --- a/internal/lua/adapters/trae.lua +++ b/internal/lua/adapters/trae.lua @@ -46,9 +46,6 @@ end -- Returns (tool_calls_array_or_nil, content_with_blocks_removed). local function parse_text_tool_calls(content) if type(content) ~= "string" or content == "" then return nil, content end - if not (content:find("]*>%s*(.-)%s*") collect("]*>%s*(.-)%s*") + -- Legacy / mimicked form: models that saw past assistant turns folded as + -- [Called tool: name({...})] + -- tend to emit the same shape instead of a real block. Recover it so + -- the agent loop keeps working; otherwise the client receives plain text + -- where a structured tool call should be. Capture the name and the JSON + -- object literal independently (the JSON is the only {...} run here). + if #tcs == 0 and content:find("%[Called tool") then + for name, argsraw in content:gmatch("%[Called tool%s*:%s*([%w_%-%.]+)%s*%((%b{})%s*%)%s*%]") do + local aok, decoded = pcall(json.decode, argsraw) + idx = idx + 1 + table.insert(tcs, { + id = "call_legacy_" .. idx, + type = "function", + name = name, + arguments = aok and decoded or {} + }) + end + end + if #tcs == 0 then return nil, content end -- drop the blocks from user-visible content; keep any surrounding prose local stripped = content:gsub("]*>%s*.-%s*", "") stripped = stripped:gsub("]*>%s*.-%s*", "") + stripped = stripped:gsub("%[Called tool%s*:%s*[%w_%-%.]+%s*%(%b{}%s*%)%s*%]", "") stripped = stripped:gsub("^%s+", ""):gsub("%s+$", "") return tcs, stripped end diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 46c48b3..c942199 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -13,6 +13,7 @@ import ( "log" "net" "net/http" + "net/url" "strconv" "strings" "sync" @@ -24,6 +25,20 @@ import ( "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 ---- // 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 // 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. + // 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{ - Proxy: http.ProxyFromEnvironment, + Proxy: proxyFor(cfg), DialContext: (&net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}).DialContext, ForceAttemptHTTP2: true, MaxIdleConns: 100,