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,