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 解析(未知字段会报错)
- 配置热更新失败自动回滚备份
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)
- 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

View File

@@ -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",

View File

@@ -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"`

View File

@@ -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) {

View File

@@ -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")
}
}

View File

@@ -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',