5 Commits

18 changed files with 619 additions and 39 deletions

View File

@@ -108,6 +108,8 @@ clawgo provider login codex --manual
- 额度或限流失败时自动切到 OAuth 账号池
- 仍保留多账号轮换和后台刷新
如果某个 OpenAI 兼容服务商只支持 `POST /v1/chat/completions`,可以在对应 provider 配置里设置 `responses.api: "chat_completions"`;默认值是 `responses`
### 4. 启动
交互模式:

View File

@@ -119,6 +119,8 @@ If you have both an `API key` and OAuth accounts for the same upstream, prefer c
- the provider runtime panel shows current candidate ordering, the most recent successful credential, and recent hit/error history
- to persist runtime history across restarts, configure `runtime_persist`, `runtime_history_file`, and `runtime_history_max` on the provider
If an OpenAI-compatible provider only supports `POST /v1/chat/completions`, set `responses.api: "chat_completions"` on that provider. The default remains `responses`.
### 4. Start
Interactive mode:

View File

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

View File

@@ -180,6 +180,7 @@
"max_tokens": 8192,
"temperature": 0.7,
"responses": {
"api": "responses",
"web_search_enabled": false,
"web_search_context_size": "",
"file_search_vector_store_ids": [],
@@ -208,6 +209,7 @@
"max_tokens": 8192,
"temperature": 0.7,
"responses": {
"api": "responses",
"web_search_enabled": false,
"web_search_context_size": "",
"file_search_vector_store_ids": [],
@@ -237,6 +239,7 @@
"max_tokens": 8192,
"temperature": 0.7,
"responses": {
"api": "responses",
"web_search_enabled": false,
"web_search_context_size": "",
"file_search_vector_store_ids": [],
@@ -253,6 +256,7 @@
"max_tokens": 8192,
"temperature": 0.7,
"responses": {
"api": "responses",
"web_search_enabled": false,
"web_search_context_size": "",
"file_search_vector_store_ids": [],
@@ -280,6 +284,7 @@
"max_tokens": 8192,
"temperature": 0.7,
"responses": {
"api": "responses",
"web_search_enabled": false,
"web_search_context_size": "",
"file_search_vector_store_ids": [],
@@ -306,6 +311,7 @@
"max_tokens": 8192,
"temperature": 0.7,
"responses": {
"api": "responses",
"web_search_enabled": false,
"web_search_context_size": "",
"file_search_vector_store_ids": [],

View File

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

View File

@@ -260,6 +260,7 @@ type ProviderOAuthConfig struct {
}
type ProviderResponsesConfig struct {
API string `json:"api,omitempty"`
WebSearchEnabled bool `json:"web_search_enabled"`
WebSearchContextSize string `json:"web_search_context_size"`
FileSearchVectorStoreIDs []string `json:"file_search_vector_store_ids"`

View File

@@ -11,6 +11,9 @@ func TestNormalizedViewProjectsCoreAndRuntime(t *testing.T) {
MaxTokens: 12288,
Temperature: 0.35,
TimeoutSec: 90,
Responses: ProviderResponsesConfig{
API: "chat_completions",
},
}
cfg.Agents.Subagents["coder"] = SubagentConfig{
Enabled: true,
@@ -40,4 +43,7 @@ func TestNormalizedViewProjectsCoreAndRuntime(t *testing.T) {
if got := view.Runtime.Providers["openai"].Temperature; got != 0.35 {
t.Fatalf("expected provider temperature in normalized runtime view, got %v", got)
}
if got := view.Runtime.Providers["openai"].Responses.API; got != "chat_completions" {
t.Fatalf("expected provider responses.api in normalized runtime view, got %q", got)
}
}

View File

@@ -515,6 +515,13 @@ func validateProviderConfig(path string, p ProviderConfig) []error {
if p.OAuth.CooldownSec < 0 {
errs = append(errs, fmt.Errorf("%s.oauth.cooldown_sec must be >= 0", path))
}
if p.Responses.API != "" {
switch strings.TrimSpace(p.Responses.API) {
case "responses", "chat_completions":
default:
errs = append(errs, fmt.Errorf("%s.responses.api must be one of: responses, chat_completions", path))
}
}
if p.Responses.WebSearchContextSize != "" {
switch p.Responses.WebSearchContextSize {
case "low", "medium", "high":

View File

@@ -247,3 +247,27 @@ func TestValidateProviderHybridRequiresOAuthProvider(t *testing.T) {
t.Fatalf("expected oauth.provider validation error, got %v", errs)
}
}
func TestValidateProviderResponsesAPIRejectsUnknownValue(t *testing.T) {
t.Parallel()
cfg := DefaultConfig()
pc := cfg.Models.Providers["openai"]
pc.Responses.API = "legacy"
cfg.Models.Providers["openai"] = pc
errs := Validate(cfg)
if len(errs) == 0 {
t.Fatalf("expected validation errors")
}
found := false
for _, err := range errs {
if strings.Contains(err.Error(), "models.providers.openai.responses.api") {
found = true
break
}
}
if !found {
t.Fatalf("expected responses.api validation error, got %v", errs)
}
}

View File

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

View File

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

View File

@@ -31,6 +31,7 @@ type HTTPProvider struct {
apiBase string
defaultModel string
supportsResponsesCompact bool
responsesAPI string
authMode string
timeout time.Duration
httpClient *http.Client
@@ -48,6 +49,7 @@ func NewHTTPProvider(providerName, apiKey, apiBase, defaultModel string, support
apiBase: normalizedBase,
defaultModel: strings.TrimSpace(defaultModel),
supportsResponsesCompact: supportsResponsesCompact,
responsesAPI: "responses",
authMode: authMode,
timeout: timeout,
httpClient: &http.Client{Timeout: timeout},
@@ -79,7 +81,7 @@ func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []Too
if !json.Valid(body) {
return nil, fmt.Errorf("API error (status %d, content-type %q): non-JSON response: %s", statusCode, contentType, previewResponseBody(body))
}
if p.useOpenAICompatChatUpstream() {
if p.useOpenAICompatChatUpstream() || p.useConfiguredOpenAICompatChat() {
return parseOpenAICompatResponse(body)
}
return parseResponsesAPIResponse(body)
@@ -102,7 +104,7 @@ func (p *HTTPProvider) ChatStream(ctx context.Context, messages []Message, tools
if !json.Valid(body) {
return nil, fmt.Errorf("API error (status %d, content-type %q): non-JSON response: %s", status, ctype, previewResponseBody(body))
}
if p.useOpenAICompatChatUpstream() {
if p.useOpenAICompatChatUpstream() || p.useConfiguredOpenAICompatChat() {
return parseOpenAICompatResponse(body)
}
return parseResponsesAPIResponse(body)

View File

@@ -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,
})
}
}
@@ -112,6 +119,18 @@ func (p *HTTPProvider) useOpenAICompatChatUpstream() bool {
}
}
func (p *HTTPProvider) useConfiguredOpenAICompatChat() bool {
if p == nil {
return false
}
switch strings.ToLower(strings.TrimSpace(p.responsesAPI)) {
case "chat_completions":
return true
default:
return false
}
}
func (p *HTTPProvider) compatBase() string {
switch p.oauthProvider() {
case defaultQwenOAuthProvider:
@@ -158,6 +177,7 @@ func (p *HTTPProvider) buildOpenAICompatChatRequest(messages []Message, tools []
if temperature, ok := float64FromOption(options, "temperature"); ok {
requestBody["temperature"] = temperature
}
normalizeOpenAICompatThinkingMessages(requestBody)
return requestBody
}
@@ -173,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 {
@@ -213,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
@@ -307,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>|<DSMLtool_calls>\s*(.*?)\s*</DSMLtool_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
@@ -326,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
@@ -358,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, "<DSMLtool_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)
@@ -366,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|DSMLinvoke)\b[^>]*\bname\s*=\s*"([^"]+)"[^>]*>`)
m := re.FindStringSubmatch(src)
if len(m) < 2 {
return ""
}
return strings.TrimSpace(m[1])
}

View File

@@ -1,6 +1,7 @@
package providers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
@@ -180,3 +181,163 @@ func TestBuildOpenAICompatChatRequestStripsKimiPrefixAndSuffix(t *testing.T) {
t.Fatalf("reasoning_effort = %#v, want auto", got)
}
}
func TestHTTPProviderChatUsesConfiguredChatCompletionsAPI(t *testing.T) {
var gotPath string
var gotBody map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
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":"hello from chat"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}`))
}))
defer server.Close()
provider := NewHTTPProvider("openai", "token", server.URL+"/v1", "gpt-5", false, "api_key", 5*time.Second, nil)
provider.responsesAPI = "chat_completions"
resp, err := provider.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-5", nil)
if err != nil {
t.Fatalf("Chat error: %v", err)
}
if gotPath != "/v1/chat/completions" {
t.Fatalf("path = %q, want /v1/chat/completions", gotPath)
}
if gotBody["model"] != "gpt-5" {
t.Fatalf("model = %#v, want gpt-5", gotBody["model"])
}
if resp.Content != "hello from chat" {
t.Fatalf("content = %q, want hello from chat", resp.Content)
}
if resp.Usage == nil || resp.Usage.TotalTokens != 3 {
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(`<DSMLtool_calls><DSMLinvoke name="read_file"></DSMLinvoke></DSMLtool_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)
}
}

View File

@@ -116,7 +116,11 @@ func CreateProviderByName(cfg *config.Config, name string) (LLMProvider, error)
if oauthProvider == defaultIFlowOAuthProvider || strings.EqualFold(routeName, defaultIFlowOAuthProvider) {
return NewIFlowProvider(routeName, pc.APIKey, pc.APIBase, defaultModel, pc.SupportsResponsesCompact, pc.Auth, time.Duration(pc.TimeoutSec)*time.Second, oauth), nil
}
return NewHTTPProvider(routeName, pc.APIKey, pc.APIBase, defaultModel, pc.SupportsResponsesCompact, pc.Auth, time.Duration(pc.TimeoutSec)*time.Second, oauth), nil
provider := NewHTTPProvider(routeName, pc.APIKey, pc.APIBase, defaultModel, pc.SupportsResponsesCompact, pc.Auth, time.Duration(pc.TimeoutSec)*time.Second, oauth)
if api := strings.TrimSpace(pc.Responses.API); api != "" {
provider.responsesAPI = api
}
return provider, nil
}
func ProviderSupportsResponsesCompact(cfg *config.Config, name string) bool {

View File

@@ -44,7 +44,7 @@ func (p *HTTPProvider) callResponses(ctx context.Context, messages []Message, to
if prevID, ok := stringOption(options, "responses_previous_response_id"); ok && prevID != "" {
requestBody["previous_response_id"] = prevID
}
if p.useOpenAICompatChatUpstream() {
if p.useOpenAICompatChatUpstream() || p.useConfiguredOpenAICompatChat() {
chatBody := p.buildOpenAICompatChatRequest(messages, tools, model, options)
return p.postJSON(ctx, endpointFor(p.compatBase(), "/chat/completions"), chatBody)
}
@@ -309,7 +309,7 @@ func (p *HTTPProvider) callResponsesStream(ctx context.Context, messages []Messa
if streamOpts, ok := mapOption(options, "responses_stream_options"); ok && len(streamOpts) > 0 {
requestBody["stream_options"] = streamOpts
}
if p.useOpenAICompatChatUpstream() {
if p.useOpenAICompatChatUpstream() || p.useConfiguredOpenAICompatChat() {
chatBody := p.buildOpenAICompatChatRequest(messages, tools, model, options)
chatBody["stream"] = true
streamOptions := map[string]interface{}{"include_usage": true}

View File

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

View File

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