diff --git a/README.md b/README.md index ecabd99..46c3c02 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,12 @@ clawgo uninstall [--purge] [--remove-bin] - 严格 JSON 解析(未知字段会报错) - 配置热更新失败自动回滚备份 +Provider(`providers.proxy` / `providers.proxies.`)新增开关: + +- `cross_session_call_id`(bool,默认 `false`) + - `true`:沿用 `call_id` 传 `function_call_output` + - `false`:不传 `call_id`,改为传递 tool 结果内容(适合跨会话/聚合路由场景) + --- ## 稳定性与审计建议 diff --git a/README_EN.md b/README_EN.md index aab36b3..1fe3e12 100644 --- a/README_EN.md +++ b/README_EN.md @@ -239,6 +239,12 @@ clawgo uninstall [--purge] [--remove-bin] - Strict JSON parsing (unknown fields fail fast) - Auto rollback on failed hot reload +New provider switch (`providers.proxy` / `providers.proxies.`): + +- `cross_session_call_id` (bool, default `false`) + - `true`: keep using `call_id` with `function_call_output` + - `false`: do not send `call_id`; pass tool results as plain content (recommended for cross-session/aggregator routing) + --- ## Stability / Operations Notes diff --git a/config.example.json b/config.example.json index 8610129..c4c6fc0 100644 --- a/config.example.json +++ b/config.example.json @@ -132,6 +132,7 @@ "api_key": "YOUR_CLIPROXYAPI_KEY", "api_base": "http://localhost:8080/v1", "protocol": "chat_completions", + "cross_session_call_id": false, "models": ["glm-4.7", "gpt-4o-mini"], "supports_responses_compact": false, "auth": "bearer", @@ -142,6 +143,7 @@ "api_key": "YOUR_BACKUP_PROXY_KEY", "api_base": "http://localhost:8081/v1", "protocol": "responses", + "cross_session_call_id": false, "models": ["gpt-4o-mini", "deepseek-chat"], "supports_responses_compact": true, "auth": "bearer", diff --git a/pkg/config/config.go b/pkg/config/config.go index 0348803..b4ceaf6 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -246,6 +246,7 @@ type ProviderConfig struct { APIKey string `json:"api_key" env:"CLAWGO_PROVIDERS_{{.Name}}_API_KEY"` APIBase string `json:"api_base" env:"CLAWGO_PROVIDERS_{{.Name}}_API_BASE"` Protocol string `json:"protocol" env:"CLAWGO_PROVIDERS_{{.Name}}_PROTOCOL"` + CrossSessionCallID bool `json:"cross_session_call_id" env:"CLAWGO_PROVIDERS_{{.Name}}_CROSS_SESSION_CALL_ID"` Models []string `json:"models" env:"CLAWGO_PROVIDERS_{{.Name}}_MODELS"` SupportsResponsesCompact bool `json:"supports_responses_compact" env:"CLAWGO_PROVIDERS_{{.Name}}_SUPPORTS_RESPONSES_COMPACT"` Auth string `json:"auth" env:"CLAWGO_PROVIDERS_{{.Name}}_AUTH"` diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go index 98a6971..79c0ec8 100644 --- a/pkg/providers/http_provider.go +++ b/pkg/providers/http_provider.go @@ -26,19 +26,21 @@ type HTTPProvider struct { apiBase string protocol string defaultModel string + crossSessionCallID bool supportsResponsesCompact bool authMode string timeout time.Duration httpClient *http.Client } -func NewHTTPProvider(apiKey, apiBase, protocol, defaultModel string, supportsResponsesCompact bool, authMode string, timeout time.Duration) *HTTPProvider { +func NewHTTPProvider(apiKey, apiBase, protocol, defaultModel string, crossSessionCallID bool, supportsResponsesCompact bool, authMode string, timeout time.Duration) *HTTPProvider { normalizedBase := normalizeAPIBase(apiBase) return &HTTPProvider{ apiKey: apiKey, apiBase: normalizedBase, protocol: normalizeProtocol(protocol), defaultModel: strings.TrimSpace(defaultModel), + crossSessionCallID: crossSessionCallID, supportsResponsesCompact: supportsResponsesCompact, authMode: authMode, timeout: timeout, @@ -143,7 +145,7 @@ func (p *HTTPProvider) callResponses(ctx context.Context, messages []Message, to input := make([]map[string]interface{}, 0, len(messages)) pendingCalls := map[string]struct{}{} for _, msg := range messages { - input = append(input, toResponsesInputItemsWithState(msg, pendingCalls)...) + input = append(input, toResponsesInputItemsWithState(msg, pendingCalls, p.crossSessionCallID)...) } requestBody := map[string]interface{}{ "model": model, @@ -243,10 +245,10 @@ func toChatCompletionsContent(msg Message) []map[string]interface{} { } func toResponsesInputItems(msg Message) []map[string]interface{} { - return toResponsesInputItemsWithState(msg, nil) + return toResponsesInputItemsWithState(msg, nil, true) } -func toResponsesInputItemsWithState(msg Message, pendingCalls map[string]struct{}) []map[string]interface{} { +func toResponsesInputItemsWithState(msg Message, pendingCalls map[string]struct{}, crossSessionCallID bool) []map[string]interface{} { role := strings.ToLower(strings.TrimSpace(msg.Role)) switch role { case "system", "developer", "user": @@ -302,6 +304,12 @@ func toResponsesInputItemsWithState(msg Message, pendingCalls map[string]struct{ } return items case "tool": + if !crossSessionCallID { + if strings.TrimSpace(msg.Content) == "" { + return nil + } + return []map[string]interface{}{responsesMessageItem("user", msg.Content, "input_text")} + } callID := msg.ToolCallID if callID == "" { return nil @@ -432,7 +440,7 @@ func (p *HTTPProvider) callResponsesStream(ctx context.Context, messages []Messa input := make([]map[string]interface{}, 0, len(messages)) pendingCalls := map[string]struct{}{} for _, msg := range messages { - input = append(input, toResponsesInputItemsWithState(msg, pendingCalls)...) + input = append(input, toResponsesInputItemsWithState(msg, pendingCalls, p.crossSessionCallID)...) } requestBody := map[string]interface{}{ "model": model, @@ -915,7 +923,7 @@ func (p *HTTPProvider) BuildSummaryViaResponsesCompact(ctx context.Context, mode } pendingCalls := map[string]struct{}{} for _, msg := range messages { - input = append(input, toResponsesInputItemsWithState(msg, pendingCalls)...) + input = append(input, toResponsesInputItemsWithState(msg, pendingCalls, p.crossSessionCallID)...) } if len(input) == 0 { return strings.TrimSpace(existingSummary), nil @@ -1022,7 +1030,7 @@ func CreateProviderByName(cfg *config.Config, name string) (LLMProvider, error) if len(pc.Models) > 0 { defaultModel = pc.Models[0] } - return NewHTTPProvider(pc.APIKey, pc.APIBase, pc.Protocol, defaultModel, pc.SupportsResponsesCompact, pc.Auth, time.Duration(pc.TimeoutSec)*time.Second), nil + return NewHTTPProvider(pc.APIKey, pc.APIBase, pc.Protocol, defaultModel, pc.CrossSessionCallID, pc.SupportsResponsesCompact, pc.Auth, time.Duration(pc.TimeoutSec)*time.Second), nil } func CreateProviders(cfg *config.Config) (map[string]LLMProvider, error) { diff --git a/pkg/providers/http_provider_toolcall_pairing_test.go b/pkg/providers/http_provider_toolcall_pairing_test.go index 299e6c7..52515eb 100644 --- a/pkg/providers/http_provider_toolcall_pairing_test.go +++ b/pkg/providers/http_provider_toolcall_pairing_test.go @@ -6,7 +6,7 @@ func TestToResponsesInputItemsWithState_DropsOrphanToolOutputs(t *testing.T) { pending := map[string]struct{}{} orphan := Message{Role: "tool", ToolCallID: "call-orphan", Content: "orphan output"} - if got := toResponsesInputItemsWithState(orphan, pending); len(got) != 0 { + if got := toResponsesInputItemsWithState(orphan, pending, true); len(got) != 0 { t.Fatalf("expected orphan tool output to be dropped, got: %#v", got) } @@ -20,7 +20,7 @@ func TestToResponsesInputItemsWithState_DropsOrphanToolOutputs(t *testing.T) { }, }}, } - items := toResponsesInputItemsWithState(assistant, pending) + items := toResponsesInputItemsWithState(assistant, pending, true) if len(items) == 0 { t.Fatalf("assistant tool call should produce responses items") } @@ -29,7 +29,7 @@ func TestToResponsesInputItemsWithState_DropsOrphanToolOutputs(t *testing.T) { } matched := Message{Role: "tool", ToolCallID: "call-1", Content: "file content"} - matchedItems := toResponsesInputItemsWithState(matched, pending) + matchedItems := toResponsesInputItemsWithState(matched, pending, true) if len(matchedItems) != 1 { t.Fatalf("expected matched tool output item, got %#v", matchedItems) } @@ -40,3 +40,24 @@ func TestToResponsesInputItemsWithState_DropsOrphanToolOutputs(t *testing.T) { t.Fatalf("matched tool output should clear pending call id") } } + +func TestToResponsesInputItemsWithState_ToolResultAsUserInputWhenCallIDDisabled(t *testing.T) { + pending := map[string]struct{}{ + "call-1": {}, + } + msg := Message{Role: "tool", ToolCallID: "call-1", Content: "file content"} + + items := toResponsesInputItemsWithState(msg, pending, false) + if len(items) != 1 { + t.Fatalf("expected one fallback tool result item, got %#v", items) + } + if items[0]["type"] != "message" { + t.Fatalf("expected message item, got %#v", items[0]) + } + if items[0]["role"] != "user" { + t.Fatalf("expected fallback role=user, got %#v", items[0]) + } + if _, ok := pending["call-1"]; !ok { + t.Fatalf("pending call state should remain untouched when call_id mode is disabled") + } +} diff --git a/webui/src/pages/Config.tsx b/webui/src/pages/Config.tsx index 8159d65..cdf161b 100644 --- a/webui/src/pages/Config.tsx +++ b/webui/src/pages/Config.tsx @@ -113,6 +113,7 @@ const Config: React.FC = () => { api_key: '', api_base: '', protocol: 'responses', + cross_session_call_id: false, models: [], supports_responses_compact: false, auth: 'bearer',