mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-21 09:28:00 +00:00
Merge feature/source-proxy: 让被 CF/格式问题挡住的源真正可用
两件事都在回答同一个问题——为什么配了源却用不了: - trae 源在流式下把模型打印的 tool_call 当纯文本透传,客户端收到 finish_reason "stop" + 一段文本,agent 循环当场终止 - justwoker/tabitoken 坐在 Cloudflare 后面,直连 403,必须走代理 + 浏览器 UA;但全局 env 代理会把 trae/localzen 等内网源也绕出去 因此引入 per-source proxy_url:指定的源走专属代理,未指定的保持直连。
This commit is contained in:
@ -47,15 +47,19 @@ type Model struct {
|
|||||||
|
|
||||||
// Source describes a single upstream LLM provider.
|
// Source describes a single upstream LLM provider.
|
||||||
type Source struct {
|
type Source struct {
|
||||||
Name string `yaml:"name" json:"name"`
|
Name string `yaml:"name" json:"name"`
|
||||||
BaseURL string `yaml:"base_url" json:"base_url"`
|
BaseURL string `yaml:"base_url" json:"base_url"`
|
||||||
APIKey string `yaml:"api_key" json:"api_key"`
|
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)
|
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"`
|
Adapter string `yaml:"adapter" json:"adapter"`
|
||||||
Endpoint string `yaml:"endpoint" json:"endpoint,omitempty"` // chat endpoint override
|
Endpoint string `yaml:"endpoint" json:"endpoint,omitempty"` // chat endpoint override
|
||||||
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"`
|
||||||
@ -328,13 +332,15 @@ type RuntimeConfig struct {
|
|||||||
// name and api_key) so the WebUI can spin up multiple key-bearing sources
|
// name and api_key) so the WebUI can spin up multiple key-bearing sources
|
||||||
// from one shared template.
|
// from one shared template.
|
||||||
type SourceTemplate struct {
|
type SourceTemplate struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
BaseURL string `json:"base_url"`
|
BaseURL string `json:"base_url"`
|
||||||
Adapter string `json:"adapter"`
|
Adapter string `json:"adapter"`
|
||||||
Endpoint string `json:"endpoint,omitempty"`
|
Endpoint string `json:"endpoint,omitempty"`
|
||||||
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"`
|
||||||
|
|||||||
@ -46,9 +46,6 @@ end
|
|||||||
-- Returns (tool_calls_array_or_nil, content_with_blocks_removed).
|
-- Returns (tool_calls_array_or_nil, content_with_blocks_removed).
|
||||||
local function parse_text_tool_calls(content)
|
local function parse_text_tool_calls(content)
|
||||||
if type(content) ~= "string" or content == "" then return nil, content end
|
if type(content) ~= "string" or content == "" then return nil, content end
|
||||||
if not (content:find("<tool_call", 1, true) or content:find("<toolcall", 1, true)) then
|
|
||||||
return nil, content
|
|
||||||
end
|
|
||||||
|
|
||||||
local tcs = {}
|
local tcs = {}
|
||||||
local idx = 0
|
local idx = 0
|
||||||
@ -88,11 +85,31 @@ local function parse_text_tool_calls(content)
|
|||||||
collect("<tool_call[^>]*>%s*(.-)%s*</tool_call%s*>")
|
collect("<tool_call[^>]*>%s*(.-)%s*</tool_call%s*>")
|
||||||
collect("<toolcall[^>]*>%s*(.-)%s*</toolcall%s*>")
|
collect("<toolcall[^>]*>%s*(.-)%s*</toolcall%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
|
if #tcs == 0 then return nil, content end
|
||||||
|
|
||||||
-- drop the blocks from user-visible content; keep any surrounding prose
|
-- drop the blocks from user-visible content; keep any surrounding prose
|
||||||
local stripped = content:gsub("<tool_call[^>]*>%s*.-%s*</tool_call%s*>", "")
|
local stripped = content:gsub("<tool_call[^>]*>%s*.-%s*</tool_call%s*>", "")
|
||||||
stripped = stripped:gsub("<toolcall[^>]*>%s*.-%s*</toolcall%s*>", "")
|
stripped = stripped:gsub("<toolcall[^>]*>%s*.-%s*</toolcall%s*>", "")
|
||||||
|
stripped = stripped:gsub("%[Called tool%s*:%s*[%w_%-%.]+%s*%(%b{}%s*%)%s*%]", "")
|
||||||
stripped = stripped:gsub("^%s+", ""):gsub("%s+$", "")
|
stripped = stripped:gsub("^%s+", ""):gsub("%s+$", "")
|
||||||
return tcs, stripped
|
return tcs, stripped
|
||||||
end
|
end
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user