Files
ttstd-infinite-canvas/backend/internal/app/provider_text.go

1407 lines
48 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
// 文本生成、Agent 工具循环和文本流解析。
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log"
"math"
"net/http"
"sort"
"strings"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/protocol"
)
func runAgentToolTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
// 浏览器持久化的是协议中立请求;资源水合和模型路由完成后才展开上游协议,
// 防止供应商请求体反向污染任务记录,也避免切换模型时复用错误协议。
if input.AgentRequests != nil && input.AgentRequests.Canonical != nil {
_, declarative := agentProtocolAdapterForContext(ctx, input.Config.InterfaceType)
requests, err := expandCanonicalAgentRequest(input.AgentRequests.Canonical, input.Config, declarative)
if err != nil {
return nil, err
}
input.AgentRequests = requests
}
if adapter, ok := agentProtocolAdapterForContext(ctx, input.Config.InterfaceType); ok {
return runDeclarativeAgentTask(ctx, input, adapter)
}
if input.AgentRequests == nil {
return nil, errors.New("画布 Agent 工具请求缺少协议参数")
}
request := input.AgentRequests.ChatCompletion
path := "/chat/completions"
protocol := "chat-completion"
if input.Config.InterfaceType == string(model.ChannelInterfaceOpenAIResponse) {
request = input.AgentRequests.Responses
path = "/responses"
protocol = "responses"
} else if input.Config.InterfaceType == string(model.ChannelInterfaceClaudeAPI) {
if input.AgentRequests.Claude != nil {
request = input.AgentRequests.Claude
}
path = "/messages"
protocol = "claude-api"
}
if request == nil {
return nil, errors.New("画布 Agent 工具请求缺少协议参数")
}
body := cloneStringAnyMap(request)
if protocol == "claude-api" && input.AgentRequests.Claude == nil {
body = claudeAgentBody(body)
}
body["model"] = input.Config.Model
applyTextThinking(body, input, protocol)
applyAgentOutputLimit(body, agentStepOutputLimit(input), protocol)
normalizeAgentToolChoice(body, input, protocol)
result, err := postAgentRequest(ctx, input, path, body, protocol)
if protocol == "chat-completion" && isAgentToolChoiceCompatibilityError(err) {
if !isAutoAgentToolChoice(body["tool_choice"]) {
autoBody := cloneStringAnyMap(body)
autoBody["tool_choice"] = "auto"
result, err = postAgentRequest(ctx, input, path, autoBody, protocol)
}
if isAgentToolChoiceCompatibilityError(err) {
withoutToolChoice := cloneStringAnyMap(body)
delete(withoutToolChoice, "tool_choice")
result, err = postAgentRequest(ctx, input, path, withoutToolChoice, protocol)
}
}
if err != nil {
return nil, err
}
return result, nil
}
func postAgentRequest(ctx context.Context, input canvasGenerationInput, path string, body map[string]interface{}, protocol string) (map[string]interface{}, error) {
if input.StreamText {
return postStreamingAgent(ctx, input.Config, path, body, protocol, input.OnTextDelta, input.OnReasoningDelta)
}
delete(body, "stream")
var payload map[string]interface{}
if err := postJSON(ctx, input.Config, path, body, &payload); err != nil {
return nil, err
}
return parseAgentToolPayload(payload, protocol)
}
// agentStepOutputLimit 取本次 Agent 调用的输出上限。
//
// 两个来源语义相同但通道不同:textOptions.maxOutputTokens 是任务信封里下发的策略值
// (画布 Agent 每步按运行时策略给,persist 在任务输入里,重启后仍在);input.MaxOutputTokens
// 是进程内直传的旧入口(渠道模型能力声明)。都为 0 时不写上限字段,交给上游按剩余上下文放行。
// 都非零时取较小值:策略上限不该超过模型自己声明的物理上限。
func agentStepOutputLimit(input canvasGenerationInput) int {
limits := []int{input.TextOptions.MaxOutputTokens, input.MaxOutputTokens}
limit := 0
for _, candidate := range limits {
if candidate <= 0 {
continue
}
if limit == 0 || candidate < limit {
limit = candidate
}
}
return limit
}
// applyAgentOutputLimit 按协议写入输出上限字段名:Claude 与 Chat Completions 用 max_tokens,
// Responses 用 max_output_tokens。
func applyAgentOutputLimit(body map[string]interface{}, limit int, protocol string) {
if limit <= 0 {
return
}
field := "max_tokens"
if protocol == "responses" {
field = "max_output_tokens"
}
applyTextOutputLimit(body, limit, field)
}
func runDeclarativeAgentTask(ctx context.Context, input canvasGenerationInput, adapter protocol.AgentAdapter) (map[string]interface{}, error) {
wire := input.Config.InterfaceType
if wire == string(model.ChannelInterfaceOpenAIResponse) {
wire = "responses"
}
knownWire := wire == "chat-completion" || wire == "responses" || wire == "claude-api"
if input.TextOptions.Thinking && !knownWire {
return nil, errors.New("当前声明式 Agent 渠道尚不支持思考模式,请关闭思考模式或切换内置协议渠道")
}
if input.AgentRequests == nil {
return nil, errors.New("画布 Agent 工具请求缺少协议参数")
}
request := map[string]any{
"chatCompletion": input.AgentRequests.ChatCompletion,
"responses": input.AgentRequests.Responses,
"claude": input.AgentRequests.Claude,
"gemini": input.AgentRequests.Gemini,
}
spec, err := adapter.BuildAgent(ctx, protocol.AgentRequestContext{BaseURL: input.Config.BaseURL, Model: input.Config.Model, Request: request})
if err != nil {
return nil, err
}
if knownWire {
body := protocolBodyObject(spec.Body)
if body == nil {
return nil, errors.New("声明式 Agent 请求体必须是 JSON 对象")
}
applyTextThinking(body, input, wire)
applyAgentOutputLimit(body, agentStepOutputLimit(input), wire)
normalizeAgentToolChoice(body, input, wire)
spec.Body = body
if input.StreamText {
body["stream"] = true
if wire == "chat-completion" {
if err := ensureChatCompletionStreamUsage(body); err != nil {
return nil, err
}
}
parser := newStreamingAgentParser(wire, input.OnTextDelta)
parser.emitReasoning = input.OnReasoningDelta
data, mime, err := executeProtocolBinaryRequestWithConsumer(ctx, input.Config, spec, parser.consume)
if err != nil {
return nil, err
}
if strings.Contains(strings.ToLower(mime), "event-stream") {
parser.flush()
return parser.result()
}
var payload map[string]interface{}
if err := json.Unmarshal(data, &payload); err != nil {
return nil, fmt.Errorf("Agent 接口返回格式无效:%w", err)
}
return parseAgentToolPayload(payload, wire)
}
}
body, err := executeDeclarativeAgentWithGeminiCache(ctx, input, spec)
if err != nil {
return nil, err
}
parsed, err := adapter.ParseAgent(ctx, body)
if err != nil {
return nil, err
}
result := map[string]interface{}{"mode": "text", "text": parsed.Text, "toolCalls": []interface{}{}}
if parsed.Reasoning != "" {
result["reasoning"] = parsed.Reasoning
}
calls := make([]interface{}, 0, len(parsed.ToolCalls))
for _, call := range parsed.ToolCalls {
mapped := map[string]interface{}{
"id": call.ID,
"type": "function",
"function": map[string]interface{}{"name": call.Name, "arguments": call.Arguments},
}
if call.ThoughtSignature != "" {
mapped["thoughtSignature"] = call.ThoughtSignature
}
calls = append(calls, mapped)
}
result["toolCalls"] = calls
if strings.TrimSpace(parsed.Text) == "" && len(calls) == 0 {
return nil, errors.New("声明式 Agent 接口没有返回内容")
}
return result, nil
}
// executeDeclarativeAgentWithGeminiCache keeps explicit Prompt Cache entirely
// optional: cache creation, cleanup, and a single stale-cache rebuild can never
// turn a valid uncached Agent request into a failed run.
func executeDeclarativeAgentWithGeminiCache(ctx context.Context, input canvasGenerationInput, spec protocol.RequestSpec) ([]byte, error) {
baseSpec, err := cloneProtocolRequestSpec(spec)
if err != nil {
return nil, err
}
executeSpec := baseSpec
cacheUsed := false
if input.Config.InterfaceType == officialGeminiAgentInterface {
prepared, used, prepareErr := prepareOfficialGeminiAgentCache(ctx, input, baseSpec)
if prepareErr != nil {
log.Printf("gemini agent prompt cache unavailable: model=%s reason=%s", safeProviderModel(input.Config.Model), safeProviderLogError(prepareErr))
} else {
executeSpec, cacheUsed = prepared, used
}
}
body, err := executeProtocolRequest(ctx, input.Config, executeSpec)
if err == nil || input.Config.InterfaceType != officialGeminiAgentInterface || !cacheUsed || !geminiRequestUsesCachedContent(executeSpec) || !isGeminiCachedContentNotFound(err, stringValue(protocolBodyObject(executeSpec.Body)["cachedContent"])) {
return body, err
}
resourceName := ""
if body := protocolBodyObject(executeSpec.Body); body != nil {
resourceName, _ = body["cachedContent"].(string)
}
if invalidateErr := invalidateOfficialGeminiAgentCache(ctx, input, baseSpec, resourceName); invalidateErr != nil {
// The local identity is still removed whenever possible. Do not replace a
// provider 404 with a cache bookkeeping error or skip the one rebuild.
log.Printf("gemini agent prompt cache invalidation failed: model=%s reason=%s", safeProviderModel(input.Config.Model), safeProviderLogError(invalidateErr))
}
rebuilt, _, prepareErr := prepareOfficialGeminiAgentCacheMode(ctx, input, baseSpec, true)
if prepareErr != nil {
log.Printf("gemini agent prompt cache rebuild unavailable: model=%s reason=%s", safeProviderModel(input.Config.Model), safeProviderLogError(prepareErr))
rebuilt = baseSpec
}
// The rebuilt request is intentionally executed only once. If it receives
// another CachedContent 404, the first stale-cache recovery already happened;
// preserve the provider error instead of recursively rebuilding forever.
return executeProtocolRequest(ctx, input.Config, rebuilt)
}
func claudeAgentBody(request map[string]interface{}) map[string]interface{} {
body := map[string]interface{}{"max_tokens": 4096}
if messages, ok := request["messages"].([]interface{}); ok {
claudeMessages := make([]interface{}, 0, len(messages))
var system []string
for _, value := range messages {
message, _ := value.(map[string]interface{})
role := strings.ToLower(strings.TrimSpace(stringField(message, "role")))
if role == "system" {
if content := strings.TrimSpace(fmt.Sprint(message["content"])); content != "" {
system = append(system, content)
}
continue
}
if role == "tool" {
claudeMessages = append(claudeMessages, map[string]interface{}{"role": "user", "content": []interface{}{map[string]interface{}{
"type": "tool_result", "tool_use_id": stringField(message, "tool_call_id"), "content": fmt.Sprint(message["content"]),
}}})
continue
}
role = mapClaudeMessageRole(role)
content := message["content"]
if content == nil {
content = ""
}
if toolCalls, ok := message["tool_calls"].([]interface{}); ok && len(toolCalls) > 0 {
blocks := make([]interface{}, 0, len(toolCalls))
for _, value := range toolCalls {
toolCall, _ := value.(map[string]interface{})
function, _ := toolCall["function"].(map[string]interface{})
blocks = append(blocks, map[string]interface{}{
"type": "tool_use", "id": stringField(toolCall, "id"), "name": stringField(function, "name"), "input": claudeToolInput(function["arguments"]),
})
}
content = blocks
}
claudeMessages = append(claudeMessages, map[string]interface{}{"role": role, "content": content})
}
body["messages"] = claudeMessages
if len(system) > 0 {
body["system"] = []interface{}{map[string]interface{}{
"type": "text", "text": strings.Join(system, "\n\n"),
"cache_control": map[string]interface{}{"type": "ephemeral"},
}}
}
}
if tools, ok := request["tools"].([]interface{}); ok && len(tools) > 0 {
claudeTools := make([]interface{}, 0, len(tools))
for _, value := range tools {
tool, _ := value.(map[string]interface{})
function, _ := tool["function"].(map[string]interface{})
if len(function) == 0 {
continue
}
claudeTools = append(claudeTools, map[string]interface{}{
"name": stringField(function, "name"), "description": stringField(function, "description"), "input_schema": function["parameters"],
})
}
if len(claudeTools) > 0 {
if last, ok := claudeTools[len(claudeTools)-1].(map[string]interface{}); ok {
last["cache_control"] = map[string]interface{}{"type": "ephemeral"}
}
body["tools"] = claudeTools
}
}
if choice, ok := request["tool_choice"]; ok {
body["tool_choice"] = claudeToolChoice(choice)
}
return body
}
func mapClaudeMessageRole(role string) string {
if role == "assistant" {
return "assistant"
}
return "user"
}
func claudeToolInput(value interface{}) interface{} {
if raw, ok := value.(string); ok {
var parsed interface{}
if json.Unmarshal([]byte(raw), &parsed) == nil && parsed != nil {
return parsed
}
}
if value != nil {
return value
}
return map[string]interface{}{}
}
func claudeToolChoice(value interface{}) interface{} {
switch choice := value.(type) {
case string:
switch strings.ToLower(strings.TrimSpace(choice)) {
case "required":
return map[string]interface{}{"type": "any"}
case "none":
return map[string]interface{}{"type": "auto"}
default:
return map[string]interface{}{"type": "auto"}
}
case map[string]interface{}:
if function, ok := choice["function"].(map[string]interface{}); ok && stringField(function, "name") != "" {
return map[string]interface{}{"type": "tool", "name": stringField(function, "name")}
}
if name := stringField(choice, "name"); name != "" {
return map[string]interface{}{"type": "tool", "name": name}
}
}
return map[string]interface{}{"type": "auto"}
}
func isAgentToolChoiceCompatibilityError(err error) bool {
if err == nil {
return false
}
message := strings.ToLower(err.Error())
var payloadErr providerPayloadError
if errors.As(err, &payloadErr) {
message += " " + strings.ToLower(payloadErr.raw)
}
var httpErr providerHTTPError
if errors.As(err, &httpErr) {
message += " " + strings.ToLower(httpErr.Body)
}
return strings.Contains(message, "tool_choice") || strings.Contains(message, "tool choice") || strings.Contains(message, "tool-choice") || strings.Contains(message, "thinking mode")
}
func isAutoAgentToolChoice(value interface{}) bool {
choice, ok := value.(string)
return ok && strings.EqualFold(strings.TrimSpace(choice), "auto")
}
func parseAgentToolPayload(payload map[string]interface{}, protocol string) (map[string]interface{}, error) {
if err := validateTextPayload(payload); err != nil {
return nil, err
}
result := map[string]interface{}{"mode": "text", "text": "", "toolCalls": []interface{}{}}
if protocol == "responses" {
result["text"] = firstNonEmptyString(stringField(payload, "output_text"), extractResponseText(payload))
if reasoning := extractResponseReasoning(payload); reasoning != "" {
result["reasoning"] = reasoning
}
calls := make([]interface{}, 0)
for _, value := range interfaceSlice(payload["output"]) {
item, _ := value.(map[string]interface{})
if stringField(item, "type") != "function_call" {
continue
}
calls = append(calls, map[string]interface{}{"id": firstNonEmptyString(stringField(item, "call_id"), stringField(item, "id")), "type": "function", "function": map[string]interface{}{"name": stringField(item, "name"), "arguments": stringField(item, "arguments")}})
}
result["toolCalls"] = calls
return result, nil
}
if protocol == "claude-api" {
content := interfaceSlice(payload["content"])
calls := make([]interface{}, 0)
for _, value := range content {
item, _ := value.(map[string]interface{})
switch stringField(item, "type") {
case "text":
result["text"] = result["text"].(string) + stringField(item, "text")
case "thinking":
result["reasoning"] = resultString(result, "reasoning") + firstNonEmptyString(stringField(item, "thinking"), stringField(item, "text"))
case "tool_use":
arguments, err := json.Marshal(item["input"])
if err != nil {
return nil, err
}
calls = append(calls, map[string]interface{}{"id": stringField(item, "id"), "type": "function", "function": map[string]interface{}{"name": stringField(item, "name"), "arguments": string(arguments)}})
}
}
result["toolCalls"] = calls
if result["text"] == "" && len(calls) == 0 {
return nil, errors.New("Claude Agent 接口没有返回内容")
}
return result, nil
}
choices := interfaceSlice(payload["choices"])
if len(choices) == 0 {
return nil, errors.New("画布 Agent 接口没有返回 choices")
}
choice, _ := choices[0].(map[string]interface{})
message, _ := choice["message"].(map[string]interface{})
result["text"] = stringField(message, "content")
if reasoning := firstNonEmptyString(stringField(message, "reasoning_content"), stringField(message, "reasoning")); reasoning != "" {
result["reasoning"] = reasoning
}
calls := make([]interface{}, 0)
for _, value := range interfaceSlice(message["tool_calls"]) {
item, _ := value.(map[string]interface{})
function, _ := item["function"].(map[string]interface{})
calls = append(calls, map[string]interface{}{"id": stringField(item, "id"), "type": "function", "function": map[string]interface{}{"name": stringField(function, "name"), "arguments": stringField(function, "arguments")}})
}
result["toolCalls"] = calls
return result, nil
}
func postStreamingAgent(ctx context.Context, config providerConfig, path string, body map[string]interface{}, protocol string, onDelta func(string), onReasoning ...func(string)) (map[string]interface{}, error) {
body["stream"] = true
if protocol == "chat-completion" {
if err := ensureChatCompletionStreamUsage(body); err != nil {
return nil, err
}
}
parser := newStreamingAgentParser(protocol, onDelta)
if len(onReasoning) > 0 {
parser.emitReasoning = onReasoning[0]
}
data, mimeType, err := postStreamingBinary(ctx, config, path, body, parser.consume)
if err != nil {
return nil, err
}
if !strings.Contains(strings.ToLower(mimeType), "event-stream") {
var payload map[string]interface{}
if err := json.Unmarshal(data, &payload); err != nil {
return nil, fmt.Errorf("Agent 接口返回格式无效:%w", err)
}
return parseAgentToolPayload(payload, protocol)
}
parser.flush()
return parser.result()
}
type streamingAgentToolCall struct {
id string
name string
arguments string
}
type streamingAgentParser struct {
protocol string
buffer string
text strings.Builder
reasoning strings.Builder
toolCalls map[int]*streamingAgentToolCall
toolCallByID map[string]int
completed map[string]interface{}
err error
emit func(string)
emitReasoning func(string)
}
func newStreamingAgentParser(protocol string, emit func(string)) *streamingAgentParser {
return &streamingAgentParser{protocol: protocol, toolCalls: map[int]*streamingAgentToolCall{}, toolCallByID: map[string]int{}, emit: emit}
}
func (p *streamingAgentParser) consume(mimeType string, chunk []byte) {
if p == nil || p.err != nil || !strings.Contains(strings.ToLower(mimeType), "event-stream") || len(chunk) == 0 {
return
}
p.buffer += string(chunk)
p.consumeFrames(false)
}
func (p *streamingAgentParser) flush() {
if p == nil || p.err != nil {
return
}
p.consumeFrames(true)
}
func (p *streamingAgentParser) consumeFrames(flush bool) {
for p.err == nil {
match := sseFrameBoundaryPattern.FindStringIndex(p.buffer)
if match == nil {
break
}
p.consumeFrame(p.buffer[:match[0]])
p.buffer = p.buffer[match[1]:]
}
if flush && p.err == nil && strings.TrimSpace(p.buffer) != "" {
p.consumeFrame(p.buffer)
p.buffer = ""
}
}
func (p *streamingAgentParser) consumeFrame(frame string) {
var eventName string
var dataLines []string
for _, line := range strings.Split(strings.ReplaceAll(frame, "\r\n", "\n"), "\n") {
switch {
case strings.HasPrefix(line, "event:"):
eventName = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
case strings.HasPrefix(line, "data:"):
dataLines = append(dataLines, strings.TrimPrefix(strings.TrimPrefix(line, "data:"), " "))
}
}
raw := strings.TrimSpace(strings.Join(dataLines, "\n"))
if raw == "" || raw == "[DONE]" {
return
}
var payload map[string]interface{}
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
p.err = fmt.Errorf("Agent 流式事件解析失败:%w", err)
return
}
if err := validateTextPayload(payload); err != nil {
p.err = err
return
}
switch p.protocol {
case "responses":
p.consumeResponsesEvent(eventName, payload)
case "claude-api":
p.consumeClaudeEvent(payload)
default:
p.consumeChatCompletionEvent(payload)
}
}
func (p *streamingAgentParser) consumeResponsesEvent(eventName string, payload map[string]interface{}) {
eventType := firstNonEmptyString(strings.TrimSpace(eventName), stringField(payload, "type"))
switch eventType {
case "response.output_text.delta", "output_text.delta":
p.appendText(stringField(payload, "delta"))
case "response.reasoning.delta", "response.reasoning_text.delta", "response.reasoning_summary_text.delta":
p.appendReasoning(stringField(payload, "delta"))
case "response.completed":
p.completed, _ = payload["response"].(map[string]interface{})
case "response.output_item.added":
item, _ := payload["item"].(map[string]interface{})
if stringField(item, "type") == "function_call" {
index := intField(payload, "output_index", len(p.toolCalls))
p.setToolCall(index, firstNonEmptyString(stringField(item, "call_id"), stringField(item, "id")), stringField(item, "name"), stringField(item, "arguments"))
}
case "response.function_call_arguments.delta":
index := p.responseToolCallIndex(payload)
p.toolCall(index).arguments += stringField(payload, "delta")
case "response.function_call_arguments.done":
index := p.responseToolCallIndex(payload)
if arguments := stringField(payload, "arguments"); arguments != "" {
p.toolCall(index).arguments = arguments
}
}
}
func (p *streamingAgentParser) responseToolCallIndex(payload map[string]interface{}) int {
itemID := firstNonEmptyString(stringField(payload, "item_id"), stringField(payload, "call_id"))
if index, ok := p.toolCallByID[itemID]; ok {
return index
}
return intField(payload, "output_index", len(p.toolCalls))
}
func (p *streamingAgentParser) consumeChatCompletionEvent(payload map[string]interface{}) {
choices, _ := payload["choices"].([]interface{})
for _, value := range choices {
choice, _ := value.(map[string]interface{})
delta, _ := choice["delta"].(map[string]interface{})
p.appendText(streamContentText(delta["content"]))
p.appendReasoning(firstNonEmptyString(stringField(delta, "reasoning_content"), stringField(delta, "reasoning"), stringField(delta, "reasoning_text")))
for fallbackIndex, toolValue := range interfaceSlice(delta["tool_calls"]) {
tool, _ := toolValue.(map[string]interface{})
index := intField(tool, "index", fallbackIndex)
function, _ := tool["function"].(map[string]interface{})
current := p.toolCall(index)
if id := stringField(tool, "id"); id != "" {
current.id = id
p.toolCallByID[id] = index
}
if name := stringField(function, "name"); name != "" {
current.name += name
}
current.arguments += stringField(function, "arguments")
}
}
}
func (p *streamingAgentParser) consumeClaudeEvent(payload map[string]interface{}) {
switch stringField(payload, "type") {
case "content_block_start":
block, _ := payload["content_block"].(map[string]interface{})
index := intField(payload, "index", len(p.toolCalls))
switch stringField(block, "type") {
case "text":
p.appendText(stringField(block, "text"))
case "thinking":
p.appendReasoning(firstNonEmptyString(stringField(block, "thinking"), stringField(block, "text")))
case "tool_use":
arguments := ""
if input := block["input"]; input != nil {
if encoded, err := json.Marshal(input); err == nil && string(encoded) != "{}" {
arguments = string(encoded)
}
}
p.setToolCall(index, stringField(block, "id"), stringField(block, "name"), arguments)
}
case "content_block_delta":
delta, _ := payload["delta"].(map[string]interface{})
index := intField(payload, "index", len(p.toolCalls)-1)
if stringField(delta, "type") == "text_delta" {
p.appendText(stringField(delta, "text"))
}
if stringField(delta, "type") == "thinking_delta" {
p.appendReasoning(firstNonEmptyString(stringField(delta, "thinking"), stringField(delta, "text")))
}
if stringField(delta, "type") == "input_json_delta" {
p.toolCall(index).arguments += stringField(delta, "partial_json")
}
case "error":
errValue, _ := payload["error"].(map[string]interface{})
p.err = errors.New(defaultString(stringField(errValue, "message"), "Claude 上游返回失败"))
}
}
func resultString(value map[string]interface{}, key string) string {
return stringField(value, key)
}
func (p *streamingAgentParser) appendText(delta string) {
if delta == "" {
return
}
p.text.WriteString(delta)
if p.emit != nil {
p.emit(delta)
}
}
func (p *streamingAgentParser) appendReasoning(delta string) {
if delta == "" {
return
}
p.reasoning.WriteString(delta)
if p.emitReasoning != nil {
p.emitReasoning(delta)
}
}
func (p *streamingAgentParser) toolCall(index int) *streamingAgentToolCall {
if index < 0 {
index = 0
}
if p.toolCalls[index] == nil {
p.toolCalls[index] = &streamingAgentToolCall{}
}
return p.toolCalls[index]
}
func (p *streamingAgentParser) setToolCall(index int, id string, name string, arguments string) {
call := p.toolCall(index)
call.id, call.name, call.arguments = id, name, arguments
if id != "" {
p.toolCallByID[id] = index
}
}
func (p *streamingAgentParser) result() (map[string]interface{}, error) {
if p.err != nil {
return nil, p.err
}
if p.completed != nil {
result, err := parseAgentToolPayload(p.completed, p.protocol)
if err != nil {
return nil, err
}
if p.text.Len() > 0 {
result["text"] = p.text.String()
}
if p.reasoning.Len() > 0 {
result["reasoning"] = p.reasoning.String()
}
return result, nil
}
result := map[string]interface{}{"mode": "text", "text": p.text.String(), "toolCalls": []interface{}{}}
if p.reasoning.Len() > 0 {
result["reasoning"] = p.reasoning.String()
}
indices := make([]int, 0, len(p.toolCalls))
for index := range p.toolCalls {
indices = append(indices, index)
}
sort.Ints(indices)
calls := make([]interface{}, 0, len(indices))
for _, index := range indices {
call := p.toolCalls[index]
if call == nil || strings.TrimSpace(call.id) == "" || strings.TrimSpace(call.name) == "" {
continue
}
arguments := call.arguments
if strings.TrimSpace(arguments) == "" {
arguments = "{}"
}
var parsed interface{}
if err := json.Unmarshal([]byte(arguments), &parsed); err != nil {
return nil, fmt.Errorf("Agent 工具参数不是完整 JSON:%w", err)
}
calls = append(calls, map[string]interface{}{"id": call.id, "type": "function", "function": map[string]interface{}{"name": call.name, "arguments": arguments}})
}
result["toolCalls"] = calls
if p.text.Len() == 0 && len(calls) == 0 {
return nil, errors.New("画布 Agent 接口没有返回内容")
}
return result, nil
}
func intField(value map[string]interface{}, key string, fallback int) int {
number, ok := value[key].(float64)
if !ok || math.IsNaN(number) || math.IsInf(number, 0) {
return fallback
}
return int(number)
}
func extractResponseReasoning(payload map[string]interface{}) string {
var chunks []string
for _, value := range interfaceSlice(payload["output"]) {
item, _ := value.(map[string]interface{})
if stringField(item, "type") != "reasoning" {
continue
}
for _, key := range []string{"summary", "content"} {
for _, part := range interfaceSlice(item[key]) {
record, _ := part.(map[string]interface{})
if text := strings.TrimSpace(stringField(record, "text")); text != "" {
chunks = append(chunks, text)
}
}
}
}
return strings.Join(chunks, "\n")
}
func interfaceSlice(value interface{}) []interface{} {
items, _ := value.([]interface{})
return items
}
func cloneStringAnyMap(value map[string]interface{}) map[string]interface{} {
cloned := make(map[string]interface{}, len(value)+1)
for key, item := range value {
cloned[key] = item
}
return cloned
}
func runTextTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
if _, ok := declarativeProtocolAdapterForContext(ctx, input.Config.InterfaceType); ok {
return runDeclarativeProtocolTask(ctx, input)
}
switch input.Config.InterfaceType {
case "chat-completion":
return runChatCompletionsTextTask(ctx, input)
case "openai-response":
return runResponsesTextTask(ctx, input)
case string(model.ChannelInterfaceClaudeAPI):
return runClaudeTextTask(ctx, input)
}
return runLegacyTextTask(ctx, input)
}
// Known text protocols keep the plugin's request mapping and host transport,
// while sharing the SSE parser used by text generation and Agent requests.
func executeProtocolCreateRequest(ctx context.Context, input canvasGenerationInput, spec protocol.RequestSpec) ([]byte, *protocol.Result, error) {
wire := input.Config.InterfaceType
if wire == string(model.ChannelInterfaceOpenAIResponse) {
wire = "responses"
}
if input.Mode != "text" || !input.StreamText || (wire != "chat-completion" && wire != "responses" && wire != "claude-api") {
data, err := executeProtocolRequest(ctx, input.Config, spec)
return data, nil, err
}
body := protocolBodyObject(spec.Body)
if body == nil {
return nil, nil, errors.New("声明式流式文本请求体必须是 JSON 对象")
}
body["stream"] = true
if wire == "chat-completion" {
if err := ensureChatCompletionStreamUsage(body); err != nil {
return nil, nil, err
}
}
spec.Body = body
parser := newStreamingAgentParser(wire, input.OnTextDelta)
parser.emitReasoning = input.OnReasoningDelta
data, mimeType, err := executeProtocolBinaryRequestWithConsumer(ctx, input.Config, spec, parser.consume)
if err != nil || !strings.Contains(strings.ToLower(mimeType), "event-stream") {
return data, nil, err
}
parser.flush()
parsed, err := parser.result()
if err != nil {
return nil, nil, err
}
text := stringField(parsed, "text")
if text == "" {
return nil, nil, errors.New("流式文本接口没有返回内容")
}
return data, &protocol.Result{Text: text, Reasoning: stringField(parsed, "reasoning")}, nil
}
func runLegacyTextTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
responseInput, err := textResponseInput(input)
if err != nil {
return nil, err
}
body := map[string]interface{}{"model": input.Config.Model, "input": responseInput}
applyTextThinking(body, input, "responses")
applyTextOutputLimit(body, input.MaxOutputTokens, "max_output_tokens")
result, err := requestTextProvider(ctx, input.Config, "/responses", body, "responses", input.StreamText, input.OnTextDelta)
if err != nil {
if !shouldFallbackTextToChat(err) {
return nil, err
}
result, chatErr := runChatCompletionsTextTask(ctx, input)
if chatErr == nil {
return result, nil
}
return nil, fmt.Errorf("文本接口请求失败:Responses API %v;Chat Completions %v", err, chatErr)
}
return providerTextTaskResult(result), nil
}
func runResponsesTextTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
responseInput, err := textResponseInput(input)
if err != nil {
return nil, err
}
body := map[string]interface{}{"model": input.Config.Model, "input": responseInput}
applyTextThinking(body, input, "responses")
applyTextOutputLimit(body, input.MaxOutputTokens, "max_output_tokens")
result, err := requestTextProvider(ctx, input.Config, "/responses", body, "responses", input.StreamText, input.OnTextDelta)
if err != nil {
return nil, err
}
return providerTextTaskResult(result), nil
}
func runChatCompletionsTextTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
messages := []map[string]interface{}{}
if systemPrompt := strings.TrimSpace(input.Config.SystemPrompt); systemPrompt != "" {
messages = append(messages, map[string]interface{}{"role": "system", "content": systemPrompt})
}
messages = append(messages, validatedTextHistory(input.TextHistory)...)
userContent, err := textChatContent(input)
if err != nil {
return nil, err
}
messages = append(messages, map[string]interface{}{"role": "user", "content": userContent})
body := map[string]interface{}{"model": input.Config.Model, "messages": messages}
applyTextThinking(body, input, "chat-completion")
applyTextOutputLimit(body, input.MaxOutputTokens, "max_tokens")
result, err := requestTextProvider(ctx, input.Config, "/chat/completions", body, "chat-completion", input.StreamText, input.OnTextDelta)
if err != nil {
return nil, err
}
return providerTextTaskResult(result), nil
}
func runClaudeTextTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
if len(input.ReferenceVideos) > 0 {
return nil, errors.New("Claude API 当前不支持视频参考输入")
}
messages := make([]map[string]interface{}, 0, len(input.TextHistory)+1)
for _, message := range validatedTextHistory(input.TextHistory) {
messages = append(messages, message)
}
content, err := claudeTextContent(input)
if err != nil {
return nil, err
}
messages = append(messages, map[string]interface{}{"role": "user", "content": content})
maxTokens := 4096
if input.MaxOutputTokens > 0 {
maxTokens = input.MaxOutputTokens
}
body := map[string]interface{}{"model": input.Config.Model, "max_tokens": maxTokens, "messages": messages}
applyTextThinking(body, input, "claude-api")
if systemPrompt := strings.TrimSpace(input.Config.SystemPrompt); systemPrompt != "" {
body["system"] = systemPrompt
}
result, err := requestTextProvider(ctx, input.Config, "/messages", body, "claude-api", input.StreamText, input.OnTextDelta)
if err != nil {
return nil, err
}
return providerTextTaskResult(result), nil
}
func applyTextThinking(body map[string]interface{}, input canvasGenerationInput, protocol string) {
if !input.TextOptions.Thinking {
return
}
switch protocol {
case "responses":
body["reasoning"] = map[string]interface{}{"effort": "medium", "summary": "auto"}
case "chat-completion":
body["reasoning_effort"] = "medium"
case "claude-api":
body["thinking"] = map[string]interface{}{"type": "enabled", "budget_tokens": 1024}
}
}
// Chat Completion defaults to automatic tool selection when tools are present,
// so an explicit "auto" only reduces compatibility. Reasoning endpoints also
// disagree on forced choices. Normalize before the first network request while
// preserving required/named choices for non-reasoning structured tasks.
func normalizeAgentToolChoice(body map[string]interface{}, input canvasGenerationInput, protocol string) {
if protocol == "chat-completion" && (input.TextOptions.Thinking || isAutoAgentToolChoice(body["tool_choice"])) {
delete(body, "tool_choice")
}
}
type providerTextResult struct {
Text string
Reasoning string
}
func providerTextTaskResult(result providerTextResult) map[string]interface{} {
payload := map[string]interface{}{"mode": "text", "text": result.Text}
if strings.TrimSpace(result.Reasoning) != "" {
payload["reasoning"] = result.Reasoning
}
return payload
}
func applyTextOutputLimit(body map[string]interface{}, limit int, field string) {
if limit > 0 {
body[field] = limit
}
}
func claudeTextContent(input canvasGenerationInput) (interface{}, error) {
if len(input.ReferenceImages) == 0 {
return input.Prompt, nil
}
content := []map[string]interface{}{{"type": "text", "text": input.Prompt}}
for _, image := range input.ReferenceImages {
value, err := openAIImageInputURL(image)
if err != nil {
return nil, err
}
if strings.HasPrefix(value, "data:") {
mimeType, data, ok := splitDataURL(value)
if !ok {
return nil, errors.New("Claude 参考图片 data URL 无效")
}
content = append(content, map[string]interface{}{"type": "image", "source": map[string]interface{}{"type": "base64", "media_type": mimeType, "data": data}})
} else {
content = append(content, map[string]interface{}{"type": "image", "source": map[string]interface{}{"type": "url", "url": value}})
}
}
return content, nil
}
func splitDataURL(value string) (string, string, bool) {
if !strings.HasPrefix(value, "data:") {
return "", "", false
}
separator := strings.Index(value, ",")
if separator <= len("data:") {
return "", "", false
}
header := strings.TrimPrefix(value[:separator], "data:")
if !strings.HasSuffix(header, ";base64") {
return "", "", false
}
return strings.TrimSuffix(header, ";base64"), value[separator+1:], value[separator+1:] != ""
}
func textResponseInput(input canvasGenerationInput) (interface{}, error) {
systemPrompt := strings.TrimSpace(input.Config.SystemPrompt)
if len(input.TextHistory) == 0 && len(input.ReferenceImages) == 0 && len(input.ReferenceVideos) == 0 {
return withSystemPrompt(input.Config, input.Prompt), nil
}
messages := make([]map[string]interface{}, 0, len(input.TextHistory)+2)
if systemPrompt != "" {
messages = append(messages, map[string]interface{}{"role": "system", "content": systemPrompt})
}
messages = append(messages, validatedTextHistory(input.TextHistory)...)
content, err := textResponseContent(input)
if err != nil {
return nil, err
}
messages = append(messages, map[string]interface{}{"role": "user", "content": content})
return messages, nil
}
func validatedTextHistory(history []providerTextMessage) []map[string]interface{} {
result := make([]map[string]interface{}, 0, len(history))
for _, message := range history {
role := strings.ToLower(strings.TrimSpace(message.Role))
content := strings.TrimSpace(message.Content)
if (role != "user" && role != "assistant") || content == "" {
continue
}
result = append(result, map[string]interface{}{"role": role, "content": content})
}
return result
}
func textResponseContent(input canvasGenerationInput) ([]map[string]interface{}, error) {
content := []map[string]interface{}{{"type": "input_text", "text": input.Prompt}}
for _, image := range input.ReferenceImages {
url, err := openAIImageInputURL(image)
if err != nil {
return nil, err
}
content = append(content, map[string]interface{}{"type": "input_image", "image_url": url})
}
for _, video := range input.ReferenceVideos {
url, err := openAIVideoInputURL(video)
if err != nil {
return nil, err
}
content = append(content, map[string]interface{}{"type": "input_video", "video_url": url})
}
return content, nil
}
func textChatContent(input canvasGenerationInput) (interface{}, error) {
if len(input.ReferenceImages) == 0 && len(input.ReferenceVideos) == 0 {
return input.Prompt, nil
}
content := []map[string]interface{}{{"type": "text", "text": input.Prompt}}
for _, image := range input.ReferenceImages {
url, err := openAIImageInputURL(image)
if err != nil {
return nil, err
}
content = append(content, map[string]interface{}{"type": "image_url", "image_url": map[string]interface{}{"url": url}})
}
for _, video := range input.ReferenceVideos {
url, err := openAIVideoInputURL(video)
if err != nil {
return nil, err
}
content = append(content, map[string]interface{}{"type": "video_url", "video_url": map[string]interface{}{"url": url}})
}
return content, nil
}
func shouldFallbackTextToChat(err error) bool {
var httpErr providerHTTPError
if !errors.As(err, &httpErr) {
return false
}
// 只在上游明确说这个路径不存在/不允许时,才把 Responses 换成 Chat Completions。
// 502/503/504 是瞬时故障,换协议不会修好,还会把真正的上游故障伪装成“能力缺失后的第二次失败”。
switch httpErr.StatusCode {
case http.StatusNotFound, http.StatusMethodNotAllowed, http.StatusNotImplemented:
return true
default:
return false
}
}
func requestTextProvider(ctx context.Context, config providerConfig, path string, body map[string]interface{}, protocol string, stream bool, onDelta func(string)) (providerTextResult, error) {
if stream {
return postStreamingTextResult(ctx, config, path, body, protocol, onDelta)
}
var payload map[string]interface{}
if err := postJSON(ctx, config, path, body, &payload); err != nil {
return providerTextResult{}, err
}
parsed, err := parseAgentToolPayload(payload, protocol)
if err != nil {
return providerTextResult{}, err
}
result := providerTextResult{Text: stringField(parsed, "text"), Reasoning: stringField(parsed, "reasoning")}
if result.Text == "" {
return providerTextResult{}, errors.New("文本接口没有返回内容")
}
return result, nil
}
func postStreamingText(ctx context.Context, config providerConfig, path string, body map[string]interface{}, protocol string, onDelta func(string)) (string, error) {
result, err := postStreamingTextResult(ctx, config, path, body, protocol, onDelta)
return result.Text, err
}
func postStreamingTextResult(ctx context.Context, config providerConfig, path string, body map[string]interface{}, protocol string, onDelta func(string)) (providerTextResult, error) {
// 文本创作与 Agent 共用同一套 SSE 解析,确保正文、推理摘要和供应商错误语义一致。
parsed, err := postStreamingAgent(ctx, config, path, body, protocol, onDelta)
if err != nil {
return providerTextResult{}, err
}
result := providerTextResult{Text: stringField(parsed, "text"), Reasoning: stringField(parsed, "reasoning")}
if result.Text == "" {
return providerTextResult{}, errors.New("流式文本接口没有返回内容")
}
return result, nil
}
func extractTextPayload(payload map[string]interface{}, protocol string) string {
if protocol == "claude-api" {
content, _ := payload["content"].([]interface{})
var result strings.Builder
for _, item := range content {
record, _ := item.(map[string]interface{})
if stringField(record, "type") == "text" {
result.WriteString(stringField(record, "text"))
}
}
return result.String()
}
if protocol == "responses" {
text := stringField(payload, "output_text")
if text == "" {
text = extractResponseText(payload)
}
return text
}
return extractChatCompletionText(payload)
}
func validateTextPayload(payload map[string]interface{}) error {
if code, ok := payload["code"].(float64); ok && code != 0 {
rawMessage := defaultString(stringField(payload, "msg"), "请求失败")
return providerPayloadError{raw: rawMessage, message: providerPayloadErrorMessage(rawMessage)}
}
if errValue, ok := payload["error"].(map[string]interface{}); ok {
if message := stringField(errValue, "message"); message != "" {
return providerPayloadError{raw: message, message: providerPayloadErrorMessage(message)}
}
}
return nil
}
func parseTextEventStream(data []byte, protocol string) (string, error) {
scanner := bufio.NewScanner(bytes.NewReader(data))
scanner.Buffer(make([]byte, 64<<10), len(data)+1)
var text strings.Builder
var eventName string
var dataLines []string
flush := func() error {
if len(dataLines) == 0 {
eventName = ""
return nil
}
raw := strings.TrimSpace(strings.Join(dataLines, "\n"))
dataLines = nil
if raw == "" || raw == "[DONE]" {
eventName = ""
return nil
}
var payload map[string]interface{}
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return fmt.Errorf("流式文本事件解析失败:%w", err)
}
if eventName == "error" {
if err := validateTextPayload(payload); err != nil {
return err
}
return errors.New("上游流式文本请求失败")
}
if err := validateTextPayload(payload); err != nil {
return err
}
if protocol == "responses" {
text.WriteString(stringField(payload, "delta"))
} else if protocol == "claude-api" {
delta, _ := payload["delta"].(map[string]interface{})
if stringField(delta, "type") == "text_delta" {
text.WriteString(stringField(delta, "text"))
}
} else {
choices, _ := payload["choices"].([]interface{})
for _, choice := range choices {
record, _ := choice.(map[string]interface{})
delta, _ := record["delta"].(map[string]interface{})
text.WriteString(streamContentText(delta["content"]))
}
}
eventName = ""
return nil
}
for scanner.Scan() {
line := strings.TrimSuffix(scanner.Text(), "\r")
if line == "" {
if err := flush(); err != nil {
return "", err
}
continue
}
switch {
case strings.HasPrefix(line, "event:"):
eventName = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
case strings.HasPrefix(line, "data:"):
value := strings.TrimPrefix(line, "data:")
dataLines = append(dataLines, strings.TrimPrefix(value, " "))
}
}
if err := scanner.Err(); err != nil {
return "", fmt.Errorf("读取流式文本响应失败:%w", err)
}
if err := flush(); err != nil {
return "", err
}
if text.Len() == 0 {
return "", errors.New("流式文本接口没有返回内容")
}
return text.String(), nil
}
func streamContentText(value interface{}) string {
if text, ok := value.(string); ok {
return text
}
parts, ok := value.([]interface{})
if !ok {
return ""
}
var result strings.Builder
for _, part := range parts {
record, _ := part.(map[string]interface{})
result.WriteString(stringField(record, "text"))
}
return result.String()
}
type streamingTextDeltaParser struct {
protocol string
buffer string
emit func(string)
}
func newStreamingTextDeltaParser(protocol string, emit func(string)) *streamingTextDeltaParser {
return &streamingTextDeltaParser{protocol: protocol, emit: emit}
}
func (p *streamingTextDeltaParser) consume(mimeType string, chunk []byte) {
if p == nil || p.emit == nil || !strings.Contains(strings.ToLower(mimeType), "event-stream") || len(chunk) == 0 {
return
}
p.buffer += string(chunk)
p.consumeFrames(false)
}
func (p *streamingTextDeltaParser) flush() {
if p == nil || p.emit == nil {
return
}
p.consumeFrames(true)
}
func (p *streamingTextDeltaParser) consumeFrames(flush bool) {
for {
match := sseFrameBoundaryPattern.FindStringIndex(p.buffer)
if match == nil {
break
}
p.consumeFrame(p.buffer[:match[0]])
p.buffer = p.buffer[match[1]:]
}
if flush && strings.TrimSpace(p.buffer) != "" {
p.consumeFrame(p.buffer)
p.buffer = ""
}
}
func (p *streamingTextDeltaParser) consumeFrame(frame string) {
var eventName string
var dataLines []string
for _, line := range strings.Split(strings.ReplaceAll(frame, "\r\n", "\n"), "\n") {
switch {
case strings.HasPrefix(line, "event:"):
eventName = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
case strings.HasPrefix(line, "data:"):
dataLines = append(dataLines, strings.TrimSpace(strings.TrimPrefix(line, "data:")))
}
}
raw := strings.TrimSpace(strings.Join(dataLines, "\n"))
if raw == "" || raw == "[DONE]" {
return
}
var payload map[string]interface{}
if json.Unmarshal([]byte(raw), &payload) != nil {
return
}
if delta := streamingTextDelta(p.protocol, eventName, payload); delta != "" {
p.emit(delta)
}
}
func streamingTextDelta(protocol string, eventName string, payload map[string]interface{}) string {
if protocol == "responses" {
eventType := firstNonEmptyString(strings.TrimSpace(eventName), stringField(payload, "type"))
if eventType == "response.output_text.delta" || eventType == "output_text.delta" || eventType == "" || eventType == "message" {
return stringField(payload, "delta")
}
return ""
}
if protocol == "claude-api" {
delta, _ := payload["delta"].(map[string]interface{})
if stringField(delta, "type") == "text_delta" {
return stringField(delta, "text")
}
return ""
}
choices, _ := payload["choices"].([]interface{})
var text strings.Builder
for _, choice := range choices {
record, _ := choice.(map[string]interface{})
delta, _ := record["delta"].(map[string]interface{})
text.WriteString(streamContentText(delta["content"]))
}
return text.String()
}
func extractResponseText(payload map[string]interface{}) string {
output, ok := payload["output"].([]interface{})
if !ok {
return ""
}
var chunks []string
for _, item := range output {
record, ok := item.(map[string]interface{})
if !ok || record["type"] != "message" {
continue
}
content, _ := record["content"].([]interface{})
for _, part := range content {
partRecord, ok := part.(map[string]interface{})
if ok && stringField(partRecord, "text") != "" {
chunks = append(chunks, stringField(partRecord, "text"))
}
}
}
return strings.Join(chunks, "")
}
func extractChatCompletionText(payload map[string]interface{}) string {
if data, ok := payload["data"].(map[string]interface{}); ok {
payload = data
}
choices, ok := payload["choices"].([]interface{})
if !ok {
return ""
}
var chunks []string
for _, choice := range choices {
record, ok := choice.(map[string]interface{})
if !ok {
continue
}
if message, ok := record["message"].(map[string]interface{}); ok {
if text := stringField(message, "content"); text != "" {
chunks = append(chunks, text)
}
}
if text := stringField(record, "text"); text != "" {
chunks = append(chunks, text)
}
}
return strings.Join(chunks, "")
}