feat(provider): add cross_session_call_id toggle and docs

This commit is contained in:
lpf
2026-03-03 11:26:53 +08:00
parent bd93c12edc
commit 75abdcdf07
7 changed files with 55 additions and 10 deletions

View File

@@ -239,6 +239,12 @@ clawgo uninstall [--purge] [--remove-bin]
- 严格 JSON 解析(未知字段会报错) - 严格 JSON 解析(未知字段会报错)
- 配置热更新失败自动回滚备份 - 配置热更新失败自动回滚备份
Provider`providers.proxy` / `providers.proxies.<name>`)新增开关:
- `cross_session_call_id`bool默认 `false`
- `true`:沿用 `call_id``function_call_output`
- `false`:不传 `call_id`,改为传递 tool 结果内容(适合跨会话/聚合路由场景)
--- ---
## 稳定性与审计建议 ## 稳定性与审计建议

View File

@@ -239,6 +239,12 @@ clawgo uninstall [--purge] [--remove-bin]
- Strict JSON parsing (unknown fields fail fast) - Strict JSON parsing (unknown fields fail fast)
- Auto rollback on failed hot reload - Auto rollback on failed hot reload
New provider switch (`providers.proxy` / `providers.proxies.<name>`):
- `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 ## Stability / Operations Notes

View File

@@ -132,6 +132,7 @@
"api_key": "YOUR_CLIPROXYAPI_KEY", "api_key": "YOUR_CLIPROXYAPI_KEY",
"api_base": "http://localhost:8080/v1", "api_base": "http://localhost:8080/v1",
"protocol": "chat_completions", "protocol": "chat_completions",
"cross_session_call_id": false,
"models": ["glm-4.7", "gpt-4o-mini"], "models": ["glm-4.7", "gpt-4o-mini"],
"supports_responses_compact": false, "supports_responses_compact": false,
"auth": "bearer", "auth": "bearer",
@@ -142,6 +143,7 @@
"api_key": "YOUR_BACKUP_PROXY_KEY", "api_key": "YOUR_BACKUP_PROXY_KEY",
"api_base": "http://localhost:8081/v1", "api_base": "http://localhost:8081/v1",
"protocol": "responses", "protocol": "responses",
"cross_session_call_id": false,
"models": ["gpt-4o-mini", "deepseek-chat"], "models": ["gpt-4o-mini", "deepseek-chat"],
"supports_responses_compact": true, "supports_responses_compact": true,
"auth": "bearer", "auth": "bearer",

View File

@@ -246,6 +246,7 @@ type ProviderConfig struct {
APIKey string `json:"api_key" env:"CLAWGO_PROVIDERS_{{.Name}}_API_KEY"` APIKey string `json:"api_key" env:"CLAWGO_PROVIDERS_{{.Name}}_API_KEY"`
APIBase string `json:"api_base" env:"CLAWGO_PROVIDERS_{{.Name}}_API_BASE"` APIBase string `json:"api_base" env:"CLAWGO_PROVIDERS_{{.Name}}_API_BASE"`
Protocol string `json:"protocol" env:"CLAWGO_PROVIDERS_{{.Name}}_PROTOCOL"` 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"` Models []string `json:"models" env:"CLAWGO_PROVIDERS_{{.Name}}_MODELS"`
SupportsResponsesCompact bool `json:"supports_responses_compact" env:"CLAWGO_PROVIDERS_{{.Name}}_SUPPORTS_RESPONSES_COMPACT"` SupportsResponsesCompact bool `json:"supports_responses_compact" env:"CLAWGO_PROVIDERS_{{.Name}}_SUPPORTS_RESPONSES_COMPACT"`
Auth string `json:"auth" env:"CLAWGO_PROVIDERS_{{.Name}}_AUTH"` Auth string `json:"auth" env:"CLAWGO_PROVIDERS_{{.Name}}_AUTH"`

View File

