mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-05-04 04:27:29 +08:00
fix bug
This commit is contained in:
@@ -1596,7 +1596,7 @@ func (al *AgentLoop) callLLMWithModelFallback(
|
||||
}
|
||||
|
||||
if idx < len(candidates)-1 {
|
||||
logger.WarnCF("agent", "Model request failed, trying fallback model", map[string]interface{}{
|
||||
logger.DebugCF("agent", "Model request failed, trying fallback model", map[string]interface{}{
|
||||
"failed_model": model,
|
||||
"next_model": candidates[idx+1],
|
||||
logger.FieldError: err.Error(),
|
||||
@@ -1693,9 +1693,12 @@ func isGatewayTransientError(err error) bool {
|
||||
"status 502",
|
||||
"status 503",
|
||||
"status 504",
|
||||
"status 524",
|
||||
"bad gateway",
|
||||
"service unavailable",
|
||||
"gateway timeout",
|
||||
"a timeout occurred",
|
||||
"error code: 524",
|
||||
"non-json response",
|
||||
"unexpected end of json input",
|
||||
"invalid character '<'",
|
||||
|
||||
@@ -91,6 +91,35 @@ func TestCallLLMWithModelFallback_RetriesOnGateway502(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallLLMWithModelFallback_RetriesOnGateway524(t *testing.T) {
|
||||
p := &fallbackTestProvider{
|
||||
byModel: map[string]fallbackResult{
|
||||
"gemini-3-flash": {err: fmt.Errorf("API error (status 524, content-type \"text/plain; charset=UTF-8\"): error code: 524")},
|
||||
"gpt-4o-mini": {resp: &providers.LLMResponse{Content: "ok"}},
|
||||
},
|
||||
}
|
||||
|
||||
al := &AgentLoop{
|
||||
provider: p,
|
||||
model: "gemini-3-flash",
|
||||
modelFallbacks: []string{"gpt-4o-mini"},
|
||||
}
|
||||
|
||||
resp, err := al.callLLMWithModelFallback(context.Background(), nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp == nil || resp.Content != "ok" {
|
||||
t.Fatalf("unexpected response: %+v", resp)
|
||||
}
|
||||
if len(p.called) != 2 {
|
||||
t.Fatalf("expected 2 model attempts, got %d (%v)", len(p.called), p.called)
|
||||
}
|
||||
if p.called[0] != "gemini-3-flash" || p.called[1] != "gpt-4o-mini" {
|
||||
t.Fatalf("unexpected model order: %v", p.called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallLLMWithModelFallback_NoRetryOnNonRetryableError(t *testing.T) {
|
||||
p := &fallbackTestProvider{
|
||||
byModel: map[string]fallbackResult{
|
||||
@@ -126,3 +155,10 @@ func TestShouldRetryWithFallbackModel_HTMLUnmarshalError(t *testing.T) {
|
||||
t.Fatalf("expected HTML parse error to trigger fallback retry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldRetryWithFallbackModel_Gateway524Error(t *testing.T) {
|
||||
err := fmt.Errorf("API error (status 524, content-type \"text/plain; charset=UTF-8\"): error code: 524")
|
||||
if !shouldRetryWithFallbackModel(err) {
|
||||
t.Fatalf("expected 524 gateway timeout to trigger fallback retry")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -193,6 +194,16 @@ func (p *HTTPProvider) parseResponse(body []byte) (*LLMResponse, error) {
|
||||
content = *choice.Message.Content
|
||||
}
|
||||
|
||||
// Compatibility fallback: some models emit tool calls as XML-like text blocks
|
||||
// instead of native `tool_calls` JSON.
|
||||
if len(toolCalls) == 0 {
|
||||
compatCalls, cleanedContent := parseCompatFunctionCalls(content)
|
||||
if len(compatCalls) > 0 {
|
||||
toolCalls = compatCalls
|
||||
content = cleanedContent
|
||||
}
|
||||
}
|
||||
|
||||
return &LLMResponse{
|
||||
Content: content,
|
||||
ToolCalls: toolCalls,
|
||||
@@ -215,6 +226,77 @@ func previewResponseBody(body []byte) string {
|
||||
return preview
|
||||
}
|
||||
|
||||
func parseCompatFunctionCalls(content string) ([]ToolCall, string) {
|
||||
if strings.TrimSpace(content) == "" || !strings.Contains(content, "<function_call>") {
|
||||
return nil, content
|
||||
}
|
||||
|
||||
blockRe := regexp.MustCompile(`(?is)<function_call>\s*(.*?)\s*</function_call>`)
|
||||
blocks := blockRe.FindAllStringSubmatch(content, -1)
|
||||
if len(blocks) == 0 {
|
||||
return nil, content
|
||||
}
|
||||
|
||||
toolCalls := make([]ToolCall, 0, len(blocks))
|
||||
for i, block := range blocks {
|
||||
raw := block[1]
|
||||
invoke := extractTag(raw, "invoke")
|
||||
if invoke != "" {
|
||||
raw = invoke
|
||||
}
|
||||
|
||||
name := extractTag(raw, "toolname")
|
||||
if strings.TrimSpace(name) == "" {
|
||||
name = extractTag(raw, "tool_name")
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
args := map[string]interface{}{}
|
||||
paramsRaw := strings.TrimSpace(extractTag(raw, "parameters"))
|
||||
if paramsRaw != "" {
|
||||
if strings.HasPrefix(paramsRaw, "{") && strings.HasSuffix(paramsRaw, "}") {
|
||||
_ = json.Unmarshal([]byte(paramsRaw), &args)
|
||||
}
|
||||
if len(args) == 0 {
|
||||
paramTagRe := regexp.MustCompile(`(?is)<([a-zA-Z0-9_:-]+)>\s*(.*?)\s*</([a-zA-Z0-9_:-]+)>`)
|
||||
matches := paramTagRe.FindAllStringSubmatch(paramsRaw, -1)
|
||||
for _, m := range matches {
|
||||
if len(m) < 4 || !strings.EqualFold(strings.TrimSpace(m[1]), strings.TrimSpace(m[3])) {
|
||||
continue
|
||||
}
|
||||
k := strings.TrimSpace(m[1])
|
||||
v := strings.TrimSpace(m[2])
|
||||
if k == "" || v == "" {
|
||||
continue
|
||||
}
|
||||
args[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toolCalls = append(toolCalls, ToolCall{
|
||||
ID: fmt.Sprintf("compat_call_%d", i+1),
|
||||
Name: name,
|
||||
Arguments: args,
|
||||
})
|
||||
}
|
||||
|
||||
cleaned := strings.TrimSpace(blockRe.ReplaceAllString(content, ""))
|
||||
return toolCalls, cleaned
|
||||
}
|
||||
|
||||
func extractTag(src string, tag string) string {
|
||||
re := regexp.MustCompile(fmt.Sprintf(`(?is)<%s>\s*(.*?)\s*</%s>`, regexp.QuoteMeta(tag), regexp.QuoteMeta(tag)))
|
||||
m := re.FindStringSubmatch(src)
|
||||
if len(m) < 2 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(m[1])
|
||||
}
|
||||
|
||||
func (p *HTTPProvider) GetDefaultModel() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
64
pkg/providers/http_provider_test.go
Normal file
64
pkg/providers/http_provider_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseResponse_CompatFunctionCallXML(t *testing.T) {
|
||||
p := &HTTPProvider{}
|
||||
body := []byte(`{
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "I need to check the current state and understand what was last worked on before proceeding.\n\n<function_call><invoke><toolname>exec</toolname><parameters><command>cd /root/clawgo && git status</command></parameters></invoke></function_call>\n\n<function_call><invoke><tool_name>read_file</tool_name><parameters><path>/root/.clawgo/workspace/memory/MEMORY.md</path></parameters></invoke></function_call>"
|
||||
},
|
||||
"finish_reason": "stop"
|
||||
}]
|
||||
}`)
|
||||
|
||||
resp, err := p.parseResponse(body)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp == nil {
|
||||
t.Fatalf("expected response")
|
||||
}
|
||||
if len(resp.ToolCalls) != 2 {
|
||||
t.Fatalf("expected 2 tool calls, got %d", len(resp.ToolCalls))
|
||||
}
|
||||
|
||||
if resp.ToolCalls[0].Name != "exec" {
|
||||
t.Fatalf("expected first tool exec, got %q", resp.ToolCalls[0].Name)
|
||||
}
|
||||
if got, ok := resp.ToolCalls[0].Arguments["command"].(string); !ok || got == "" {
|
||||
t.Fatalf("expected first tool command arg, got %#v", resp.ToolCalls[0].Arguments)
|
||||
}
|
||||
|
||||
if resp.ToolCalls[1].Name != "read_file" {
|
||||
t.Fatalf("expected second tool read_file, got %q", resp.ToolCalls[1].Name)
|
||||
}
|
||||
if got, ok := resp.ToolCalls[1].Arguments["path"].(string); !ok || got == "" {
|
||||
t.Fatalf("expected second tool path arg, got %#v", resp.ToolCalls[1].Arguments)
|
||||
}
|
||||
|
||||
if resp.Content == "" {
|
||||
t.Fatalf("expected non-empty cleaned content")
|
||||
}
|
||||
if containsFunctionCallMarkup(resp.Content) {
|
||||
t.Fatalf("expected function call markup removed from content, got %q", resp.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCompatFunctionCalls_NoMarkup(t *testing.T) {
|
||||
calls, cleaned := parseCompatFunctionCalls("hello")
|
||||
if len(calls) != 0 {
|
||||
t.Fatalf("expected 0 calls, got %d", len(calls))
|
||||
}
|
||||
if cleaned != "hello" {
|
||||
t.Fatalf("expected content unchanged, got %q", cleaned)
|
||||
}
|
||||
}
|
||||
|
||||
func containsFunctionCallMarkup(s string) bool {
|
||||
return len(s) > 0 && (strings.Contains(s, "<function_call>") || strings.Contains(s, "</function_call>"))
|
||||
}
|
||||
Reference in New Issue
Block a user