mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-05-11 22:28:58 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a55fb6aa66 | ||
|
|
45c3234316 | ||
|
|
579c4a92d9 | ||
|
|
b8cf8ad1b1 |
@@ -15,7 +15,7 @@ import (
|
||||
"github.com/YspCoder/clawgo/pkg/logger"
|
||||
)
|
||||
|
||||
var version = "1.2.3"
|
||||
var version = "1.2.10"
|
||||
var buildTime = "unknown"
|
||||
|
||||
const logo = ">"
|
||||
|
||||
@@ -944,8 +944,9 @@ func estimateResponseUsage(ctx context.Context, provider providers.LLMProvider,
|
||||
|
||||
func buildAssistantToolCallMessage(response *providers.LLMResponse) providers.Message {
|
||||
assistantMsg := providers.Message{
|
||||
Role: "assistant",
|
||||
Content: response.Content,
|
||||
Role: "assistant",
|
||||
Content: response.Content,
|
||||
ReasoningContent: response.ReasoningContent,
|
||||
}
|
||||
if response == nil {
|
||||
return assistantMsg
|
||||
|
||||
@@ -476,6 +476,7 @@ func (p *CodexProvider) doStreamAttempt(req *http.Request, attempt authAttempt,
|
||||
var dataLines []string
|
||||
var finalJSON []byte
|
||||
completed := false
|
||||
streamState := newCodexStreamState()
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.TrimSpace(line) == "" {
|
||||
@@ -492,10 +493,11 @@ func (p *CodexProvider) doStreamAttempt(req *http.Request, attempt authAttempt,
|
||||
}
|
||||
var obj map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(payload), &obj); err == nil {
|
||||
streamState.applyEvent(obj)
|
||||
if typ := strings.TrimSpace(fmt.Sprintf("%v", obj["type"])); typ == "response.completed" {
|
||||
completed = true
|
||||
if respObj, ok := obj["response"]; ok {
|
||||
finalJSON = mergeStreamFinalJSON(finalJSON, respObj)
|
||||
finalJSON = mergeStreamFinalJSON(finalJSON, streamState.finalizeResponse(respObj))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -624,6 +626,7 @@ func (p *CodexProvider) doWebsocketAttempt(ctx context.Context, endpoint string,
|
||||
return nil, 0, "", err
|
||||
}
|
||||
}
|
||||
streamState := newCodexStreamState()
|
||||
for {
|
||||
msgType, msg, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
@@ -646,7 +649,9 @@ func (p *CodexProvider) doWebsocketAttempt(ctx context.Context, endpoint string,
|
||||
if err := json.Unmarshal(msg, &event); err != nil {
|
||||
continue
|
||||
}
|
||||
switch strings.TrimSpace(fmt.Sprintf("%v", event["type"])) {
|
||||
typ := strings.TrimSpace(fmt.Sprintf("%v", event["type"]))
|
||||
streamState.applyEvent(event)
|
||||
switch typ {
|
||||
case "response.output_text.delta":
|
||||
if d := strings.TrimSpace(fmt.Sprintf("%v", event["delta"])); d != "" {
|
||||
if onDelta != nil {
|
||||
@@ -655,7 +660,11 @@ func (p *CodexProvider) doWebsocketAttempt(ctx context.Context, endpoint string,
|
||||
}
|
||||
case "response.completed":
|
||||
if respObj, ok := event["response"]; ok {
|
||||
b, _ := json.Marshal(respObj)
|
||||
b, _ := json.Marshal(streamState.finalizeResponse(respObj))
|
||||
return b, http.StatusOK, "application/json", nil
|
||||
}
|
||||
b, _ := json.Marshal(streamState.finalizeResponse(nil))
|
||||
if len(b) != 0 && string(b) != "null" {
|
||||
return b, http.StatusOK, "application/json", nil
|
||||
}
|
||||
return msg, http.StatusOK, "application/json", nil
|
||||
@@ -663,6 +672,173 @@ func (p *CodexProvider) doWebsocketAttempt(ctx context.Context, endpoint string,
|
||||
}
|
||||
}
|
||||
|
||||
type codexStreamState struct {
|
||||
outputByIndex map[int]map[string]interface{}
|
||||
itemIndexByID map[string]int
|
||||
}
|
||||
|
||||
func newCodexStreamState() *codexStreamState {
|
||||
return &codexStreamState{
|
||||
outputByIndex: map[int]map[string]interface{}{},
|
||||
itemIndexByID: map[string]int{},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *codexStreamState) applyEvent(event map[string]interface{}) {
|
||||
if s == nil || len(event) == 0 {
|
||||
return
|
||||
}
|
||||
typ := strings.TrimSpace(asString(event["type"]))
|
||||
switch typ {
|
||||
case "response.output_item.added", "response.output_item.done":
|
||||
s.mergeOutputItem(intValue(event["output_index"]), mapFromAny(event["item"]))
|
||||
case "response.function_call_arguments.delta":
|
||||
item := s.ensureItem(intValue(event["output_index"]), strings.TrimSpace(asString(event["item_id"])))
|
||||
if item == nil {
|
||||
return
|
||||
}
|
||||
if name := strings.TrimSpace(asString(event["name"])); name != "" {
|
||||
item["name"] = name
|
||||
}
|
||||
item["type"] = firstNonEmpty(strings.TrimSpace(asString(item["type"])), "function_call")
|
||||
if callID := strings.TrimSpace(asString(event["call_id"])); callID != "" {
|
||||
item["call_id"] = callID
|
||||
}
|
||||
item["arguments"] = strings.TrimSpace(asString(item["arguments"])) + asString(event["delta"])
|
||||
case "response.function_call_arguments.done":
|
||||
item := s.ensureItem(intValue(event["output_index"]), strings.TrimSpace(asString(event["item_id"])))
|
||||
if item == nil {
|
||||
return
|
||||
}
|
||||
item["type"] = firstNonEmpty(strings.TrimSpace(asString(item["type"])), "function_call")
|
||||
if name := strings.TrimSpace(asString(event["name"])); name != "" {
|
||||
item["name"] = name
|
||||
}
|
||||
if callID := strings.TrimSpace(asString(event["call_id"])); callID != "" {
|
||||
item["call_id"] = callID
|
||||
}
|
||||
if args := asString(event["arguments"]); strings.TrimSpace(args) != "" {
|
||||
item["arguments"] = args
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *codexStreamState) finalizeResponse(respObj interface{}) interface{} {
|
||||
resp := mapFromAny(respObj)
|
||||
if len(resp) == 0 {
|
||||
if len(s.outputByIndex) == 0 {
|
||||
return respObj
|
||||
}
|
||||
resp = map[string]interface{}{}
|
||||
}
|
||||
merged := s.mergedOutput(resp["output"])
|
||||
if len(merged) > 0 {
|
||||
resp["output"] = merged
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func (s *codexStreamState) mergedOutput(existing interface{}) []map[string]interface{} {
|
||||
maxIndex := -1
|
||||
for idx := range s.outputByIndex {
|
||||
if idx > maxIndex {
|
||||
maxIndex = idx
|
||||
}
|
||||
}
|
||||
var out []map[string]interface{}
|
||||
switch raw := existing.(type) {
|
||||
case []map[string]interface{}:
|
||||
out = make([]map[string]interface{}, len(raw))
|
||||
for i, item := range raw {
|
||||
out[i] = cloneCodexMap(item)
|
||||
}
|
||||
case []interface{}:
|
||||
out = make([]map[string]interface{}, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
out = append(out, cloneCodexMap(mapFromAny(item)))
|
||||
}
|
||||
if len(out)-1 > maxIndex {
|
||||
maxIndex = len(out) - 1
|
||||
}
|
||||
default:
|
||||
if maxIndex >= 0 {
|
||||
out = make([]map[string]interface{}, maxIndex+1)
|
||||
}
|
||||
}
|
||||
if len(out)-1 < maxIndex {
|
||||
grown := make([]map[string]interface{}, maxIndex+1)
|
||||
copy(grown, out)
|
||||
out = grown
|
||||
}
|
||||
for idx, item := range s.outputByIndex {
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
if out[idx] == nil {
|
||||
out[idx] = cloneCodexMap(item)
|
||||
continue
|
||||
}
|
||||
for k, v := range item {
|
||||
if k == "content" {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(asString(out[idx][k])) == "" {
|
||||
out[idx][k] = v
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(asString(out[idx]["arguments"])) == "" && strings.TrimSpace(asString(item["arguments"])) != "" {
|
||||
out[idx]["arguments"] = item["arguments"]
|
||||
}
|
||||
}
|
||||
compact := make([]map[string]interface{}, 0, len(out))
|
||||
for _, item := range out {
|
||||
if len(item) == 0 {
|
||||
continue
|
||||
}
|
||||
compact = append(compact, item)
|
||||
}
|
||||
return compact
|
||||
}
|
||||
|
||||
func (s *codexStreamState) mergeOutputItem(outputIndex int, item map[string]interface{}) {
|
||||
if s == nil || outputIndex < 0 || len(item) == 0 {
|
||||
return
|
||||
}
|
||||
target := s.ensureItem(outputIndex, strings.TrimSpace(asString(item["id"])))
|
||||
for k, v := range item {
|
||||
target[k] = v
|
||||
}
|
||||
if id := strings.TrimSpace(asString(target["id"])); id != "" {
|
||||
s.itemIndexByID[id] = outputIndex
|
||||
}
|
||||
}
|
||||
|
||||
func (s *codexStreamState) ensureItem(outputIndex int, itemID string) map[string]interface{} {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
if outputIndex < 0 && itemID != "" {
|
||||
if idx, ok := s.itemIndexByID[itemID]; ok {
|
||||
outputIndex = idx
|
||||
}
|
||||
}
|
||||
if outputIndex < 0 {
|
||||
outputIndex = len(s.outputByIndex)
|
||||
}
|
||||
item := s.outputByIndex[outputIndex]
|
||||
if item == nil {
|
||||
item = map[string]interface{}{}
|
||||
s.outputByIndex[outputIndex] = item
|
||||
}
|
||||
if itemID != "" {
|
||||
if _, ok := item["id"]; !ok {
|
||||
item["id"] = itemID
|
||||
}
|
||||
s.itemIndexByID[itemID] = outputIndex
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func codexExecutionSessionID(options map[string]interface{}) string {
|
||||
if value, ok := stringOption(options, "codex_execution_session"); ok {
|
||||
return strings.TrimSpace(value)
|
||||
|
||||
@@ -235,6 +235,48 @@ func TestCodexProviderChatMergesLateUsageFromStreamingCompletion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexProviderChatCollectsToolCallsFromWebsocketEvents(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = fmt.Fprint(w, "data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"id\":\"item_1\",\"type\":\"function_call\",\"call_id\":\"call_1\",\"name\":\"remind\",\"arguments\":\"\"}}\n\n")
|
||||
_, _ = fmt.Fprint(w, "data: {\"type\":\"response.function_call_arguments.done\",\"output_index\":0,\"item_id\":\"item_1\",\"call_id\":\"call_1\",\"name\":\"remind\",\"arguments\":\"{\\\"message\\\":\\\"开会\\\",\\\"time_expr\\\":\\\"10m\\\"}\"}\n\n")
|
||||
_, _ = fmt.Fprint(w, "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\"}}\n\n")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewCodexProvider("codex", "test-api-key", server.URL, "gpt-5.4", false, "", 5*time.Second, nil)
|
||||
resp, err := provider.Chat(t.Context(), []Message{{Role: "user", Content: "10分钟后通知我开会"}}, []ToolDefinition{{
|
||||
Type: "function",
|
||||
Function: ToolFunctionDefinition{
|
||||
Name: "remind",
|
||||
Description: "Set a reminder",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"message": map[string]interface{}{"type": "string"},
|
||||
"time_expr": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
"required": []string{"message", "time_expr"},
|
||||
},
|
||||
},
|
||||
}}, "gpt-5.4", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat error: %v", err)
|
||||
}
|
||||
if len(resp.ToolCalls) != 1 {
|
||||
t.Fatalf("expected one tool call, got %#v", resp.ToolCalls)
|
||||
}
|
||||
if got := resp.ToolCalls[0].Name; got != "remind" {
|
||||
t.Fatalf("tool name = %q, want remind", got)
|
||||
}
|
||||
if got := asString(resp.ToolCalls[0].Arguments["message"]); got != "开会" {
|
||||
t.Fatalf("message arg = %q, want 开会", got)
|
||||
}
|
||||
if got := asString(resp.ToolCalls[0].Arguments["time_expr"]); got != "10m" {
|
||||
t.Fatalf("time_expr arg = %q, want 10m", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexHandleAttemptFailureMarksAPIKeyCooldown(t *testing.T) {
|
||||
provider := NewCodexProvider("codex-websocket-failure", "test-api-key", "", "gpt-5.4", false, "", 5*time.Second, nil)
|
||||
provider.handleAttemptFailure(authAttempt{kind: "api_key", token: "test-api-key"}, http.StatusTooManyRequests, []byte(`{"error":{"message":"rate limit exceeded"}}`))
|
||||
|
||||
@@ -11,8 +11,9 @@ func parseOpenAICompatResponse(body []byte) (*LLMResponse, error) {
|
||||
var payload struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
ToolCalls []struct {
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content"`
|
||||
ToolCalls []struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Function struct {
|
||||
@@ -37,8 +38,9 @@ func parseOpenAICompatResponse(body []byte) (*LLMResponse, error) {
|
||||
}
|
||||
choice := payload.Choices[0]
|
||||
resp := &LLMResponse{
|
||||
Content: choice.Message.Content,
|
||||
FinishReason: choice.FinishReason,
|
||||
Content: choice.Message.Content,
|
||||
ReasoningContent: choice.Message.ReasoningContent,
|
||||
FinishReason: choice.FinishReason,
|
||||
}
|
||||
if payload.Usage.TotalTokens > 0 || payload.Usage.PromptTokens > 0 || payload.Usage.CompletionTokens > 0 {
|
||||
resp.Usage = &UsageInfo{
|
||||
@@ -50,6 +52,10 @@ func parseOpenAICompatResponse(body []byte) (*LLMResponse, error) {
|
||||
if len(choice.Message.ToolCalls) > 0 {
|
||||
resp.ToolCalls = make([]ToolCall, 0, len(choice.Message.ToolCalls))
|
||||
for _, tc := range choice.Message.ToolCalls {
|
||||
args := map[string]interface{}{}
|
||||
if strings.TrimSpace(tc.Function.Arguments) != "" {
|
||||
_ = json.Unmarshal([]byte(tc.Function.Arguments), &args)
|
||||
}
|
||||
resp.ToolCalls = append(resp.ToolCalls, ToolCall{
|
||||
ID: tc.ID,
|
||||
Type: tc.Type,
|
||||
@@ -57,7 +63,8 @@ func parseOpenAICompatResponse(body []byte) (*LLMResponse, error) {
|
||||
Name: tc.Function.Name,
|
||||
Arguments: tc.Function.Arguments,
|
||||
},
|
||||
Name: tc.Function.Name,
|
||||
Name: tc.Function.Name,
|
||||
Arguments: args,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -170,6 +177,7 @@ func (p *HTTPProvider) buildOpenAICompatChatRequest(messages []Message, tools []
|
||||
if temperature, ok := float64FromOption(options, "temperature"); ok {
|
||||
requestBody["temperature"] = temperature
|
||||
}
|
||||
normalizeOpenAICompatThinkingMessages(requestBody)
|
||||
return requestBody
|
||||
}
|
||||
|
||||
@@ -185,6 +193,9 @@ func openAICompatMessages(messages []Message) []map[string]interface{} {
|
||||
out = append(out, map[string]interface{}{"role": "user", "content": content})
|
||||
case "assistant":
|
||||
item := map[string]interface{}{"role": "assistant", "content": content}
|
||||
if reasoning := strings.TrimSpace(msg.ReasoningContent); reasoning != "" {
|
||||
item["reasoning_content"] = reasoning
|
||||
}
|
||||
if len(msg.ToolCalls) > 0 {
|
||||
toolCalls := make([]map[string]interface{}, 0, len(msg.ToolCalls))
|
||||
for _, tc := range msg.ToolCalls {
|
||||
@@ -225,6 +236,96 @@ func openAICompatMessages(messages []Message) []map[string]interface{} {
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeOpenAICompatThinkingMessages(body map[string]interface{}) {
|
||||
var items []map[string]interface{}
|
||||
switch raw := body["messages"].(type) {
|
||||
case []map[string]interface{}:
|
||||
items = raw
|
||||
case []interface{}:
|
||||
items = make([]map[string]interface{}, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
msg, _ := item.(map[string]interface{})
|
||||
if msg != nil {
|
||||
items = append(items, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return
|
||||
}
|
||||
latestReasoning := ""
|
||||
hasLatestReasoning := false
|
||||
for i := range items {
|
||||
msg := items[i]
|
||||
if !strings.EqualFold(strings.TrimSpace(fmt.Sprintf("%v", msg["role"])), "assistant") {
|
||||
continue
|
||||
}
|
||||
if raw, ok := msg["reasoning_content"]; ok {
|
||||
if reasoning := strings.TrimSpace(fmt.Sprintf("%v", raw)); reasoning != "" && reasoning != "<nil>" {
|
||||
latestReasoning = reasoning
|
||||
hasLatestReasoning = true
|
||||
}
|
||||
}
|
||||
if !assistantMessageHasToolCalls(msg) {
|
||||
continue
|
||||
}
|
||||
existingReasoning := strings.TrimSpace(fmt.Sprintf("%v", msg["reasoning_content"]))
|
||||
if existingReasoning == "" || existingReasoning == "<nil>" {
|
||||
msg["reasoning_content"] = fallbackAssistantReasoningContent(msg, hasLatestReasoning, latestReasoning)
|
||||
if reasoning := strings.TrimSpace(fmt.Sprintf("%v", msg["reasoning_content"])); reasoning != "" && reasoning != "<nil>" {
|
||||
latestReasoning = reasoning
|
||||
hasLatestReasoning = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assistantMessageHasToolCalls(msg map[string]interface{}) bool {
|
||||
switch raw := msg["tool_calls"].(type) {
|
||||
case []interface{}:
|
||||
return len(raw) > 0
|
||||
case []map[string]interface{}:
|
||||
return len(raw) > 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func fallbackAssistantReasoningContent(msg map[string]interface{}, hasLatest bool, latest string) string {
|
||||
if hasLatest && strings.TrimSpace(latest) != "" {
|
||||
return latest
|
||||
}
|
||||
if text := strings.TrimSpace(fmt.Sprintf("%v", msg["content"])); text != "" && text != "<nil>" {
|
||||
return text
|
||||
}
|
||||
switch content := msg["content"].(type) {
|
||||
case []map[string]interface{}:
|
||||
return joinAssistantTextParts(content)
|
||||
case []interface{}:
|
||||
parts := make([]map[string]interface{}, 0, len(content))
|
||||
for _, raw := range content {
|
||||
part, _ := raw.(map[string]interface{})
|
||||
if part != nil {
|
||||
parts = append(parts, part)
|
||||
}
|
||||
}
|
||||
return joinAssistantTextParts(parts)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func joinAssistantTextParts(parts []map[string]interface{}) string {
|
||||
texts := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
text := strings.TrimSpace(fmt.Sprintf("%v", part["text"]))
|
||||
if text != "" && text != "<nil>" {
|
||||
texts = append(texts, text)
|
||||
}
|
||||
}
|
||||
return strings.Join(texts, "\n")
|
||||
}
|
||||
|
||||
func openAICompatMessageContent(msg Message) interface{} {
|
||||
if len(msg.ContentParts) == 0 {
|
||||
return msg.Content
|
||||
@@ -319,17 +420,25 @@ func codexCompatRequestBody(requestBody map[string]interface{}) map[string]inter
|
||||
}
|
||||
|
||||
func parseCompatFunctionCalls(content string) ([]ToolCall, string) {
|
||||
if strings.TrimSpace(content) == "" || !strings.Contains(content, "<function_call>") {
|
||||
if strings.TrimSpace(content) == "" || !containsCompatFunctionCallMarkup(content) {
|
||||
return nil, content
|
||||
}
|
||||
blockRe := regexp.MustCompile(`(?is)<function_call>\s*(.*?)\s*</function_call>`)
|
||||
blocks := blockRe.FindAllStringSubmatch(content, -1)
|
||||
blockRe := regexp.MustCompile(`(?is)<function_call>\s*(.*?)\s*</function_call>|<||DSML||tool_calls>\s*(.*?)\s*</||DSML||tool_calls>`)
|
||||
matches := blockRe.FindAllStringSubmatch(content, -1)
|
||||
blocks := make([]string, 0, len(matches))
|
||||
for _, match := range matches {
|
||||
switch {
|
||||
case len(match) > 1 && strings.TrimSpace(match[1]) != "":
|
||||
blocks = append(blocks, match[1])
|
||||
case len(match) > 2 && strings.TrimSpace(match[2]) != "":
|
||||
blocks = append(blocks, match[2])
|
||||
}
|
||||
}
|
||||
if len(blocks) == 0 {
|
||||
return nil, content
|
||||
}
|
||||
toolCalls := make([]ToolCall, 0, len(blocks))
|
||||
for i, block := range blocks {
|
||||
raw := block[1]
|
||||
for i, raw := range blocks {
|
||||
invoke := extractTag(raw, "invoke")
|
||||
if invoke != "" {
|
||||
raw = invoke
|
||||
@@ -338,6 +447,9 @@ func parseCompatFunctionCalls(content string) ([]ToolCall, string) {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
name = extractTag(raw, "tool_name")
|
||||
}
|
||||
if strings.TrimSpace(name) == "" {
|
||||
name = extractInvokeNameAttr(raw)
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
continue
|
||||
@@ -370,6 +482,14 @@ func parseCompatFunctionCalls(content string) ([]ToolCall, string) {
|
||||
return toolCalls, cleaned
|
||||
}
|
||||
|
||||
func containsCompatFunctionCallMarkup(content string) bool {
|
||||
trimmed := strings.TrimSpace(content)
|
||||
if trimmed == "" {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(trimmed, "<function_call>") || strings.Contains(trimmed, "<||DSML||tool_calls>")
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -378,3 +498,12 @@ func extractTag(src string, tag string) string {
|
||||
}
|
||||
return strings.TrimSpace(m[1])
|
||||
}
|
||||
|
||||
func extractInvokeNameAttr(src string) string {
|
||||
re := regexp.MustCompile(`(?is)<(?:invoke|||DSML||invoke)\b[^>]*\bname\s*=\s*"([^"]+)"[^>]*>`)
|
||||
m := re.FindStringSubmatch(src)
|
||||
if len(m) < 2 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(m[1])
|
||||
}
|
||||
|
||||
@@ -215,3 +215,129 @@ func TestHTTPProviderChatUsesConfiguredChatCompletionsAPI(t *testing.T) {
|
||||
t.Fatalf("usage = %#v, want total_tokens=3", resp.Usage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOpenAICompatResponseCapturesReasoningContent(t *testing.T) {
|
||||
resp, err := parseOpenAICompatResponse([]byte(`{"choices":[{"message":{"content":"answer","reasoning_content":"hidden chain"},"finish_reason":"stop"}]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("parseOpenAICompatResponse error: %v", err)
|
||||
}
|
||||
if resp.ReasoningContent != "hidden chain" {
|
||||
t.Fatalf("ReasoningContent = %q, want hidden chain", resp.ReasoningContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOpenAICompatResponsePopulatesToolArgumentsMap(t *testing.T) {
|
||||
resp, err := parseOpenAICompatResponse([]byte(`{"choices":[{"message":{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"remind","arguments":"{\"message\":\"开会\",\"time_expr\":\"10m\"}"}}]},"finish_reason":"tool_calls"}]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("parseOpenAICompatResponse error: %v", err)
|
||||
}
|
||||
if len(resp.ToolCalls) != 1 {
|
||||
t.Fatalf("tool calls = %#v, want one call", resp.ToolCalls)
|
||||
}
|
||||
if got := asString(resp.ToolCalls[0].Arguments["message"]); got != "开会" {
|
||||
t.Fatalf("message = %q, want 开会", got)
|
||||
}
|
||||
if got := asString(resp.ToolCalls[0].Arguments["time_expr"]); got != "10m" {
|
||||
t.Fatalf("time_expr = %q, want 10m", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAICompatMessagesIncludeReasoningContent(t *testing.T) {
|
||||
msgs := openAICompatMessages([]Message{{
|
||||
Role: "assistant",
|
||||
Content: "tool plan",
|
||||
ReasoningContent: "thinking trace",
|
||||
ToolCalls: []ToolCall{{
|
||||
ID: "call_1",
|
||||
Name: "read_file",
|
||||
Function: &FunctionCall{
|
||||
Name: "read_file",
|
||||
Arguments: `{"path":"a.txt"}`,
|
||||
},
|
||||
}},
|
||||
}})
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("messages len = %d", len(msgs))
|
||||
}
|
||||
if got := msgs[0]["reasoning_content"]; got != "thinking trace" {
|
||||
t.Fatalf("reasoning_content = %#v, want thinking trace", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAICompatThinkingMessagesBackfillsReasoningForToolCalls(t *testing.T) {
|
||||
body := map[string]interface{}{
|
||||
"messages": []map[string]interface{}{
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": []map[string]interface{}{
|
||||
{"id": "call_1"},
|
||||
},
|
||||
"content": "thinking content",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
normalizeOpenAICompatThinkingMessages(body)
|
||||
|
||||
msgs := body["messages"].([]map[string]interface{})
|
||||
if got := msgs[0]["reasoning_content"]; got != "thinking content" {
|
||||
t.Fatalf("reasoning_content = %#v, want thinking content", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPProviderChatConfiguredCompatBackfillsReasoningContentForToolHistory(t *testing.T) {
|
||||
var gotBody map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewHTTPProvider("openai", "token", server.URL+"/v1", "gpt-5", false, "api_key", 5*time.Second, nil)
|
||||
provider.responsesAPI = "chat_completions"
|
||||
|
||||
_, err := provider.Chat(t.Context(), []Message{
|
||||
{Role: "user", Content: "hello"},
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: "thinking content",
|
||||
ToolCalls: []ToolCall{{
|
||||
ID: "call_1",
|
||||
Name: "read_file",
|
||||
Function: &FunctionCall{
|
||||
Name: "read_file",
|
||||
Arguments: `{"path":"a.txt"}`,
|
||||
},
|
||||
}},
|
||||
},
|
||||
{Role: "tool", ToolCallID: "call_1", Content: "file body"},
|
||||
}, nil, "gpt-5(high)", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat error: %v", err)
|
||||
}
|
||||
|
||||
rawMsgs, _ := gotBody["messages"].([]interface{})
|
||||
if len(rawMsgs) < 2 {
|
||||
t.Fatalf("messages = %#v", gotBody["messages"])
|
||||
}
|
||||
assistant, _ := rawMsgs[1].(map[string]interface{})
|
||||
if got := assistant["reasoning_content"]; got != "thinking content" {
|
||||
t.Fatalf("reasoning_content = %#v, want thinking content", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCompatFunctionCallsSupportsDSMLToolCalls(t *testing.T) {
|
||||
calls, cleaned := parseCompatFunctionCalls(`<||DSML||tool_calls><||DSML||invoke name="read_file"></||DSML||invoke></||DSML||tool_calls>`)
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("calls = %#v, want one tool call", calls)
|
||||
}
|
||||
if calls[0].Name != "read_file" {
|
||||
t.Fatalf("tool name = %q, want read_file", calls[0].Name)
|
||||
}
|
||||
if strings.TrimSpace(cleaned) != "" {
|
||||
t.Fatalf("cleaned = %q, want empty", cleaned)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,10 +16,11 @@ type FunctionCall struct {
|
||||
}
|
||||
|
||||
type LLMResponse struct {
|
||||
Content string `json:"content"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
Usage *UsageInfo `json:"usage,omitempty"`
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
Usage *UsageInfo `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
type UsageInfo struct {
|
||||
@@ -29,11 +30,12 @@ type UsageInfo struct {
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
ContentParts []MessageContentPart `json:"content_parts,omitempty"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||
ContentParts []MessageContentPart `json:"content_parts,omitempty"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
}
|
||||
|
||||
type MessageContentPart struct {
|
||||
|
||||
@@ -64,9 +64,10 @@ type openClawEvent struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
} `json:"content,omitempty"`
|
||||
ToolCallID string `json:"toolCallId,omitempty"`
|
||||
ToolName string `json:"toolName,omitempty"`
|
||||
ToolCalls []providers.ToolCall `json:"toolCalls,omitempty"`
|
||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||
ToolCallID string `json:"toolCallId,omitempty"`
|
||||
ToolName string `json:"toolName,omitempty"`
|
||||
ToolCalls []providers.ToolCall `json:"toolCalls,omitempty"`
|
||||
} `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
@@ -577,9 +578,10 @@ func toOpenClawMessageEvent(msg providers.Message) openClawEvent {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
} `json:"content,omitempty"`
|
||||
ToolCallID string `json:"toolCallId,omitempty"`
|
||||
ToolName string `json:"toolName,omitempty"`
|
||||
ToolCalls []providers.ToolCall `json:"toolCalls,omitempty"`
|
||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||
ToolCallID string `json:"toolCallId,omitempty"`
|
||||
ToolName string `json:"toolName,omitempty"`
|
||||
ToolCalls []providers.ToolCall `json:"toolCalls,omitempty"`
|
||||
}{
|
||||
Role: mappedRole,
|
||||
Content: []struct {
|
||||
@@ -588,8 +590,9 @@ func toOpenClawMessageEvent(msg providers.Message) openClawEvent {
|
||||
}{
|
||||
{Type: "text", Text: msg.Content},
|
||||
},
|
||||
ToolCallID: msg.ToolCallID,
|
||||
ToolCalls: msg.ToolCalls,
|
||||
ReasoningContent: msg.ReasoningContent,
|
||||
ToolCallID: msg.ToolCallID,
|
||||
ToolCalls: msg.ToolCalls,
|
||||
},
|
||||
}
|
||||
return e
|
||||
@@ -620,7 +623,7 @@ func fromJSONLLine(line []byte) (providers.Message, bool) {
|
||||
content += part.Text
|
||||
}
|
||||
}
|
||||
return providers.Message{Role: role, Content: content, ToolCallID: event.Message.ToolCallID, ToolCalls: event.Message.ToolCalls}, true
|
||||
return providers.Message{Role: role, Content: content, ReasoningContent: event.Message.ReasoningContent, ToolCallID: event.Message.ToolCallID, ToolCalls: event.Message.ToolCalls}, true
|
||||
}
|
||||
|
||||
func deriveSessionID(key string) string {
|
||||
|
||||
Reference in New Issue
Block a user