@@ -26,19 +26,21 @@ type HTTPProvider struct {
apiBase string apiBase string
protocol string protocol string
defaultModel string defaultModel string
crossSessionCallID bool
supportsResponsesCompact bool supportsResponsesCompact bool
authMode string authMode string
timeout time.Duration timeout time.Duration
httpClient *http.Client 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) normalizedBase := normalizeAPIBase(apiBase)
return &HTTPProvider{ return &HTTPProvider{
apiKey: apiKey, apiKey: apiKey,
apiBase: normalizedBase, apiBase: normalizedBase,
protocol: normalizeProtocol(protocol), protocol: normalizeProtocol(protocol),
defaultModel: strings.TrimSpace(defaultModel), defaultModel: strings.TrimSpace(defaultModel),
crossSessionCallID: crossSessionCallID,
supportsResponsesCompact: supportsResponsesCompact, supportsResponsesCompact: supportsResponsesCompact,
authMode: authMode, authMode: authMode,
timeout: timeout, timeout: timeout,
@@ -143,7 +145,7 @@ func (p *HTTPProvider) callResponses(ctx context.Context, messages []Message, to
input := make([]map[string]interface{}, 0, len(messages)) input := make([]map[string]interface{}, 0, len(messages))
pendingCalls := map[string]struct{}{} pendingCalls := map[string]struct{}{}
for _, msg := range messages { for _, msg := range messages {
input = append(input, toResponsesInputItemsWithState(msg, pendingCalls)...) input = append(input, toResponsesInputItemsWithState(msg, pendingCalls, p.crossSessionCallID)...)
} }
requestBody := map[string]interface{}{ requestBody := map[string]interface{}{
"model": model, "model": model,
@@ -243,10 +245,10 @@ func toChatCompletionsContent(msg Message) []map[string]interface{} {
} }
func toResponsesInputItems(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)) role := strings.ToLower(strings.TrimSpace(msg.Role))
switch role { switch role {
case "system", "developer", "user": case "system", "developer", "user":
@@ -302,6 +304,12 @@ func toResponsesInputItemsWithState(msg Message, pendingCalls map[string]struct{
} }
return items return items
case "tool": case "tool":
if !crossSessionCallID {
if strings.TrimSpace(msg.Content) == "" {
return nil
}
return []map[string]interface{}{responsesMessageItem("user", msg.Content, "input_text")}
}
callID := msg.ToolCallID callID := msg.ToolCallID
if callID == "" { if callID == "" {
return nil return nil
@@ -432,7 +440,7 @@ func (p *HTTPProvider) callResponsesStream(ctx context.Context, messages []Messa
input := make([]map[string]interface{}, 0, len(messages)) input := make([]map[string]interface{}, 0, len(messages))
pendingCalls := map[string]struct{}{} pendingCalls := map[string]struct{}{}
for _, msg := range messages { for _, msg := range messages {
input = append(input, toResponsesInputItemsWithState(msg, pendingCalls)...) input = append(input, toResponsesInputItemsWithState(msg, pendingCalls, p.crossSessionCallID)...)
} }
requestBody := map[string]interface{}{ requestBody := map[string]interface{}{
"model": model, "model": model,
@@ -915,7 +923,7 @@ func (p *HTTPProvider) BuildSummaryViaResponsesCompact(ctx context.Context, mode
} }
pendingCalls := map[string]struct{}{} pendingCalls := map[string]struct{}{}
for _, msg := range messages { for _, msg := range messages {
input = append(input, toResponsesInputItemsWithState(msg, pendingCalls)...) input = append(input, toResponsesInputItemsWithState(msg, pendingCalls, p.crossSessionCallID)...)
} }
if len(input) == 0 { if len(input) == 0 {
return strings.TrimSpace(existingSummary), nil return strings.TrimSpace(existingSummary), nil
@@ -1022,7 +1030,7 @@ func CreateProviderByName(cfg *config.Config, name string) (LLMProvider, error)
if len(pc.Models) > 0 { if len(pc.Models) > 0 {
defaultModel = 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) { func CreateProviders(cfg *config.Config) (map[string]LLMProvider, error) {

View File

@@ -6,7 +6,7 @@ func TestToResponsesInputItemsWithState_DropsOrphanToolOutputs(t *testing.T) {
pending := map[string]struct{}{} pending := map[string]struct{}{}
orphan := Message{Role: "tool", ToolCallID: "call-orphan", Content: "orphan output"} 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) 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 { if len(items) == 0 {
t.Fatalf("assistant tool call should produce responses items") 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"} matched := Message{Role: "tool", ToolCallID: "call-1", Content: "file content"}
matchedItems := toResponsesInputItemsWithState(matched, pending) matchedItems := toResponsesInputItemsWithState(matched, pending, true)
if len(matchedItems) != 1 { if len(matchedItems) != 1 {
t.Fatalf("expected matched tool output item, got %#v", matchedItems) 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") 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")
}
}

View File

@@ -113,6 +113,7 @@ const Config: React.FC = () => {
api_key: '', api_key: '',
api_base: '', api_base: '',
protocol: 'responses', protocol: 'responses',
cross_session_call_id: false,
models: [], models: [],
supports_responses_compact: false, supports_responses_compact: false,
auth: 'bearer', auth: 'bearer',