diff --git a/dto/claude.go b/dto/claude.go index d7fed412aaa9..da5b0fa2c873 100644 --- a/dto/claude.go +++ b/dto/claude.go @@ -34,6 +34,8 @@ type ClaudeMediaMessage struct { Input any `json:"input,omitempty"` Content any `json:"content,omitempty"` ToolUseId string `json:"tool_use_id,omitempty"` + // redacted_thinking + Data string `json:"data,omitempty"` } func (c *ClaudeMediaMessage) SetText(s string) { diff --git a/dto/openai_response.go b/dto/openai_response.go index 0e6b818dbd8b..012fc5d79219 100644 --- a/dto/openai_response.go +++ b/dto/openai_response.go @@ -334,20 +334,23 @@ func (o *OpenAIResponsesResponse) GetSize() string { } type IncompleteDetails struct { - Reasoning string `json:"reasoning"` + Reason string `json:"reason,omitempty"` } type ResponsesOutput struct { - Type string `json:"type"` - ID string `json:"id"` - Status string `json:"status"` - Role string `json:"role"` - Content []ResponsesOutputContent `json:"content"` - Quality string `json:"quality"` - Size string `json:"size"` - CallId string `json:"call_id,omitempty"` - Name string `json:"name,omitempty"` - Arguments json.RawMessage `json:"arguments,omitempty"` + Type string `json:"type"` + ID string `json:"id"` + Status string `json:"status,omitempty"` + Role string `json:"role,omitempty"` + Content []ResponsesOutputContent `json:"content,omitempty"` + Quality string `json:"quality,omitempty"` + Size string `json:"size,omitempty"` + CallId string `json:"call_id,omitempty"` + Name string `json:"name,omitempty"` + Arguments json.RawMessage `json:"arguments,omitempty"` + Input string `json:"input,omitempty"` + Summary []ResponsesReasoningSummaryPart `json:"summary,omitempty"` + EncryptedContent string `json:"encrypted_content,omitempty"` } // ArgumentsString returns function call arguments in the string form expected by Chat Completions. @@ -388,19 +391,22 @@ const ( ResponsesOutputTypeItemDone = "response.output_item.done" ) -// ResponsesStreamResponse 用于处理 /v1/responses 流式响应 type ResponsesStreamResponse struct { - Type string `json:"type"` - Response *OpenAIResponsesResponse `json:"response,omitempty"` - Delta string `json:"delta,omitempty"` - Item *ResponsesOutput `json:"item,omitempty"` - // - response.function_call_arguments.delta - // - response.function_call_arguments.done - OutputIndex *int `json:"output_index,omitempty"` - ContentIndex *int `json:"content_index,omitempty"` - SummaryIndex *int `json:"summary_index,omitempty"` - ItemID string `json:"item_id,omitempty"` - Part *ResponsesReasoningSummaryPart `json:"part,omitempty"` + Type string `json:"type"` + Response *OpenAIResponsesResponse `json:"response,omitempty"` + Delta string `json:"delta,omitempty"` + Item *ResponsesOutput `json:"item,omitempty"` + OutputIndex *int `json:"output_index,omitempty"` + ContentIndex *int `json:"content_index,omitempty"` + SummaryIndex *int `json:"summary_index,omitempty"` + AnnotationIndex *int `json:"annotation_index,omitempty"` + ItemID string `json:"item_id,omitempty"` + Part any `json:"part,omitempty"` + Text string `json:"text,omitempty"` + Arguments string `json:"arguments,omitempty"` + Input string `json:"input,omitempty"` + Annotation any `json:"annotation,omitempty"` + SequenceNumber int `json:"sequence_number"` } // GetOpenAIError 从动态错误类型中提取OpenAIError结构 diff --git a/relay/channel/claude/adaptor.go b/relay/channel/claude/adaptor.go index 6daf5b6f245e..757de7dc792e 100644 --- a/relay/channel/claude/adaptor.go +++ b/relay/channel/claude/adaptor.go @@ -108,8 +108,13 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela } func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { - // TODO implement me - return nil, errors.New("not implemented") + claudeReq, customToolNames, err := ConvertResponsesRequestToClaude(&request) + if err != nil { + return nil, err + } + // 无条件覆盖:retry 或 pass-through 路径下,前一次设置的 customToolNames 不应残留污染响应分支。 + c.Set(customToolNamesContextKey, customToolNames) + return claudeReq, nil } func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { diff --git a/relay/channel/claude/claude_to_responses.go b/relay/channel/claude/claude_to_responses.go new file mode 100644 index 000000000000..b44ec315b9b8 --- /dev/null +++ b/relay/channel/claude/claude_to_responses.go @@ -0,0 +1,772 @@ +package claude + +import ( + "encoding/json" + "strconv" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" +) + +const ( + respEventCreated = "response.created" + respEventInProgress = "response.in_progress" + respEventCompleted = "response.completed" + respEventFailed = "response.failed" + respEventIncomplete = "response.incomplete" + respEventOutputItemAdded = "response.output_item.added" + respEventOutputItemDone = "response.output_item.done" + respEventContentPartAdded = "response.content_part.added" + respEventContentPartDone = "response.content_part.done" + respEventOutputTextDelta = "response.output_text.delta" + respEventOutputTextDone = "response.output_text.done" + respEventOutputTextAnnotationAdded = "response.output_text.annotation.added" + respEventReasoningSummaryPartAdded = "response.reasoning_summary_part.added" + respEventReasoningSummaryPartDone = "response.reasoning_summary_part.done" + respEventReasoningSummaryTextDelta = "response.reasoning_summary_text.delta" + respEventReasoningSummaryTextDone = "response.reasoning_summary_text.done" + respEventFnCallArgsDelta = "response.function_call_arguments.delta" + respEventFnCallArgsDone = "response.function_call_arguments.done" + respEventCustomToolInputDelta = "response.custom_tool_call_input.delta" + respEventCustomToolInputDone = "response.custom_tool_call_input.done" +) + +type responsesBlockKind int + +const ( + blockUnknown responsesBlockKind = iota + blockText + blockThinking + blockRedactedThinking + blockToolUse + blockCustomToolCall +) + +const customToolNamesContextKey = "claude_responses_custom_tool_names" + +type responsesOutputItem struct { + kind responsesBlockKind + outputIndex int + itemID string + role string + text strings.Builder + thinking strings.Builder + signature strings.Builder + toolCallID string + toolName string + toolArgs strings.Builder + annotations []any + redactedData string + customInput string + customStreamer *customInputStreamer + emittedSummary bool + emittedContent bool + emittedItemAdded bool +} + +// Claude content_block 的索引与 Responses output_index 不是 1:1 — 按收到顺序自增 outputIndex。 +type ClaudeResponsesStreamState struct { + ResponseID string + Model string + CreatedAt int64 + StopReason string + Usage *dto.ClaudeUsage + Outputs []*responsesOutputItem + CustomToolNames map[string]bool + blockToOutput map[int]*responsesOutputItem + nextOutputIdx int + seq int + createdEmitted bool +} + +func NewClaudeResponsesStreamState(model string) *ClaudeResponsesStreamState { + return &ClaudeResponsesStreamState{ + Model: model, + blockToOutput: make(map[int]*responsesOutputItem), + } +} + +func (s *ClaudeResponsesStreamState) nextSeq() int { + n := s.seq + s.seq++ + return n +} + +func (s *ClaudeResponsesStreamState) Snapshot(status string) *dto.OpenAIResponsesResponse { + resp := &dto.OpenAIResponsesResponse{ + ID: s.ResponseID, + Object: "response", + CreatedAt: int(s.CreatedAt), + Model: s.Model, + Output: make([]dto.ResponsesOutput, 0, len(s.Outputs)), + } + if status != "" { + raw, _ := common.Marshal(status) + resp.Status = raw + } + for _, it := range s.Outputs { + resp.Output = append(resp.Output, it.toOutput()) + } + if s.Usage != nil { + resp.Usage = buildResponsesUsage(s.Usage) + } + return resp +} + +func (it *responsesOutputItem) toOutput() dto.ResponsesOutput { + switch it.kind { + case blockText: + return dto.ResponsesOutput{ + Type: "message", + ID: it.itemID, + Status: "completed", + Role: "assistant", + Content: []dto.ResponsesOutputContent{{ + Type: "output_text", + Text: it.text.String(), + Annotations: it.annotations, + }}, + } + case blockThinking: + out := dto.ResponsesOutput{ + Type: "reasoning", + ID: it.itemID, + Status: "completed", + Summary: []dto.ResponsesReasoningSummaryPart{}, + EncryptedContent: EncodeThinkingSignature(it.signature.String()), + } + text := it.thinking.String() + if text != "" { + out.Summary = []dto.ResponsesReasoningSummaryPart{{Type: "summary_text", Text: text}} + } + return out + case blockRedactedThinking: + return dto.ResponsesOutput{ + Type: "reasoning", + ID: it.itemID, + Status: "completed", + Summary: []dto.ResponsesReasoningSummaryPart{}, + EncryptedContent: EncodeRedactedThinking(it.redactedData), + } + case blockToolUse: + args := it.toolArgs.String() + if args == "" { + args = "{}" + } + return dto.ResponsesOutput{ + Type: "function_call", + ID: it.itemID, + Status: "completed", + CallId: it.toolCallID, + Name: it.toolName, + Arguments: argumentsAsJSONString(args), + } + case blockCustomToolCall: + return dto.ResponsesOutput{ + Type: "custom_tool_call", + ID: it.itemID, + Status: "completed", + CallId: it.toolCallID, + Name: it.toolName, + Input: it.customInput, + } + } + return dto.ResponsesOutput{Type: "unknown", ID: it.itemID} +} + +func buildResponsesUsage(u *dto.ClaudeUsage) *dto.Usage { + if u == nil { + return nil + } + usage := &dto.Usage{ + UsageSemantic: "openai", + InputTokens: u.InputTokens, + OutputTokens: u.OutputTokens, + PromptTokens: u.InputTokens, + CompletionTokens: u.OutputTokens, + InputTokensDetails: &dto.InputTokenDetails{ + CachedTokens: u.CacheReadInputTokens, + CachedCreationTokens: u.CacheCreationInputTokens, + }, + } + usage.TotalTokens = usage.InputTokens + usage.OutputTokens + return usage +} + +func (s *ClaudeResponsesStreamState) HandleClaudeChunk(chunk *dto.ClaudeResponse) []dto.ResponsesStreamResponse { + if chunk == nil { + return nil + } + events := make([]dto.ResponsesStreamResponse, 0, 4) + switch chunk.Type { + case "message_start": + if chunk.Message != nil { + if chunk.Message.Id != "" { + s.ResponseID = chunk.Message.Id + } + if chunk.Message.Model != "" { + s.Model = chunk.Message.Model + } + if chunk.Message.Usage != nil { + s.Usage = chunk.Message.Usage + } + } + events = append(events, s.emitCreated()) + events = append(events, s.emitInProgress()) + case "content_block_start": + events = append(events, s.handleBlockStart(chunk)...) + case "content_block_delta": + events = append(events, s.handleBlockDelta(chunk)...) + case "content_block_stop": + events = append(events, s.handleBlockStop(chunk)...) + case "message_delta": + if chunk.Delta != nil && chunk.Delta.StopReason != nil { + s.StopReason = *chunk.Delta.StopReason + } + if chunk.Usage != nil { + s.mergeUsage(chunk.Usage) + } + case "message_stop": + // 最终完成事件由 caller(FinalEvents)发出,保证 message_stop 之后才补 usage + } + return events +} + +func (s *ClaudeResponsesStreamState) FinalEvents() []dto.ResponsesStreamResponse { + status := "completed" + switch s.StopReason { + case "max_tokens": + status = "incomplete" + case "refusal": + status = "completed" + } + resp := s.Snapshot(status) + evtType := respEventCompleted + if status == "incomplete" { + evtType = respEventIncomplete + resp.IncompleteDetails = &dto.IncompleteDetails{Reason: "max_output_tokens"} + } + return []dto.ResponsesStreamResponse{{ + Type: evtType, + Response: resp, + SequenceNumber: s.nextSeq(), + }} +} + +func (s *ClaudeResponsesStreamState) FailedEvent(errMsg string) dto.ResponsesStreamResponse { + resp := s.Snapshot("failed") + resp.Error = map[string]string{ + "code": "upstream_error", + "message": errMsg, + } + return dto.ResponsesStreamResponse{ + Type: respEventFailed, + Response: resp, + SequenceNumber: s.nextSeq(), + } +} + +func (s *ClaudeResponsesStreamState) mergeUsage(u *dto.ClaudeUsage) { + if s.Usage == nil { + s.Usage = &dto.ClaudeUsage{} + } + if u.InputTokens > 0 { + s.Usage.InputTokens = u.InputTokens + } + if u.CacheReadInputTokens > 0 { + s.Usage.CacheReadInputTokens = u.CacheReadInputTokens + } + if u.CacheCreationInputTokens > 0 { + s.Usage.CacheCreationInputTokens = u.CacheCreationInputTokens + } + if u.OutputTokens > 0 { + s.Usage.OutputTokens = u.OutputTokens + } +} + +func (s *ClaudeResponsesStreamState) emitCreated() dto.ResponsesStreamResponse { + s.createdEmitted = true + return dto.ResponsesStreamResponse{ + Type: respEventCreated, + Response: s.Snapshot("in_progress"), + SequenceNumber: s.nextSeq(), + } +} + +func (s *ClaudeResponsesStreamState) emitInProgress() dto.ResponsesStreamResponse { + return dto.ResponsesStreamResponse{ + Type: respEventInProgress, + Response: s.Snapshot("in_progress"), + SequenceNumber: s.nextSeq(), + } +} + +func (s *ClaudeResponsesStreamState) handleBlockStart(chunk *dto.ClaudeResponse) []dto.ResponsesStreamResponse { + if chunk.ContentBlock == nil { + return nil + } + blockIdx := chunk.GetIndex() + + kind := classifyClaudeBlock(chunk.ContentBlock.Type) + if kind == blockUnknown { + return nil + } + if kind == blockToolUse && s.CustomToolNames[chunk.ContentBlock.Name] { + kind = blockCustomToolCall + } + + it := &responsesOutputItem{ + kind: kind, + outputIndex: s.nextOutputIdx, + } + s.nextOutputIdx++ + + events := make([]dto.ResponsesStreamResponse, 0, 2) + switch kind { + case blockText: + it.itemID = "msg_" + s.ResponseID + "_" + strconv.Itoa(it.outputIndex) + it.role = "assistant" + events = append(events, s.emitOutputItemAdded(it)) + events = append(events, s.emitContentPartAdded(it)) + it.emittedContent = true + case blockThinking: + it.itemID = "rs_" + s.ResponseID + "_" + strconv.Itoa(it.outputIndex) + if chunk.ContentBlock.Thinking != nil { + it.thinking.WriteString(*chunk.ContentBlock.Thinking) + } + if chunk.ContentBlock.Signature != "" { + it.signature.WriteString(chunk.ContentBlock.Signature) + } + events = append(events, s.emitOutputItemAdded(it)) + events = append(events, s.emitSummaryPartAdded(it)) + it.emittedSummary = true + case blockRedactedThinking: + it.itemID = "rs_redacted_" + s.ResponseID + "_" + strconv.Itoa(it.outputIndex) + it.redactedData = chunk.ContentBlock.Data + events = append(events, s.emitOutputItemAdded(it)) + case blockToolUse: + it.toolCallID = chunk.ContentBlock.Id + it.toolName = chunk.ContentBlock.Name + it.itemID = "fc_" + it.toolCallID + events = append(events, s.emitOutputItemAdded(it)) + case blockCustomToolCall: + it.toolCallID = chunk.ContentBlock.Id + it.toolName = chunk.ContentBlock.Name + it.itemID = "ctc_" + it.toolCallID + it.customStreamer = newCustomInputStreamer() + events = append(events, s.emitOutputItemAdded(it)) + } + s.blockToOutput[blockIdx] = it + s.Outputs = append(s.Outputs, it) + it.emittedItemAdded = true + return events +} + +// Claude 的 server_tool_use / web_search_tool_result / code_execution_tool_result 等 server-side 块 +// 在 Responses API 里没有 1:1 对应类型,目前直接丢弃以避免产出 type:"unknown" 污染 output; +// 等后续单独映射成 web_search_call/code_interpreter_call 时再扩展这个分类器。 +func classifyClaudeBlock(blockType string) responsesBlockKind { + switch blockType { + case "text": + return blockText + case "thinking": + return blockThinking + case "redacted_thinking": + return blockRedactedThinking + case "tool_use": + return blockToolUse + } + return blockUnknown +} + +func (s *ClaudeResponsesStreamState) handleBlockDelta(chunk *dto.ClaudeResponse) []dto.ResponsesStreamResponse { + if chunk.Delta == nil { + return nil + } + blockIdx := chunk.GetIndex() + it, ok := s.blockToOutput[blockIdx] + if !ok { + return nil + } + events := make([]dto.ResponsesStreamResponse, 0, 1) + switch chunk.Delta.Type { + case "text_delta": + if chunk.Delta.Text != nil && *chunk.Delta.Text != "" { + it.text.WriteString(*chunk.Delta.Text) + events = append(events, dto.ResponsesStreamResponse{ + Type: respEventOutputTextDelta, + ItemID: it.itemID, + OutputIndex: intPtr(it.outputIndex), + ContentIndex: intPtr(0), + Delta: *chunk.Delta.Text, + SequenceNumber: s.nextSeq(), + }) + } + case "thinking_delta": + if chunk.Delta.Thinking != nil && *chunk.Delta.Thinking != "" { + it.thinking.WriteString(*chunk.Delta.Thinking) + events = append(events, dto.ResponsesStreamResponse{ + Type: respEventReasoningSummaryTextDelta, + ItemID: it.itemID, + OutputIndex: intPtr(it.outputIndex), + SummaryIndex: intPtr(0), + Delta: *chunk.Delta.Thinking, + SequenceNumber: s.nextSeq(), + }) + } + case "signature_delta": + if chunk.Delta.Signature != "" { + it.signature.WriteString(chunk.Delta.Signature) + } + case "input_json_delta": + if chunk.Delta.PartialJson != nil && *chunk.Delta.PartialJson != "" { + it.toolArgs.WriteString(*chunk.Delta.PartialJson) + if it.kind == blockCustomToolCall { + if it.customStreamer == nil { + it.customStreamer = newCustomInputStreamer() + } + delta := it.customStreamer.Feed(*chunk.Delta.PartialJson) + if delta != "" { + events = append(events, dto.ResponsesStreamResponse{ + Type: respEventCustomToolInputDelta, + ItemID: it.itemID, + OutputIndex: intPtr(it.outputIndex), + Delta: delta, + SequenceNumber: s.nextSeq(), + }) + } + break + } + events = append(events, dto.ResponsesStreamResponse{ + Type: respEventFnCallArgsDelta, + ItemID: it.itemID, + OutputIndex: intPtr(it.outputIndex), + Delta: *chunk.Delta.PartialJson, + SequenceNumber: s.nextSeq(), + }) + } + case "citations_delta": + events = append(events, s.handleCitationDelta(it, chunk.Delta)) + } + return events +} + +func (s *ClaudeResponsesStreamState) handleCitationDelta(it *responsesOutputItem, delta *dto.ClaudeMediaMessage) dto.ResponsesStreamResponse { + idx := len(it.annotations) + it.annotations = append(it.annotations, delta) + return dto.ResponsesStreamResponse{ + Type: respEventOutputTextAnnotationAdded, + ItemID: it.itemID, + OutputIndex: intPtr(it.outputIndex), + ContentIndex: intPtr(0), + AnnotationIndex: intPtr(idx), + Annotation: delta, + SequenceNumber: s.nextSeq(), + } +} + +func (s *ClaudeResponsesStreamState) handleBlockStop(chunk *dto.ClaudeResponse) []dto.ResponsesStreamResponse { + blockIdx := chunk.GetIndex() + it, ok := s.blockToOutput[blockIdx] + if !ok { + return nil + } + events := make([]dto.ResponsesStreamResponse, 0, 4) + switch it.kind { + case blockText: + text := it.text.String() + events = append(events, dto.ResponsesStreamResponse{ + Type: respEventOutputTextDone, + ItemID: it.itemID, + OutputIndex: intPtr(it.outputIndex), + ContentIndex: intPtr(0), + Text: text, + SequenceNumber: s.nextSeq(), + }) + events = append(events, dto.ResponsesStreamResponse{ + Type: respEventContentPartDone, + ItemID: it.itemID, + OutputIndex: intPtr(it.outputIndex), + ContentIndex: intPtr(0), + Part: map[string]any{ + "type": "output_text", + "text": text, + "annotations": it.annotations, + }, + SequenceNumber: s.nextSeq(), + }) + case blockThinking: + text := it.thinking.String() + events = append(events, dto.ResponsesStreamResponse{ + Type: respEventReasoningSummaryTextDone, + ItemID: it.itemID, + OutputIndex: intPtr(it.outputIndex), + SummaryIndex: intPtr(0), + Text: text, + SequenceNumber: s.nextSeq(), + }) + events = append(events, dto.ResponsesStreamResponse{ + Type: respEventReasoningSummaryPartDone, + ItemID: it.itemID, + OutputIndex: intPtr(it.outputIndex), + SummaryIndex: intPtr(0), + Part: map[string]any{ + "type": "summary_text", + "text": text, + }, + SequenceNumber: s.nextSeq(), + }) + case blockToolUse: + args := it.toolArgs.String() + if args == "" { + args = "{}" + } + events = append(events, dto.ResponsesStreamResponse{ + Type: respEventFnCallArgsDone, + ItemID: it.itemID, + OutputIndex: intPtr(it.outputIndex), + Arguments: args, + SequenceNumber: s.nextSeq(), + }) + case blockCustomToolCall: + // streamer Parsed=true 用解析结果;否则用完整 raw JSON 兜底(含 input 缺失、非字符串、嵌套等异常)。 + // 失败兜底比"返回空字符串"安全:宁可让客户端拿到原始 JSON 也不能丢 Codex 等关键内容。 + if it.customStreamer != nil && it.customStreamer.Parsed() { + it.customInput = it.customStreamer.FinalInput() + } else { + it.customInput = extractCustomToolInput(it.toolArgs.String()) + if streamed := it.customStreamer.FinalInput(); it.customInput != streamed && it.customInput != "" { + if remainder, ok := strings.CutPrefix(it.customInput, streamed); ok && remainder != "" { + events = append(events, dto.ResponsesStreamResponse{ + Type: respEventCustomToolInputDelta, + ItemID: it.itemID, + OutputIndex: intPtr(it.outputIndex), + Delta: remainder, + SequenceNumber: s.nextSeq(), + }) + } else { + events = append(events, dto.ResponsesStreamResponse{ + Type: respEventCustomToolInputDelta, + ItemID: it.itemID, + OutputIndex: intPtr(it.outputIndex), + Delta: it.customInput, + SequenceNumber: s.nextSeq(), + }) + } + } + } + events = append(events, dto.ResponsesStreamResponse{ + Type: respEventCustomToolInputDone, + ItemID: it.itemID, + OutputIndex: intPtr(it.outputIndex), + Input: it.customInput, + SequenceNumber: s.nextSeq(), + }) + } + if it.kind != blockUnknown { + item := it.toOutput() + events = append(events, dto.ResponsesStreamResponse{ + Type: respEventOutputItemDone, + OutputIndex: intPtr(it.outputIndex), + Item: &item, + SequenceNumber: s.nextSeq(), + }) + } + return events +} + +func (s *ClaudeResponsesStreamState) emitOutputItemAdded(it *responsesOutputItem) dto.ResponsesStreamResponse { + item := it.toOutput() + item.Status = "in_progress" + if it.kind == blockText { + item.Content = []dto.ResponsesOutputContent{} + } + if it.kind == blockToolUse { + item.Arguments = nil + } + return dto.ResponsesStreamResponse{ + Type: respEventOutputItemAdded, + OutputIndex: intPtr(it.outputIndex), + Item: &item, + SequenceNumber: s.nextSeq(), + } +} + +func (s *ClaudeResponsesStreamState) emitContentPartAdded(it *responsesOutputItem) dto.ResponsesStreamResponse { + return dto.ResponsesStreamResponse{ + Type: respEventContentPartAdded, + ItemID: it.itemID, + OutputIndex: intPtr(it.outputIndex), + ContentIndex: intPtr(0), + Part: map[string]any{ + "type": "output_text", + "text": "", + "annotations": []any{}, + }, + SequenceNumber: s.nextSeq(), + } +} + +func (s *ClaudeResponsesStreamState) emitSummaryPartAdded(it *responsesOutputItem) dto.ResponsesStreamResponse { + return dto.ResponsesStreamResponse{ + Type: respEventReasoningSummaryPartAdded, + ItemID: it.itemID, + OutputIndex: intPtr(it.outputIndex), + SummaryIndex: intPtr(0), + Part: map[string]any{ + "type": "summary_text", + "text": "", + }, + SequenceNumber: s.nextSeq(), + } +} + +func ConvertClaudeResponseToResponses(claudeResp *dto.ClaudeResponse, customToolNames map[string]bool) *dto.OpenAIResponsesResponse { + if claudeResp == nil { + return nil + } + resp := &dto.OpenAIResponsesResponse{ + ID: claudeResp.Id, + Object: "response", + CreatedAt: 0, + Model: claudeResp.Model, + Output: make([]dto.ResponsesOutput, 0, len(claudeResp.Content)), + } + status := mapClaudeStopReasonToResponsesStatus(claudeResp.StopReason) + statusRaw, _ := common.Marshal(status) + resp.Status = statusRaw + if status == "incomplete" { + resp.IncompleteDetails = &dto.IncompleteDetails{Reason: "max_output_tokens"} + } + + idx := 0 + for _, block := range claudeResp.Content { + switch block.Type { + case "text": + resp.Output = append(resp.Output, dto.ResponsesOutput{ + Type: "message", + ID: "msg_" + claudeResp.Id + "_" + strconv.Itoa(idx), + Status: "completed", + Role: "assistant", + Content: []dto.ResponsesOutputContent{{ + Type: "output_text", + Text: block.GetText(), + Annotations: []any{}, + }}, + }) + case "thinking": + out := dto.ResponsesOutput{ + Type: "reasoning", + ID: "rs_" + claudeResp.Id + "_" + strconv.Itoa(idx), + Status: "completed", + Summary: []dto.ResponsesReasoningSummaryPart{}, + } + if block.Thinking != nil && *block.Thinking != "" { + out.Summary = []dto.ResponsesReasoningSummaryPart{{ + Type: "summary_text", + Text: *block.Thinking, + }} + } + if block.Signature != "" { + out.EncryptedContent = EncodeThinkingSignature(block.Signature) + } + resp.Output = append(resp.Output, out) + case "redacted_thinking": + resp.Output = append(resp.Output, dto.ResponsesOutput{ + Type: "reasoning", + ID: "rs_redacted_" + claudeResp.Id + "_" + strconv.Itoa(idx), + Status: "completed", + Summary: []dto.ResponsesReasoningSummaryPart{}, + EncryptedContent: EncodeRedactedThinking(block.Data), + }) + case "tool_use": + if customToolNames[block.Name] { + inputStr := "" + if raw, marshalErr := common.Marshal(block.Input); marshalErr == nil { + inputStr = extractCustomToolInput(string(raw)) + } + resp.Output = append(resp.Output, dto.ResponsesOutput{ + Type: "custom_tool_call", + ID: "ctc_" + block.Id, + Status: "completed", + CallId: block.Id, + Name: block.Name, + Input: inputStr, + }) + break + } + args, marshalErr := common.Marshal(block.Input) + if marshalErr != nil || len(args) == 0 { + args = []byte("{}") + } + resp.Output = append(resp.Output, dto.ResponsesOutput{ + Type: "function_call", + ID: "fc_" + block.Id, + Status: "completed", + CallId: block.Id, + Name: block.Name, + Arguments: argumentsAsJSONString(string(args)), + }) + } + idx++ + } + + if claudeResp.Usage != nil { + resp.Usage = buildResponsesUsage(claudeResp.Usage) + } + return resp +} + +func mapClaudeStopReasonToResponsesStatus(reason string) string { + switch reason { + case "max_tokens": + return "incomplete" + case "refusal": + return "completed" + case "": + return "completed" + } + return "completed" +} + +func intPtr(i int) *int { return &i } + +// 把 Claude 累积的 tool_use input JSON 抽成 custom_tool_call 的 raw string。 +// 入口侧把 custom tool 降级成 function tool with {input: string} schema,所以模型生成的 +// tool_use.input 应该是 {"input":""};解析失败则回退用整段 JSON 当 raw input 避免丢失。 +// extractCustomToolInput 区分 input 字段的三种状态: +// - 存在且是 string → 返回该 string(包括空串) +// - 缺失或非 string → 返回整段 raw JSON,避免模型不按 schema 输出时丢内容 +func extractCustomToolInput(rawJSON string) string { + if rawJSON == "" { + return "" + } + var probe map[string]json.RawMessage + if err := common.UnmarshalJsonStr(rawJSON, &probe); err != nil { + return rawJSON + } + raw, ok := probe["input"] + if !ok { + return rawJSON + } + var s string + if err := common.Unmarshal(raw, &s); err == nil { + return s + } + return rawJSON +} + +// OpenAI Responses 协议规定 function_call.arguments 在 wire 上是 string(客户端用 JSON.parse 解析), +// 而我们底层 raw 是 JSON object/array 字面字节。这里把它再 quote 一层, +// 使 json.RawMessage 序列化时输出 "{\"cmd\":...}" 而不是 {"cmd":...}。 +func argumentsAsJSONString(rawJSON string) json.RawMessage { + if rawJSON == "" { + rawJSON = "{}" + } + quoted, err := common.Marshal(rawJSON) + if err != nil { + return json.RawMessage(`"{}"`) + } + return json.RawMessage(quoted) +} diff --git a/relay/channel/claude/custom_input_streamer.go b/relay/channel/claude/custom_input_streamer.go new file mode 100644 index 000000000000..fe710f94a9a6 --- /dev/null +++ b/relay/channel/claude/custom_input_streamer.go @@ -0,0 +1,278 @@ +package claude + +import ( + "strings" + "unicode/utf16" + "unicode/utf8" +) + +type customInputState int + +const ( + customStateInitial customInputState = iota + customStateInObject + customStateInKey + customStateAfterKey + customStateBeforeValue + customStateInTargetValue + customStateInTargetValueEscape + customStateInTargetValueUnicode + customStateInIgnoredValue + customStateInIgnoredString + customStateInIgnoredStringEscape + customStateDone + customStateFailed +) + +// customInputStreamer 针对 {"input":""} schema 做字节级增量解析, +// 每次 Feed 返回新解析出的 raw string 字符,用于发 custom_tool_call_input.delta 流事件。 +// 仅处理我们自己定义的 schema(custom tool 降级为单 input 字段),不通用 JSON 解析; +// 但状态机是 token-aware 的:能跳过 string/object/array value,避免 value 内的 "input" 误触发。 +type customInputStreamer struct { + state customInputState + pending strings.Builder + finishedRaw string + parsed bool + currentKey strings.Builder + currentKeyIsInput bool + ignoredNesting int + unicodeHex []byte + pendingHighSurrogate rune + scanned int + maxScan int + maxInputBytes int +} + +const ( + defaultCustomMaxScanBytes = 4 * 1024 * 1024 + defaultCustomMaxInputBytes = 1 * 1024 * 1024 +) + +func newCustomInputStreamer() *customInputStreamer { + return &customInputStreamer{ + state: customStateInitial, + maxScan: defaultCustomMaxScanBytes, + maxInputBytes: defaultCustomMaxInputBytes, + } +} + +// Feed 增量喂入 partial_json,返回新解析出的 input raw string 字符。 +// Parsed/Failed 反映当前状态机进度,调用方根据其决定是否走 fallback。 +func (p *customInputStreamer) Feed(chunk string) string { + if chunk == "" || p.state == customStateDone || p.state == customStateFailed { + return "" + } + var out strings.Builder + for i := 0; i < len(chunk); i++ { + b := chunk[i] + p.scanned++ + if p.scanned > p.maxScan { + p.state = customStateFailed + return out.String() + } + if p.pending.Len() > p.maxInputBytes { + p.state = customStateFailed + return out.String() + } + p.step(b, &out) + if p.state == customStateDone || p.state == customStateFailed { + break + } + } + return out.String() +} + +func (p *customInputStreamer) step(b byte, out *strings.Builder) { + switch p.state { + case customStateInitial: + switch { + case isJSONWhitespace(b): + case b == '{': + p.state = customStateInObject + default: + p.state = customStateFailed + } + case customStateInObject: + switch { + case isJSONWhitespace(b) || b == ',': + case b == '}': + p.state = customStateDone + case b == '"': + p.currentKey.Reset() + p.state = customStateInKey + default: + p.state = customStateFailed + } + case customStateInKey: + if b == '"' { + p.currentKeyIsInput = p.currentKey.String() == "input" + p.state = customStateAfterKey + } else { + p.currentKey.WriteByte(b) + } + case customStateAfterKey: + switch { + case isJSONWhitespace(b): + case b == ':': + p.state = customStateBeforeValue + default: + p.state = customStateFailed + } + case customStateBeforeValue: + switch { + case isJSONWhitespace(b): + case b == '"': + if p.currentKeyIsInput { + p.state = customStateInTargetValue + } else { + p.state = customStateInIgnoredString + } + case b == '{' || b == '[': + if p.currentKeyIsInput { + p.state = customStateFailed + return + } + p.ignoredNesting = 1 + p.state = customStateInIgnoredValue + default: + if p.currentKeyIsInput { + p.state = customStateFailed + return + } + p.state = customStateInIgnoredValue + } + case customStateInTargetValue: + switch b { + case '"': + p.finishedRaw = p.pending.String() + p.parsed = true + p.state = customStateDone + case '\\': + p.state = customStateInTargetValueEscape + default: + out.WriteByte(b) + p.pending.WriteByte(b) + } + case customStateInTargetValueEscape: + switch b { + case 'u': + p.state = customStateInTargetValueUnicode + p.unicodeHex = p.unicodeHex[:0] + case '"', '\\', '/': + out.WriteByte(b) + p.pending.WriteByte(b) + p.state = customStateInTargetValue + case 'b': + p.writeRune('\b', out) + p.state = customStateInTargetValue + case 'f': + p.writeRune('\f', out) + p.state = customStateInTargetValue + case 'n': + p.writeRune('\n', out) + p.state = customStateInTargetValue + case 'r': + p.writeRune('\r', out) + p.state = customStateInTargetValue + case 't': + p.writeRune('\t', out) + p.state = customStateInTargetValue + default: + out.WriteByte(b) + p.pending.WriteByte(b) + p.state = customStateInTargetValue + } + case customStateInTargetValueUnicode: + p.unicodeHex = append(p.unicodeHex, b) + if len(p.unicodeHex) == 4 { + r := decodeHexQuad(p.unicodeHex) + p.unicodeHex = p.unicodeHex[:0] + if utf16.IsSurrogate(r) { + if p.pendingHighSurrogate == 0 && r >= 0xD800 && r <= 0xDBFF { + p.pendingHighSurrogate = r + } else if p.pendingHighSurrogate != 0 && r >= 0xDC00 && r <= 0xDFFF { + combined := utf16.DecodeRune(p.pendingHighSurrogate, r) + p.pendingHighSurrogate = 0 + p.writeRune(combined, out) + } else { + p.pendingHighSurrogate = 0 + p.writeRune(utf8.RuneError, out) + } + } else { + if p.pendingHighSurrogate != 0 { + p.writeRune(utf8.RuneError, out) + p.pendingHighSurrogate = 0 + } + p.writeRune(r, out) + } + p.state = customStateInTargetValue + } + case customStateInIgnoredString: + switch b { + case '"': + if p.ignoredNesting == 0 { + p.state = customStateInObject + } else { + p.state = customStateInIgnoredValue + } + case '\\': + p.state = customStateInIgnoredStringEscape + } + case customStateInIgnoredStringEscape: + p.state = customStateInIgnoredString + case customStateInIgnoredValue: + switch b { + case '{', '[': + p.ignoredNesting++ + case '}', ']': + p.ignoredNesting-- + if p.ignoredNesting <= 0 { + p.state = customStateInObject + p.ignoredNesting = 0 + } + case '"': + p.state = customStateInIgnoredString + } + } +} + +func (p *customInputStreamer) writeRune(r rune, out *strings.Builder) { + var buf [utf8.UTFMax]byte + n := utf8.EncodeRune(buf[:], r) + out.Write(buf[:n]) + p.pending.Write(buf[:n]) +} + +// FinalInput 返回完整解析出的 input 字符串。 +// Parsed=true 时返回 finishedRaw;否则返回 pending,让 truncated 已 emit 的字节不丢。 +func (p *customInputStreamer) FinalInput() string { + if p.parsed { + return p.finishedRaw + } + return p.pending.String() +} + +func (p *customInputStreamer) Parsed() bool { return p.parsed } +func (p *customInputStreamer) Failed() bool { return p.state == customStateFailed } + +func isJSONWhitespace(b byte) bool { + return b == ' ' || b == '\t' || b == '\n' || b == '\r' +} + +func decodeHexQuad(hex []byte) rune { + r := rune(0) + for _, c := range hex { + r <<= 4 + switch { + case c >= '0' && c <= '9': + r |= rune(c - '0') + case c >= 'a' && c <= 'f': + r |= rune(c-'a') + 10 + case c >= 'A' && c <= 'F': + r |= rune(c-'A') + 10 + default: + return utf8.RuneError + } + } + return r +} diff --git a/relay/channel/claude/custom_input_streamer_test.go b/relay/channel/claude/custom_input_streamer_test.go new file mode 100644 index 000000000000..06f3ca67efc6 --- /dev/null +++ b/relay/channel/claude/custom_input_streamer_test.go @@ -0,0 +1,216 @@ +package claude + +import ( + "strings" + "testing" +) + +type streamCase struct { + name string + chunks []string + wantDelta string + wantFinal string +} + +func TestCustomInputStreamerCases(t *testing.T) { + cases := []streamCase{ + { + name: "simple split", + chunks: []string{`{"input":"abc`, `def"}`}, + wantDelta: "abcdef", + wantFinal: "abcdef", + }, + { + name: "single chunk", + chunks: []string{`{"input":"hello world"}`}, + wantDelta: "hello world", + wantFinal: "hello world", + }, + { + name: "escape quote split mid-escape", + chunks: []string{`{"input":"he`, `\"`, `llo"}`}, + wantDelta: `he"llo`, + wantFinal: `he"llo`, + }, + { + name: "newline escape", + chunks: []string{`{"input":"line1\n`, `line2"}`}, + wantDelta: "line1\nline2", + wantFinal: "line1\nline2", + }, + { + name: "unicode escape split across chunks", + chunks: []string{`{"input":"\u00`, `e9"}`}, + wantDelta: "é", + wantFinal: "é", + }, + { + name: "backslash escape", + chunks: []string{`{"input":"a\\b"}`}, + wantDelta: `a\b`, + wantFinal: `a\b`, + }, + { + name: "whitespace between tokens", + chunks: []string{`{ "input" : "x" }`}, + wantDelta: "x", + wantFinal: "x", + }, + { + name: "truncated mid-string returns partial", + chunks: []string{`{"input":"abcd`}, + wantDelta: "abcd", + wantFinal: "abcd", + }, + { + name: "lark grammar style patch text with newlines", + chunks: []string{`{"input":"*** Begin Patch\n`, `*** End Patch\n"}`}, + wantDelta: "*** Begin Patch\n*** End Patch\n", + wantFinal: "*** Begin Patch\n*** End Patch\n", + }, + { + name: "byte-by-byte feed", + chunks: splitToBytes(`{"input":"hi"}`), + wantDelta: "hi", + wantFinal: "hi", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + s := newCustomInputStreamer() + got := "" + for _, c := range tc.chunks { + got += s.Feed(c) + } + if got != tc.wantDelta { + t.Errorf("incremental delta=%q want %q", got, tc.wantDelta) + } + if final := s.FinalInput(); final != tc.wantFinal { + t.Errorf("final=%q want %q", final, tc.wantFinal) + } + }) + } +} + +func splitToBytes(s string) []string { + parts := make([]string, len(s)) + for i := 0; i < len(s); i++ { + parts[i] = string(s[i]) + } + return parts +} + +func TestCustomInputStreamerSkipsValueInputMention(t *testing.T) { + // value 里出现 "input" 字面值不应误触发 + s := newCustomInputStreamer() + got := s.Feed(`{"foo":"this contains \"input\" word","input":"real"}`) + if got != "real" { + t.Errorf("got=%q want real", got) + } + if !s.Parsed() { + t.Error("should be parsed") + } +} + +func TestCustomInputStreamerSkipsKeyContainingInputSubstring(t *testing.T) { + // key "user_input" 不能误匹配 "input" + s := newCustomInputStreamer() + got := s.Feed(`{"user_input":"wrong","input":"correct"}`) + if got != "correct" { + t.Errorf("got=%q want correct", got) + } +} + +func TestCustomInputStreamerSkipsNestedObject(t *testing.T) { + s := newCustomInputStreamer() + got := s.Feed(`{"meta":{"input":"nested wrong"},"input":"real"}`) + if got != "real" { + t.Errorf("got=%q want real, nested 'input' inside meta should be skipped", got) + } +} + +func TestCustomInputStreamerSkipsArrayValue(t *testing.T) { + s := newCustomInputStreamer() + got := s.Feed(`{"arr":["input","x"],"input":"real"}`) + if got != "real" { + t.Errorf("got=%q want real", got) + } +} + +func TestCustomInputStreamerSurrogatePair(t *testing.T) { + // 😀 = U+1F600 = \uD83D\uDE00 + s := newCustomInputStreamer() + got := s.Feed(`{"input":"\uD83D\uDE00"}`) + if got != "😀" { + t.Errorf("got=%q (% x) want 😀", got, []byte(got)) + } +} + +func TestCustomInputStreamerSurrogatePairAcrossChunks(t *testing.T) { + s := newCustomInputStreamer() + got := s.Feed(`{"input":"\uD83D`) + s.Feed(`\uDE00"}`) + if got != "😀" { + t.Errorf("got=%q want 😀", got) + } +} + +func TestCustomInputStreamerNonStringInputFails(t *testing.T) { + for _, tc := range []string{ + `{"input":null}`, + `{"input":123}`, + `{"input":{"x":"y"}}`, + `{"input":["a","b"]}`, + `{"input":true}`, + } { + t.Run(tc, func(t *testing.T) { + s := newCustomInputStreamer() + s.Feed(tc) + if s.Parsed() { + t.Error("non-string input should NOT be parsed (caller falls back to raw)") + } + }) + } +} + +func TestCustomInputStreamerScanLimit(t *testing.T) { + s := newCustomInputStreamer() + s.maxScan = 10 + got := s.Feed(`{"input":"this is more than 10 bytes of scanned data"}`) + if !s.Failed() { + t.Errorf("should fail past max scan, parsed=%v failed=%v got=%q", s.Parsed(), s.Failed(), got) + } +} + +func TestCustomInputStreamerInputSizeLimit(t *testing.T) { + s := newCustomInputStreamer() + s.maxInputBytes = 10 + huge := strings.Repeat("a", 100) + s.Feed(`{"input":"` + huge + `"}`) + if !s.Failed() { + t.Error("should fail past max input size") + } +} + +func TestExtractCustomToolInputDistinguishesStates(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"string input", `{"input":"hello"}`, "hello"}, + {"empty string input", `{"input":""}`, ""}, + {"missing key returns raw", `{"text":"x"}`, `{"text":"x"}`}, + {"non-string returns raw", `{"input":123}`, `{"input":123}`}, + {"array input returns raw", `{"input":["a"]}`, `{"input":["a"]}`}, + {"object input returns raw", `{"input":{"k":"v"}}`, `{"input":{"k":"v"}}`}, + {"invalid json returns raw", `not json`, `not json`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := extractCustomToolInput(tc.in) + if got != tc.want { + t.Errorf("got=%q want=%q", got, tc.want) + } + }) + } +} diff --git a/relay/channel/claude/reasoning_encoding.go b/relay/channel/claude/reasoning_encoding.go new file mode 100644 index 000000000000..1b17a461fdd0 --- /dev/null +++ b/relay/channel/claude/reasoning_encoding.go @@ -0,0 +1,94 @@ +package claude + +import ( + "encoding/base64" + "errors" + "strings" + + "github.com/QuantumNous/new-api/common" +) + +const ( + reasoningEncodingVersion = 1 + reasoningEnvelopePrefix = "na1." + reasoningMaxRawBytes = 64 * 1024 + + ReasoningKindThinking = "thinking" + ReasoningKindRedacted = "redacted" +) + +type reasoningPayload struct { + Version int `json:"v"` + Kind string `json:"t"` + Signature string `json:"s,omitempty"` + Data string `json:"d,omitempty"` +} + +func EncodeThinkingSignature(signature string) string { + if signature == "" { + return "" + } + return encodeReasoning(reasoningPayload{ + Version: reasoningEncodingVersion, + Kind: ReasoningKindThinking, + Signature: signature, + }) +} + +func EncodeRedactedThinking(data string) string { + if data == "" { + return "" + } + return encodeReasoning(reasoningPayload{ + Version: reasoningEncodingVersion, + Kind: ReasoningKindRedacted, + Data: data, + }) +} + +// 带 na1. 前缀的 envelope 走严格解码失败必返错;不带前缀的字符串视为 legacy 裸 Anthropic signature, +// 让那些直接拼 signature 字符串作为 encrypted_content 上送的客户端能复用上一轮签名。 +func DecodeReasoningEncryptedContent(encrypted string) (kind, signature, data string, err error) { + if encrypted == "" { + return "", "", "", nil + } + if !strings.HasPrefix(encrypted, reasoningEnvelopePrefix) { + return ReasoningKindThinking, encrypted, "", nil + } + body := strings.TrimPrefix(encrypted, reasoningEnvelopePrefix) + raw, decErr := base64.RawURLEncoding.DecodeString(body) + if decErr != nil { + return "", "", "", errors.New("invalid reasoning envelope: base64 decode: " + decErr.Error()) + } + if len(raw) > reasoningMaxRawBytes { + return "", "", "", errors.New("invalid reasoning envelope: payload too large") + } + var p reasoningPayload + if err := common.Unmarshal(raw, &p); err != nil { + return "", "", "", errors.New("invalid reasoning envelope: json: " + err.Error()) + } + if p.Version != reasoningEncodingVersion { + return "", "", "", errors.New("invalid reasoning envelope: unsupported version") + } + switch p.Kind { + case ReasoningKindThinking: + if p.Signature == "" { + return "", "", "", errors.New("invalid reasoning envelope: thinking envelope missing signature") + } + return ReasoningKindThinking, p.Signature, "", nil + case ReasoningKindRedacted: + if p.Data == "" { + return "", "", "", errors.New("invalid reasoning envelope: redacted envelope missing data") + } + return ReasoningKindRedacted, "", p.Data, nil + } + return "", "", "", errors.New("invalid reasoning envelope: unknown kind " + p.Kind) +} + +func encodeReasoning(p reasoningPayload) string { + raw, err := common.Marshal(p) + if err != nil { + return "" + } + return reasoningEnvelopePrefix + base64.RawURLEncoding.EncodeToString(raw) +} diff --git a/relay/channel/claude/reasoning_encoding_test.go b/relay/channel/claude/reasoning_encoding_test.go new file mode 100644 index 000000000000..30530a0b6f1a --- /dev/null +++ b/relay/channel/claude/reasoning_encoding_test.go @@ -0,0 +1,127 @@ +package claude + +import ( + "encoding/base64" + "strings" + "testing" + + "github.com/QuantumNous/new-api/common" +) + +func TestThinkingSignatureRoundTrip(t *testing.T) { + sig := "ErkBCkYI" + encoded := EncodeThinkingSignature(sig) + if encoded == "" { + t.Fatal("empty encoded") + } + if !strings.HasPrefix(encoded, reasoningEnvelopePrefix) { + t.Errorf("encoded should start with %q, got %q", reasoningEnvelopePrefix, encoded) + } + kind, signature, data, err := DecodeReasoningEncryptedContent(encoded) + if err != nil { + t.Fatalf("decode err: %v", err) + } + if kind != ReasoningKindThinking { + t.Errorf("kind=%q want %q", kind, ReasoningKindThinking) + } + if signature != sig { + t.Errorf("signature=%q want %q", signature, sig) + } + if data != "" { + t.Errorf("data=%q want empty", data) + } +} + +func TestRedactedThinkingRoundTrip(t *testing.T) { + d := "EsAB..." + encoded := EncodeRedactedThinking(d) + if encoded == "" { + t.Fatal("empty encoded") + } + if !strings.HasPrefix(encoded, reasoningEnvelopePrefix) { + t.Errorf("encoded should start with %q, got %q", reasoningEnvelopePrefix, encoded) + } + kind, signature, data, err := DecodeReasoningEncryptedContent(encoded) + if err != nil { + t.Fatalf("decode err: %v", err) + } + if kind != ReasoningKindRedacted { + t.Errorf("kind=%q want %q", kind, ReasoningKindRedacted) + } + if signature != "" { + t.Errorf("signature=%q want empty", signature) + } + if data != d { + t.Errorf("data=%q want %q", data, d) + } +} + +func TestDecodeLegacyRawSignature(t *testing.T) { + raw := "raw_anthropic_signature_no_prefix" + kind, signature, data, err := DecodeReasoningEncryptedContent(raw) + if err != nil { + t.Fatalf("legacy fallback should not err, got %v", err) + } + if kind != ReasoningKindThinking { + t.Errorf("kind=%q want fallback thinking", kind) + } + if signature != raw { + t.Errorf("signature=%q want %q", signature, raw) + } + if data != "" { + t.Errorf("data should be empty in fallback") + } +} + +func TestEncodeEmpty(t *testing.T) { + if EncodeThinkingSignature("") != "" { + t.Error("empty signature should encode to empty string") + } + if EncodeRedactedThinking("") != "" { + t.Error("empty data should encode to empty string") + } + kind, sig, data, err := DecodeReasoningEncryptedContent("") + if err != nil { + t.Errorf("empty input should not err, got %v", err) + } + if kind != "" || sig != "" || data != "" { + t.Errorf("empty input should return all empty, got kind=%q sig=%q data=%q", kind, sig, data) + } +} + +func TestDecodeMalformedEnvelopeReturnsError(t *testing.T) { + cases := map[string]string{ + "bad base64": reasoningEnvelopePrefix + "not-base64!!!", + "bad json": reasoningEnvelopePrefix + base64.RawURLEncoding.EncodeToString([]byte("not json")), + "wrong version": reasoningEnvelopePrefix + encodeRawPayloadForTest(t, map[string]any{"v": 999, "t": "thinking", "s": "x"}), + "unknown kind": reasoningEnvelopePrefix + encodeRawPayloadForTest(t, map[string]any{"v": 1, "t": "foo", "s": "x"}), + "thinking missing sig": reasoningEnvelopePrefix + encodeRawPayloadForTest(t, map[string]any{"v": 1, "t": "thinking"}), + "redacted missing data": reasoningEnvelopePrefix + encodeRawPayloadForTest(t, map[string]any{"v": 1, "t": "redacted"}), + } + for name, input := range cases { + t.Run(name, func(t *testing.T) { + _, _, _, err := DecodeReasoningEncryptedContent(input) + if err == nil { + t.Errorf("input %q should err", input) + } + }) + } +} + +func TestDecodeOversizedPayloadRejected(t *testing.T) { + bigPayload := strings.Repeat("a", reasoningMaxRawBytes+1) + encoded := reasoningEnvelopePrefix + base64.RawURLEncoding.EncodeToString([]byte(bigPayload)) + _, _, _, err := DecodeReasoningEncryptedContent(encoded) + if err == nil { + t.Error("oversized payload should err") + } +} + +func encodeRawPayloadForTest(t *testing.T, p any) string { + t.Helper() + raw, err := common.Marshal(p) + if err != nil { + t.Fatal(err) + } + return base64.RawURLEncoding.EncodeToString(raw) +} diff --git a/relay/channel/claude/relay-claude.go b/relay/channel/claude/relay-claude.go index e177e56dab14..44336f5d307e 100644 --- a/relay/channel/claude/relay-claude.go +++ b/relay/channel/claude/relay-claude.go @@ -582,12 +582,25 @@ func ResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.OpenAITextRe } type ClaudeResponseInfo struct { - ResponseId string - Created int64 - Model string - ResponseText strings.Builder - Usage *dto.Usage - Done bool + ResponseId string + Created int64 + Model string + ResponseText strings.Builder + Usage *dto.Usage + Done bool + ResponsesState *ClaudeResponsesStreamState +} + +func getResponsesCustomToolNames(c *gin.Context) map[string]bool { + if c == nil { + return nil + } + v, ok := c.Get(customToolNamesContextKey) + if !ok { + return nil + } + names, _ := v.(map[string]bool) + return names } func cacheCreationTokensForOpenAIUsage(usage *dto.Usage) int { @@ -826,6 +839,23 @@ func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud if err != nil { logger.LogError(c, "send_stream_response_failed: "+err.Error()) } + } else if info.RelayFormat == types.RelayFormatOpenAIResponses { + FormatClaudeResponseInfo(&claudeResponse, nil, claudeInfo) + if claudeInfo.ResponsesState == nil { + claudeInfo.ResponsesState = NewClaudeResponsesStreamState(info.UpstreamModelName) + claudeInfo.ResponsesState.CreatedAt = claudeInfo.Created + claudeInfo.ResponsesState.ResponseID = claudeInfo.ResponseId + claudeInfo.ResponsesState.CustomToolNames = getResponsesCustomToolNames(c) + } + for _, evt := range claudeInfo.ResponsesState.HandleClaudeChunk(&claudeResponse) { + payload, marshalErr := common.Marshal(evt) + if marshalErr != nil { + logger.LogError(c, "marshal_responses_stream_failed: "+marshalErr.Error()) + continue + } + // 标准 OpenAI /v1/responses SSE 每个事件必须带 event: ,否则 Codex 等客户端按 untyped 处理会丢字段。 + helper.ResponseChunkData(c, evt, string(payload)) + } } return nil } @@ -865,6 +895,21 @@ func HandleStreamFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, clau } } helper.Done(c) + } else if info.RelayFormat == types.RelayFormatOpenAIResponses { + if claudeInfo.ResponsesState == nil { + claudeInfo.ResponsesState = NewClaudeResponsesStreamState(info.UpstreamModelName) + claudeInfo.ResponsesState.CreatedAt = claudeInfo.Created + claudeInfo.ResponsesState.ResponseID = claudeInfo.ResponseId + claudeInfo.ResponsesState.CustomToolNames = getResponsesCustomToolNames(c) + } + for _, evt := range claudeInfo.ResponsesState.FinalEvents() { + payload, marshalErr := common.Marshal(evt) + if marshalErr != nil { + common.SysLog("marshal final responses event failed: " + marshalErr.Error()) + continue + } + helper.ResponseChunkData(c, evt, string(payload)) + } } } @@ -925,6 +970,15 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud } case types.RelayFormatClaude: responseData = data + case types.RelayFormatOpenAIResponses: + responsesResp := ConvertClaudeResponseToResponses(&claudeResponse, getResponsesCustomToolNames(c)) + if claudeInfo.Created > 0 { + responsesResp.CreatedAt = int(claudeInfo.Created) + } + responseData, err = json.Marshal(responsesResp) + if err != nil { + return types.NewError(err, types.ErrorCodeBadResponseBody) + } } if claudeResponse.Usage != nil && claudeResponse.Usage.ServerToolUse != nil && claudeResponse.Usage.ServerToolUse.WebSearchRequests > 0 { diff --git a/relay/channel/claude/responses_conversion_test.go b/relay/channel/claude/responses_conversion_test.go new file mode 100644 index 000000000000..a81436834743 --- /dev/null +++ b/relay/channel/claude/responses_conversion_test.go @@ -0,0 +1,1027 @@ +package claude + +import ( + "strconv" + "strings" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" +) + +func ptrStr(s string) *string { return &s } +func ptrInt(i int) *int { return &i } + +func TestStreamStateThinkingTextToolUse(t *testing.T) { + state := NewClaudeResponsesStreamState("claude-opus-4-7") + state.CreatedAt = 1700000000 + state.ResponseID = "resp_abc" + + usage := &dto.ClaudeUsage{InputTokens: 50, OutputTokens: 0} + feed := func(c *dto.ClaudeResponse) []dto.ResponsesStreamResponse { + return state.HandleClaudeChunk(c) + } + + all := []dto.ResponsesStreamResponse{} + all = append(all, feed(&dto.ClaudeResponse{ + Type: "message_start", + Message: &dto.ClaudeMediaMessage{ + Id: "msg_xyz", + Model: "claude-opus-4-7", + Usage: usage, + }, + })...) + all = append(all, feed(&dto.ClaudeResponse{ + Type: "content_block_start", + Index: ptrInt(0), + ContentBlock: &dto.ClaudeMediaMessage{Type: "thinking", Thinking: ptrStr(""), Signature: ""}, + })...) + all = append(all, feed(&dto.ClaudeResponse{ + Type: "content_block_delta", + Index: ptrInt(0), + Delta: &dto.ClaudeMediaMessage{Type: "thinking_delta", Thinking: ptrStr("Let me think.")}, + })...) + all = append(all, feed(&dto.ClaudeResponse{ + Type: "content_block_delta", + Index: ptrInt(0), + Delta: &dto.ClaudeMediaMessage{Type: "signature_delta", Signature: "SIG_RAW"}, + })...) + all = append(all, feed(&dto.ClaudeResponse{Type: "content_block_stop", Index: ptrInt(0)})...) + + all = append(all, feed(&dto.ClaudeResponse{ + Type: "content_block_start", + Index: ptrInt(1), + ContentBlock: &dto.ClaudeMediaMessage{Type: "text", Text: ptrStr("")}, + })...) + all = append(all, feed(&dto.ClaudeResponse{ + Type: "content_block_delta", + Index: ptrInt(1), + Delta: &dto.ClaudeMediaMessage{Type: "text_delta", Text: ptrStr("Hello ")}, + })...) + all = append(all, feed(&dto.ClaudeResponse{ + Type: "content_block_delta", + Index: ptrInt(1), + Delta: &dto.ClaudeMediaMessage{Type: "text_delta", Text: ptrStr("world.")}, + })...) + all = append(all, feed(&dto.ClaudeResponse{Type: "content_block_stop", Index: ptrInt(1)})...) + + all = append(all, feed(&dto.ClaudeResponse{ + Type: "content_block_start", + Index: ptrInt(2), + ContentBlock: &dto.ClaudeMediaMessage{Type: "tool_use", Id: "toolu_001", Name: "get_weather"}, + })...) + all = append(all, feed(&dto.ClaudeResponse{ + Type: "content_block_delta", + Index: ptrInt(2), + Delta: &dto.ClaudeMediaMessage{Type: "input_json_delta", PartialJson: ptrStr(`{"city":`)}, + })...) + all = append(all, feed(&dto.ClaudeResponse{ + Type: "content_block_delta", + Index: ptrInt(2), + Delta: &dto.ClaudeMediaMessage{Type: "input_json_delta", PartialJson: ptrStr(`"SF"}`)}, + })...) + all = append(all, feed(&dto.ClaudeResponse{Type: "content_block_stop", Index: ptrInt(2)})...) + + all = append(all, feed(&dto.ClaudeResponse{ + Type: "message_delta", + Delta: &dto.ClaudeMediaMessage{StopReason: ptrStr("tool_use")}, + Usage: &dto.ClaudeUsage{OutputTokens: 42}, + })...) + all = append(all, feed(&dto.ClaudeResponse{Type: "message_stop"})...) + all = append(all, state.FinalEvents()...) + + gotTypes := make([]string, 0, len(all)) + for _, e := range all { + gotTypes = append(gotTypes, e.Type) + } + wantSeq := []string{ + respEventCreated, respEventInProgress, + respEventOutputItemAdded, respEventReasoningSummaryPartAdded, + respEventReasoningSummaryTextDelta, + respEventReasoningSummaryTextDone, respEventReasoningSummaryPartDone, respEventOutputItemDone, + respEventOutputItemAdded, respEventContentPartAdded, + respEventOutputTextDelta, respEventOutputTextDelta, + respEventOutputTextDone, respEventContentPartDone, respEventOutputItemDone, + respEventOutputItemAdded, + respEventFnCallArgsDelta, respEventFnCallArgsDelta, + respEventFnCallArgsDone, respEventOutputItemDone, + respEventCompleted, + } + if len(gotTypes) != len(wantSeq) { + t.Fatalf("event count mismatch: got %d (%v), want %d (%v)", len(gotTypes), gotTypes, len(wantSeq), wantSeq) + } + for i, want := range wantSeq { + if gotTypes[i] != want { + t.Errorf("event[%d]: got %q want %q (full: %v)", i, gotTypes[i], want, gotTypes) + } + } + + for i, e := range all { + if e.SequenceNumber != i { + t.Errorf("event[%d].SequenceNumber=%d want %d", i, e.SequenceNumber, i) + } + } + + final := all[len(all)-1] + if final.Response == nil || len(final.Response.Output) != 3 { + t.Fatalf("final response should have 3 output items, got %+v", final.Response) + } + reasoning := final.Response.Output[0] + if reasoning.Type != "reasoning" { + t.Errorf("output[0].type=%q want reasoning", reasoning.Type) + } + if reasoning.EncryptedContent == "" { + t.Error("reasoning encrypted_content should not be empty") + } + _, decodedSig, _, _ := DecodeReasoningEncryptedContent(reasoning.EncryptedContent) + if decodedSig != "SIG_RAW" { + t.Errorf("encrypted_content signature round-trip: got %q want SIG_RAW", decodedSig) + } + if len(reasoning.Summary) != 1 || reasoning.Summary[0].Text != "Let me think." { + t.Errorf("reasoning.summary=%v want single 'Let me think.'", reasoning.Summary) + } + + msg := final.Response.Output[1] + if msg.Type != "message" || len(msg.Content) != 1 || msg.Content[0].Text != "Hello world." { + t.Errorf("output[1] message wrong: %+v", msg) + } + + tc := final.Response.Output[2] + if tc.Type != "function_call" || tc.CallId != "toolu_001" || tc.Name != "get_weather" { + t.Errorf("output[2] function_call wrong: %+v", tc) + } + if string(tc.Arguments) != `"{\"city\":\"SF\"}"` { + t.Errorf("tool arguments: got %s want quoted JSON string", string(tc.Arguments)) + } + if got := tc.ArgumentsString(); got != `{"city":"SF"}` { + t.Errorf("ArgumentsString=%q want {\"city\":\"SF\"}", got) + } + + // Stream 路径里 response.output_item.done 的 item.arguments 也必须是字符串字面(与 OpenAI 官方一致), + // 否则 Codex 等客户端 JSON.parse(item.arguments) 会抛错。 + var streamToolDoneItem *dto.ResponsesOutput + for _, e := range all { + if e.Type == respEventOutputItemDone && e.Item != nil && e.Item.Type == "function_call" { + streamToolDoneItem = e.Item + } + } + if streamToolDoneItem == nil { + t.Fatalf("expected stream output_item.done for function_call") + } + rawDoneItem, _ := common.Marshal(streamToolDoneItem) + if !strings.Contains(string(rawDoneItem), `"arguments":"{\"city\":\"SF\"}"`) { + t.Errorf("stream done item arguments not serialized as JSON string: %s", string(rawDoneItem)) + } +} + +func TestResponsesRequestReasoningRoundTrip(t *testing.T) { + sig := "SIG_FROM_CLAUDE_PRIOR_TURN" + encryptedRaw := EncodeThinkingSignature(sig) + inputJSON := `[ + {"role":"user","content":"hi"}, + {"type":"reasoning","id":"rs_1","encrypted_content":"` + encryptedRaw + `","summary":[{"type":"summary_text","text":"thinking text"}]}, + {"role":"assistant","content":[{"type":"output_text","text":"answer"}]} + ]` + req := &dto.OpenAIResponsesRequest{ + Model: "claude-opus-4-7", + Input: []byte(inputJSON), + } + claude, _, err := ConvertResponsesRequestToClaude(req) + if err != nil { + t.Fatalf("convert: %v", err) + } + if len(claude.Messages) != 2 { + t.Fatalf("messages=%d want 2", len(claude.Messages)) + } + assistant := claude.Messages[1] + if assistant.Role != "assistant" { + t.Fatalf("messages[1].role=%q want assistant", assistant.Role) + } + blocks, _ := assistant.ParseContent() + if len(blocks) != 2 { + t.Fatalf("assistant blocks=%d want 2 (thinking + text)", len(blocks)) + } + if blocks[0].Type != "thinking" { + t.Errorf("blocks[0].type=%q want thinking", blocks[0].Type) + } + if blocks[0].Signature != sig { + t.Errorf("blocks[0].signature=%q want %q", blocks[0].Signature, sig) + } + if blocks[0].Thinking == nil || *blocks[0].Thinking != "thinking text" { + t.Errorf("blocks[0].thinking=%v want 'thinking text'", blocks[0].Thinking) + } + if blocks[1].Type != "text" { + t.Errorf("blocks[1].type=%q want text", blocks[1].Type) + } +} + +func TestResponsesRequestPreviousResponseIDRejected(t *testing.T) { + req := &dto.OpenAIResponsesRequest{ + Model: "claude-opus-4-7", + Input: []byte(`"hi"`), + PreviousResponseID: "resp_prev_xxx", + } + _, _, err := ConvertResponsesRequestToClaude(req) + if err == nil || !strings.Contains(err.Error(), "previous_response_id") { + t.Errorf("expected previous_response_id rejection, got %v", err) + } +} + +func TestResponsesRequestJSONSchemaFormatRejected(t *testing.T) { + textRaw, _ := common.Marshal(map[string]any{"format": map[string]any{"type": "json_schema"}}) + req := &dto.OpenAIResponsesRequest{ + Model: "claude-opus-4-7", + Input: []byte(`"hi"`), + Text: textRaw, + } + _, _, err := ConvertResponsesRequestToClaude(req) + if err == nil || !strings.Contains(err.Error(), "json_schema") { + t.Errorf("expected json_schema rejection, got %v", err) + } +} + +func TestResponsesRequestReasoningEffortMapsToAdaptive(t *testing.T) { + cases := []struct { + name string + effort string + summary string + wantType string + wantDisp string + }{ + {"minimal disables", "minimal", "", "disabled", ""}, + {"low to adaptive summarized", "low", "", "adaptive", "summarized"}, + {"medium to adaptive summarized", "medium", "auto", "adaptive", "summarized"}, + {"high to adaptive summarized", "high", "concise", "adaptive", "summarized"}, + {"summary none omits", "high", "none", "adaptive", "omitted"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := &dto.OpenAIResponsesRequest{ + Model: "claude-opus-4-7", + Input: []byte(`"hi"`), + Reasoning: &dto.Reasoning{Effort: tc.effort, Summary: tc.summary}, + } + claude, _, err := ConvertResponsesRequestToClaude(req) + if err != nil { + t.Fatalf("convert: %v", err) + } + if claude.Thinking == nil { + t.Fatalf("Thinking is nil") + } + if claude.Thinking.Type != tc.wantType { + t.Errorf("Thinking.Type=%q want %q", claude.Thinking.Type, tc.wantType) + } + if claude.Thinking.Display != tc.wantDisp { + t.Errorf("Thinking.Display=%q want %q", claude.Thinking.Display, tc.wantDisp) + } + }) + } +} + +func TestResponsesRequestToolCallRoundTrip(t *testing.T) { + inputJSON := `[ + {"role":"user","content":"what is the weather in SF?"}, + {"type":"function_call","call_id":"toolu_001","name":"get_weather","arguments":"{\"city\":\"SF\"}"}, + {"type":"function_call_output","call_id":"toolu_001","output":"sunny"} + ]` + req := &dto.OpenAIResponsesRequest{ + Model: "claude-opus-4-7", + Input: []byte(inputJSON), + } + claude, _, err := ConvertResponsesRequestToClaude(req) + if err != nil { + t.Fatalf("convert: %v", err) + } + if len(claude.Messages) != 3 { + t.Fatalf("messages=%d want 3 (user / assistant tool_use / user tool_result)", len(claude.Messages)) + } + assistant := claude.Messages[1] + blocks, _ := assistant.ParseContent() + if len(blocks) != 1 || blocks[0].Type != "tool_use" || blocks[0].Id != "toolu_001" { + t.Errorf("assistant blocks wrong: %+v", blocks) + } + if blocks[0].Name != "get_weather" { + t.Errorf("tool name=%q want get_weather", blocks[0].Name) + } + inputMap, ok := blocks[0].Input.(map[string]any) + if !ok || inputMap["city"] != "SF" { + t.Errorf("tool input=%v want {city:SF}", blocks[0].Input) + } + user2 := claude.Messages[2] + blocks2, _ := user2.ParseContent() + if len(blocks2) != 1 || blocks2[0].Type != "tool_result" || blocks2[0].ToolUseId != "toolu_001" { + t.Errorf("tool_result wrong: %+v", blocks2) + } + if blocks2[0].Content != "sunny" { + t.Errorf("tool_result content=%v want sunny", blocks2[0].Content) + } +} + +func TestStreamMaxTokensIncompleteEvent(t *testing.T) { + state := NewClaudeResponsesStreamState("claude-opus-4-7") + state.CreatedAt = 1700000000 + state.ResponseID = "resp_x" + + state.HandleClaudeChunk(&dto.ClaudeResponse{ + Type: "message_start", + Message: &dto.ClaudeMediaMessage{Id: "msg_1", Model: "claude-opus-4-7", Usage: &dto.ClaudeUsage{InputTokens: 10}}, + }) + state.HandleClaudeChunk(&dto.ClaudeResponse{ + Type: "content_block_start", + Index: ptrInt(0), + ContentBlock: &dto.ClaudeMediaMessage{Type: "text", Text: ptrStr("")}, + }) + state.HandleClaudeChunk(&dto.ClaudeResponse{ + Type: "content_block_delta", + Index: ptrInt(0), + Delta: &dto.ClaudeMediaMessage{Type: "text_delta", Text: ptrStr("partial")}, + }) + state.HandleClaudeChunk(&dto.ClaudeResponse{Type: "content_block_stop", Index: ptrInt(0)}) + state.HandleClaudeChunk(&dto.ClaudeResponse{ + Type: "message_delta", + Delta: &dto.ClaudeMediaMessage{StopReason: ptrStr("max_tokens")}, + Usage: &dto.ClaudeUsage{OutputTokens: 100}, + }) + final := state.FinalEvents() + if len(final) != 1 || final[0].Type != respEventIncomplete { + t.Fatalf("final event type=%v want response.incomplete (len=%d)", final, len(final)) + } + if final[0].Response == nil || final[0].Response.IncompleteDetails == nil { + t.Fatalf("final response missing incomplete_details: %+v", final[0].Response) + } + if final[0].Response.IncompleteDetails.Reason != "max_output_tokens" { + t.Errorf("incomplete_details.reason=%q want max_output_tokens", final[0].Response.IncompleteDetails.Reason) + } + raw, _ := common.Marshal(final[0].Response.IncompleteDetails) + if !strings.Contains(string(raw), `"reason":"max_output_tokens"`) { + t.Errorf("serialized JSON should use 'reason' key, got %s", string(raw)) + } +} + +func TestNonStreamMaxTokensIncomplete(t *testing.T) { + cr := &dto.ClaudeResponse{ + Id: "msg_2", + Type: "message", + Role: "assistant", + Model: "claude-opus-4-7", + StopReason: "max_tokens", + Content: []dto.ClaudeMediaMessage{ + {Type: "text", Text: ptrStr("partial")}, + }, + Usage: &dto.ClaudeUsage{InputTokens: 5, OutputTokens: 50}, + } + resp := ConvertClaudeResponseToResponses(cr, nil) + if resp.IncompleteDetails == nil { + t.Fatal("incomplete_details should be set") + } + if resp.IncompleteDetails.Reason != "max_output_tokens" { + t.Errorf("reason=%q want max_output_tokens", resp.IncompleteDetails.Reason) + } +} + +func TestAssistantTextThenReasoningRejected(t *testing.T) { + encryptedRaw := EncodeThinkingSignature("SIG") + inputJSON := `[ + {"role":"user","content":"hi"}, + {"role":"assistant","content":[{"type":"output_text","text":"hello"}]}, + {"type":"reasoning","id":"rs_1","encrypted_content":"` + encryptedRaw + `","summary":[{"type":"summary_text","text":"thought"}]} + ]` + req := &dto.OpenAIResponsesRequest{Model: "claude-opus-4-7", Input: []byte(inputJSON)} + _, _, err := ConvertResponsesRequestToClaude(req) + if err == nil || !strings.Contains(err.Error(), "reasoning") { + t.Errorf("expected rejection for reasoning after text, got %v", err) + } +} + +func TestAssistantReasoningThenTextAllowed(t *testing.T) { + encryptedRaw := EncodeThinkingSignature("SIG") + inputJSON := `[ + {"role":"user","content":"hi"}, + {"type":"reasoning","id":"rs_1","encrypted_content":"` + encryptedRaw + `","summary":[{"type":"summary_text","text":"thought"}]}, + {"role":"assistant","content":[{"type":"output_text","text":"hello"}]} + ]` + req := &dto.OpenAIResponsesRequest{Model: "claude-opus-4-7", Input: []byte(inputJSON)} + claude, _, err := ConvertResponsesRequestToClaude(req) + if err != nil { + t.Fatalf("should not err: %v", err) + } + if len(claude.Messages) != 2 { + t.Fatalf("messages=%d want 2", len(claude.Messages)) + } + assistant := claude.Messages[1] + blocks, _ := assistant.ParseContent() + if len(blocks) != 2 { + t.Fatalf("blocks=%d want 2 (thinking+text)", len(blocks)) + } + if blocks[0].Type != "thinking" || blocks[1].Type != "text" { + t.Errorf("block order wrong: %s, %s", blocks[0].Type, blocks[1].Type) + } +} + +func TestToolChoiceAllowedToolsRejected(t *testing.T) { + tc, _ := common.Marshal(map[string]any{"type": "allowed_tools", "tools": []map[string]any{{"type": "function", "name": "x"}}}) + req := &dto.OpenAIResponsesRequest{ + Model: "claude-opus-4-7", + Input: []byte(`"hi"`), + ToolChoice: tc, + } + _, _, err := ConvertResponsesRequestToClaude(req) + if err == nil || !strings.Contains(err.Error(), "allowed_tools") { + t.Errorf("expected allowed_tools rejection, got %v", err) + } +} + +func TestStreamServerToolUseSkipped(t *testing.T) { + state := NewClaudeResponsesStreamState("claude-opus-4-7") + state.CreatedAt = 1700000000 + state.ResponseID = "resp_x" + + state.HandleClaudeChunk(&dto.ClaudeResponse{ + Type: "message_start", + Message: &dto.ClaudeMediaMessage{Id: "msg_1", Model: "claude-opus-4-7", Usage: &dto.ClaudeUsage{InputTokens: 10}}, + }) + state.HandleClaudeChunk(&dto.ClaudeResponse{ + Type: "content_block_start", + Index: ptrInt(0), + ContentBlock: &dto.ClaudeMediaMessage{Type: "server_tool_use", Id: "stu_1", Name: "web_search"}, + }) + state.HandleClaudeChunk(&dto.ClaudeResponse{ + Type: "content_block_delta", + Index: ptrInt(0), + Delta: &dto.ClaudeMediaMessage{Type: "input_json_delta", PartialJson: ptrStr(`{"q":`)}, + }) + state.HandleClaudeChunk(&dto.ClaudeResponse{Type: "content_block_stop", Index: ptrInt(0)}) + + state.HandleClaudeChunk(&dto.ClaudeResponse{ + Type: "content_block_start", + Index: ptrInt(1), + ContentBlock: &dto.ClaudeMediaMessage{Type: "text", Text: ptrStr("")}, + }) + state.HandleClaudeChunk(&dto.ClaudeResponse{ + Type: "content_block_delta", + Index: ptrInt(1), + Delta: &dto.ClaudeMediaMessage{Type: "text_delta", Text: ptrStr("answer")}, + }) + state.HandleClaudeChunk(&dto.ClaudeResponse{Type: "content_block_stop", Index: ptrInt(1)}) + state.HandleClaudeChunk(&dto.ClaudeResponse{ + Type: "message_delta", + Delta: &dto.ClaudeMediaMessage{StopReason: ptrStr("end_turn")}, + }) + final := state.FinalEvents() + if final[0].Response == nil { + t.Fatal("final response nil") + } + for _, item := range final[0].Response.Output { + if item.Type == "unknown" { + t.Errorf("output should not contain type:unknown items, got %+v", item) + } + } + if len(final[0].Response.Output) != 1 { + t.Errorf("output count=%d want 1 (only text)", len(final[0].Response.Output)) + } + if len(final[0].Response.Output) >= 1 { + msg := final[0].Response.Output[0] + if msg.Type != "message" || msg.Content[0].Text != "answer" { + t.Errorf("output[0] wrong: %+v", msg) + } + want := 1 + if msg.Content[0].Text != "answer" || len(final[0].Response.Output) != want { + t.Errorf("expected only the text message at output[0], got %+v", final[0].Response.Output) + } + } +} + +func TestStreamMalformedEnvelopeInInputRejected(t *testing.T) { + bad := reasoningEnvelopePrefix + "not-base64!!!" + inputJSON := `[ + {"role":"user","content":"hi"}, + {"type":"reasoning","id":"rs_1","encrypted_content":"` + bad + `","summary":[{"type":"summary_text","text":"x"}]} + ]` + req := &dto.OpenAIResponsesRequest{Model: "claude-opus-4-7", Input: []byte(inputJSON)} + _, _, err := ConvertResponsesRequestToClaude(req) + if err == nil || !strings.Contains(err.Error(), "envelope") { + t.Errorf("expected envelope decode error, got %v", err) + } +} + +func TestStreamInterleavedThinkingText(t *testing.T) { + state := NewClaudeResponsesStreamState("claude-opus-4-7") + state.CreatedAt = 1700000000 + state.ResponseID = "resp_x" + + state.HandleClaudeChunk(&dto.ClaudeResponse{ + Type: "message_start", + Message: &dto.ClaudeMediaMessage{Id: "msg_1", Model: "claude-opus-4-7", Usage: &dto.ClaudeUsage{InputTokens: 10}}, + }) + + for i, blockType := range []string{"thinking", "text", "thinking", "text"} { + state.HandleClaudeChunk(&dto.ClaudeResponse{ + Type: "content_block_start", + Index: ptrInt(i), + ContentBlock: &dto.ClaudeMediaMessage{Type: blockType, Text: ptrStr(""), Thinking: ptrStr(""), Signature: ""}, + }) + deltaType := "text_delta" + var delta dto.ClaudeMediaMessage + if blockType == "thinking" { + deltaType = "thinking_delta" + delta = dto.ClaudeMediaMessage{Type: deltaType, Thinking: ptrStr("t" + strconv.Itoa(i))} + } else { + delta = dto.ClaudeMediaMessage{Type: deltaType, Text: ptrStr("x" + strconv.Itoa(i))} + } + state.HandleClaudeChunk(&dto.ClaudeResponse{Type: "content_block_delta", Index: ptrInt(i), Delta: &delta}) + if blockType == "thinking" { + state.HandleClaudeChunk(&dto.ClaudeResponse{ + Type: "content_block_delta", + Index: ptrInt(i), + Delta: &dto.ClaudeMediaMessage{Type: "signature_delta", Signature: "S" + strconv.Itoa(i)}, + }) + } + state.HandleClaudeChunk(&dto.ClaudeResponse{Type: "content_block_stop", Index: ptrInt(i)}) + } + state.HandleClaudeChunk(&dto.ClaudeResponse{ + Type: "message_delta", + Delta: &dto.ClaudeMediaMessage{StopReason: ptrStr("end_turn")}, + }) + final := state.FinalEvents() + if len(final[0].Response.Output) != 4 { + t.Fatalf("output count=%d want 4 (interleaved)", len(final[0].Response.Output)) + } + wantTypes := []string{"reasoning", "message", "reasoning", "message"} + for i, item := range final[0].Response.Output { + if item.Type != wantTypes[i] { + t.Errorf("output[%d].type=%q want %q", i, item.Type, wantTypes[i]) + } + } + r0 := final[0].Response.Output[0] + if r0.EncryptedContent == "" { + t.Error("reasoning[0].encrypted_content empty") + } + _, sig, _, err := DecodeReasoningEncryptedContent(r0.EncryptedContent) + if err != nil || sig != "S0" { + t.Errorf("reasoning[0] sig roundtrip=%q want S0 err=%v", sig, err) + } + r2 := final[0].Response.Output[2] + _, sig2, _, _ := DecodeReasoningEncryptedContent(r2.EncryptedContent) + if sig2 != "S2" { + t.Errorf("reasoning[2] sig=%q want S2", sig2) + } +} + +func TestStreamRedactedThinkingEmitsEncryptedContent(t *testing.T) { + state := NewClaudeResponsesStreamState("claude-opus-4-7") + state.CreatedAt = 1700000000 + state.ResponseID = "resp_x" + + state.HandleClaudeChunk(&dto.ClaudeResponse{ + Type: "message_start", + Message: &dto.ClaudeMediaMessage{Id: "msg_1", Model: "claude-opus-4-7", Usage: &dto.ClaudeUsage{InputTokens: 10}}, + }) + state.HandleClaudeChunk(&dto.ClaudeResponse{ + Type: "content_block_start", + Index: ptrInt(0), + ContentBlock: &dto.ClaudeMediaMessage{Type: "redacted_thinking", Data: "REDACTED_BLOB"}, + }) + state.HandleClaudeChunk(&dto.ClaudeResponse{Type: "content_block_stop", Index: ptrInt(0)}) + state.HandleClaudeChunk(&dto.ClaudeResponse{ + Type: "message_delta", + Delta: &dto.ClaudeMediaMessage{StopReason: ptrStr("end_turn")}, + }) + final := state.FinalEvents() + if len(final[0].Response.Output) != 1 { + t.Fatalf("output count=%d want 1", len(final[0].Response.Output)) + } + out := final[0].Response.Output[0] + if out.Type != "reasoning" { + t.Errorf("type=%q want reasoning", out.Type) + } + if out.EncryptedContent == "" { + t.Fatal("encrypted_content empty") + } + kind, _, data, err := DecodeReasoningEncryptedContent(out.EncryptedContent) + if err != nil { + t.Fatalf("decode: %v", err) + } + if kind != ReasoningKindRedacted || data != "REDACTED_BLOB" { + t.Errorf("roundtrip kind=%q data=%q want redacted/REDACTED_BLOB", kind, data) + } +} + +func TestCustomToolDescriptionStripsFreeformHint(t *testing.T) { + toolsRaw, _ := common.Marshal([]map[string]any{{ + "type": "custom", + "name": "apply_patch", + "description": "Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON.\n\nInput must conform to the following lark grammar:\nstart: ...", + }}) + req := &dto.OpenAIResponsesRequest{ + Model: "claude-opus-4-7", + Input: []byte(`"hi"`), + Tools: toolsRaw, + } + claude, _, err := ConvertResponsesRequestToClaude(req) + if err != nil { + t.Fatalf("convert: %v", err) + } + tool := claude.Tools.([]any)[0].(*dto.Tool) + lower := strings.ToLower(tool.Description) + if strings.Contains(lower, "freeform") { + t.Errorf("freeform hint not stripped: %q", tool.Description) + } + if strings.Contains(tool.Description, "do not wrap the patch in JSON") { + t.Errorf("JSON-warning sentence not stripped: %q", tool.Description) + } + if !strings.Contains(tool.Description, "apply_patch") || !strings.Contains(tool.Description, "lark grammar") { + t.Errorf("legitimate description content removed: %q", tool.Description) + } +} + +func TestCustomToolConvertedToFunctionTool(t *testing.T) { + toolsRaw, _ := common.Marshal([]map[string]any{{ + "type": "custom", + "name": "my_custom", + "description": "do something", + "format": map[string]any{ + "type": "grammar", + "syntax": "regex", + "definition": "^[a-z]+$", + }, + }}) + req := &dto.OpenAIResponsesRequest{ + Model: "claude-opus-4-7", + Input: []byte(`"hi"`), + Tools: toolsRaw, + } + claude, _, err := ConvertResponsesRequestToClaude(req) + if err != nil { + t.Fatalf("convert: %v", err) + } + if len(claude.Tools.([]any)) != 1 { + t.Fatalf("tools count=%d want 1", len(claude.Tools.([]any))) + } + tool := claude.Tools.([]any)[0].(*dto.Tool) + if tool.Name != "my_custom" { + t.Errorf("name=%q want my_custom", tool.Name) + } + if !strings.Contains(tool.Description, "regex grammar") || !strings.Contains(tool.Description, "^[a-z]+$") { + t.Errorf("description missing grammar info: %q", tool.Description) + } + props, _ := tool.InputSchema["properties"].(map[string]any) + if _, ok := props["input"]; !ok { + t.Errorf("custom tool should have 'input' property, got %+v", tool.InputSchema) + } +} + +func TestNonStreamCustomToolCallRestoredWithNames(t *testing.T) { + cr := &dto.ClaudeResponse{ + Id: "msg_x", + Model: "claude-opus-4-7", + Role: "assistant", + Type: "message", + Content: []dto.ClaudeMediaMessage{ + {Type: "tool_use", Id: "toolu_apply", Name: "apply_patch", Input: map[string]any{"input": "*** Begin Patch\n*** End Patch\n"}}, + {Type: "tool_use", Id: "toolu_exec", Name: "exec_command", Input: map[string]any{"cmd": "ls"}}, + }, + StopReason: "tool_use", + Usage: &dto.ClaudeUsage{InputTokens: 10, OutputTokens: 20}, + } + resp := ConvertClaudeResponseToResponses(cr, map[string]bool{"apply_patch": true}) + if len(resp.Output) != 2 { + t.Fatalf("output count=%d want 2", len(resp.Output)) + } + if resp.Output[0].Type != "custom_tool_call" { + t.Errorf("output[0].type=%q want custom_tool_call", resp.Output[0].Type) + } + if resp.Output[0].Input != "*** Begin Patch\n*** End Patch\n" { + t.Errorf("output[0].input=%q want raw patch text", resp.Output[0].Input) + } + if resp.Output[1].Type != "function_call" { + t.Errorf("output[1].type=%q want function_call", resp.Output[1].Type) + } +} + +func TestStreamCustomToolCallTransparentRoundTrip(t *testing.T) { + state := NewClaudeResponsesStreamState("claude-opus-4-7") + state.CreatedAt = 1700000000 + state.ResponseID = "resp_x" + state.CustomToolNames = map[string]bool{"apply_patch": true} + + state.HandleClaudeChunk(&dto.ClaudeResponse{ + Type: "message_start", + Message: &dto.ClaudeMediaMessage{Id: "msg_1", Model: "claude-opus-4-7", Usage: &dto.ClaudeUsage{InputTokens: 10}}, + }) + state.HandleClaudeChunk(&dto.ClaudeResponse{ + Type: "content_block_start", + Index: ptrInt(0), + ContentBlock: &dto.ClaudeMediaMessage{Type: "tool_use", Id: "toolu_001", Name: "apply_patch"}, + }) + events1 := state.HandleClaudeChunk(&dto.ClaudeResponse{ + Type: "content_block_delta", + Index: ptrInt(0), + Delta: &dto.ClaudeMediaMessage{Type: "input_json_delta", PartialJson: ptrStr(`{"input":"abc`)}, + }) + for _, e := range events1 { + if e.Type == respEventFnCallArgsDelta { + t.Errorf("custom tool should not emit function_call_arguments.delta, got %+v", e) + } + } + deltaSeen := "" + for _, e := range events1 { + if e.Type == respEventCustomToolInputDelta { + deltaSeen += e.Delta + } + } + if deltaSeen != "abc" { + t.Errorf("first chunk delta=%q want abc", deltaSeen) + } + + events2 := state.HandleClaudeChunk(&dto.ClaudeResponse{ + Type: "content_block_delta", + Index: ptrInt(0), + Delta: &dto.ClaudeMediaMessage{Type: "input_json_delta", PartialJson: ptrStr(`def"}`)}, + }) + for _, e := range events2 { + if e.Type == respEventCustomToolInputDelta { + deltaSeen += e.Delta + } + } + if deltaSeen != "abcdef" { + t.Errorf("after second chunk delta accum=%q want abcdef", deltaSeen) + } + + stopEvents := state.HandleClaudeChunk(&dto.ClaudeResponse{Type: "content_block_stop", Index: ptrInt(0)}) + state.HandleClaudeChunk(&dto.ClaudeResponse{ + Type: "message_delta", + Delta: &dto.ClaudeMediaMessage{StopReason: ptrStr("tool_use")}, + }) + final := state.FinalEvents() + + gotTypes := []string{} + for _, e := range stopEvents { + gotTypes = append(gotTypes, e.Type) + } + wantInStop := []string{respEventCustomToolInputDone, respEventOutputItemDone} + for _, want := range wantInStop { + found := false + for _, got := range gotTypes { + if got == want { + found = true + break + } + } + if !found { + t.Errorf("stop events missing %q, got %v", want, gotTypes) + } + } + for _, e := range stopEvents { + if e.Type == respEventCustomToolInputDelta { + t.Errorf("custom_tool_call_input.delta should not appear in stop events (streamed earlier), got %+v", e) + } + if e.Type == respEventCustomToolInputDone { + if e.Input != "abcdef" { + t.Errorf("custom_tool_call_input.done.input=%q want abcdef", e.Input) + } + } + } + if final[0].Response == nil || len(final[0].Response.Output) != 1 { + t.Fatalf("final output count wrong: %+v", final[0].Response) + } + out := final[0].Response.Output[0] + if out.Type != "custom_tool_call" { + t.Errorf("final output[0].type=%q want custom_tool_call", out.Type) + } + if out.Input != "abcdef" { + t.Errorf("final output[0].input=%q want abcdef", out.Input) + } + if out.Name != "apply_patch" { + t.Errorf("final output[0].name=%q want apply_patch", out.Name) + } +} + +func TestBuiltinToolsStrippedSilently(t *testing.T) { + toolsRaw, _ := common.Marshal([]map[string]any{ + {"type": "function", "name": "exec", "parameters": map[string]any{"type": "object"}}, + {"type": "web_search", "external_web_access": true}, + {"type": "file_search"}, + {"type": "code_interpreter"}, + {"type": "image_generation"}, + {"type": "mcp"}, + }) + req := &dto.OpenAIResponsesRequest{Model: "claude-opus-4-7", Input: []byte(`"hi"`), Tools: toolsRaw} + claude, _, err := ConvertResponsesRequestToClaude(req) + if err != nil { + t.Fatalf("convert: %v", err) + } + tools := claude.Tools.([]any) + if len(tools) != 1 { + t.Errorf("tools count=%d want 1 (only function should survive)", len(tools)) + } + if fn, ok := tools[0].(*dto.Tool); !ok || fn.Name != "exec" { + t.Errorf("surviving tool wrong: %+v", tools[0]) + } +} + +func TestRequestAcceptsCustomToolCallEcho(t *testing.T) { + inputJSON := `[ + {"role":"user","content":"hi"}, + {"type":"custom_tool_call","status":"completed","call_id":"call_X","name":"apply_patch","input":"*** Begin Patch\n*** Add File: a.txt\n+hi\n*** End Patch\n"}, + {"type":"custom_tool_call_output","call_id":"call_X","output":"Success"} + ]` + req := &dto.OpenAIResponsesRequest{ + Model: "claude-opus-4-7", + Input: []byte(inputJSON), + } + claude, _, err := ConvertResponsesRequestToClaude(req) + if err != nil { + t.Fatalf("convert: %v", err) + } + if len(claude.Messages) < 3 { + t.Fatalf("expected at least 3 messages, got %d", len(claude.Messages)) + } + asst := claude.Messages[1] + if asst.Role != "assistant" { + t.Fatalf("messages[1].role=%q want assistant", asst.Role) + } + contents, _ := asst.ParseContent() + if len(contents) != 1 || contents[0].Type != "tool_use" || contents[0].Name != "apply_patch" || contents[0].Id != "call_X" { + t.Fatalf("expected tool_use(apply_patch, call_X), got %+v", contents) + } + inputMap, ok := contents[0].Input.(map[string]any) + if !ok { + t.Fatalf("input not a map: %T", contents[0].Input) + } + if got, _ := inputMap["input"].(string); got != "*** Begin Patch\n*** Add File: a.txt\n+hi\n*** End Patch\n" { + t.Errorf("input.input=%q want raw patch text", got) + } + + user := claude.Messages[2] + if user.Role != "user" { + t.Fatalf("messages[2].role=%q want user", user.Role) + } + userContents, _ := user.ParseContent() + if len(userContents) != 1 || userContents[0].Type != "tool_result" || userContents[0].ToolUseId != "call_X" { + t.Fatalf("expected tool_result(call_X), got %+v", userContents) + } +} + +func TestCustomToolNamesTracked(t *testing.T) { + toolsRaw, _ := common.Marshal([]map[string]any{ + {"type": "function", "name": "exec"}, + {"type": "custom", "name": "apply_patch"}, + {"type": "custom", "name": "freeform"}, + }) + req := &dto.OpenAIResponsesRequest{Model: "claude-opus-4-7", Input: []byte(`"hi"`), Tools: toolsRaw} + _, names, err := ConvertResponsesRequestToClaude(req) + if err != nil { + t.Fatalf("convert: %v", err) + } + if len(names) != 2 || !names["apply_patch"] || !names["freeform"] { + t.Errorf("customNames=%v want {apply_patch:true, freeform:true}", names) + } +} + +func TestDuplicateToolNamesRejected(t *testing.T) { + cases := []struct { + name string + tools []map[string]any + }{ + {"function-function dup", []map[string]any{ + {"type": "function", "name": "x", "parameters": map[string]any{"type": "object"}}, + {"type": "function", "name": "x", "parameters": map[string]any{"type": "object"}}, + }}, + {"function-custom dup", []map[string]any{ + {"type": "function", "name": "x", "parameters": map[string]any{"type": "object"}}, + {"type": "custom", "name": "x"}, + }}, + {"custom-custom dup", []map[string]any{ + {"type": "custom", "name": "x"}, + {"type": "custom", "name": "x"}, + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + raw, _ := common.Marshal(tc.tools) + req := &dto.OpenAIResponsesRequest{Model: "claude-opus-4-7", Input: []byte(`"hi"`), Tools: raw} + _, _, err := ConvertResponsesRequestToClaude(req) + if err == nil || !strings.Contains(err.Error(), "duplicate tool name") { + t.Errorf("expected duplicate name rejection, got %v", err) + } + }) + } +} + +func TestToolChoiceSanitizedAfterStrip(t *testing.T) { + // tool_choice 指向被剥离的工具应该被 unset + t.Run("function tool_choice pointing to stripped web_search", func(t *testing.T) { + toolsRaw, _ := common.Marshal([]map[string]any{ + {"type": "function", "name": "exec", "parameters": map[string]any{"type": "object"}}, + {"type": "web_search", "external_web_access": true}, + }) + tc, _ := common.Marshal(map[string]any{"type": "function", "name": "web_search"}) + req := &dto.OpenAIResponsesRequest{Model: "claude-opus-4-7", Input: []byte(`"hi"`), Tools: toolsRaw, ToolChoice: tc} + claude, _, err := ConvertResponsesRequestToClaude(req) + if err != nil { + t.Fatalf("convert: %v", err) + } + if claude.ToolChoice != nil { + t.Errorf("ToolChoice should be unset when pointing to stripped tool, got %+v", claude.ToolChoice) + } + }) + + t.Run("required with all tools stripped", func(t *testing.T) { + toolsRaw, _ := common.Marshal([]map[string]any{ + {"type": "web_search", "external_web_access": true}, + {"type": "file_search"}, + }) + tc, _ := common.Marshal("required") + req := &dto.OpenAIResponsesRequest{Model: "claude-opus-4-7", Input: []byte(`"hi"`), Tools: toolsRaw, ToolChoice: tc} + claude, _, err := ConvertResponsesRequestToClaude(req) + if err != nil { + t.Fatalf("convert: %v", err) + } + if claude.ToolChoice != nil { + t.Errorf("ToolChoice 'required' should be unset when no surviving tools, got %+v", claude.ToolChoice) + } + }) + + t.Run("required with surviving tools keeps any", func(t *testing.T) { + toolsRaw, _ := common.Marshal([]map[string]any{ + {"type": "function", "name": "exec", "parameters": map[string]any{"type": "object"}}, + {"type": "web_search", "external_web_access": true}, + }) + tc, _ := common.Marshal("required") + req := &dto.OpenAIResponsesRequest{Model: "claude-opus-4-7", Input: []byte(`"hi"`), Tools: toolsRaw, ToolChoice: tc} + claude, _, err := ConvertResponsesRequestToClaude(req) + if err != nil { + t.Fatalf("convert: %v", err) + } + ctc, _ := claude.ToolChoice.(*dto.ClaudeToolChoice) + if ctc == nil || ctc.Type != "any" { + t.Errorf("ToolChoice should be 'any' when surviving tools exist, got %+v", claude.ToolChoice) + } + }) + + t.Run("function tool_choice pointing to surviving function", func(t *testing.T) { + toolsRaw, _ := common.Marshal([]map[string]any{ + {"type": "function", "name": "exec", "parameters": map[string]any{"type": "object"}}, + }) + tc, _ := common.Marshal(map[string]any{"type": "function", "name": "exec"}) + req := &dto.OpenAIResponsesRequest{Model: "claude-opus-4-7", Input: []byte(`"hi"`), Tools: toolsRaw, ToolChoice: tc} + claude, _, err := ConvertResponsesRequestToClaude(req) + if err != nil { + t.Fatalf("convert: %v", err) + } + ctc, _ := claude.ToolChoice.(*dto.ClaudeToolChoice) + if ctc == nil || ctc.Type != "tool" || ctc.Name != "exec" { + t.Errorf("ToolChoice wrong: %+v", claude.ToolChoice) + } + }) +} + +func TestNonStreamCustomToolInputFallbackForMalformedOutput(t *testing.T) { + cr := &dto.ClaudeResponse{ + Id: "msg_x", + Model: "claude-opus-4-7", + Role: "assistant", + Type: "message", + Content: []dto.ClaudeMediaMessage{ + // 模型没遵守 schema:用了 text 而不是 input + {Type: "tool_use", Id: "toolu_a", Name: "apply_patch", Input: map[string]any{"text": "should not lose"}}, + }, + StopReason: "tool_use", + } + resp := ConvertClaudeResponseToResponses(cr, map[string]bool{"apply_patch": true}) + if len(resp.Output) != 1 { + t.Fatalf("output count=%d want 1", len(resp.Output)) + } + out := resp.Output[0] + if out.Type != "custom_tool_call" { + t.Errorf("type=%q want custom_tool_call", out.Type) + } + if !strings.Contains(out.Input, "should not lose") { + t.Errorf("input=%q should contain raw JSON fallback when schema deviates", out.Input) + } +} + +func TestCustomToolGrammarDescriptionTruncated(t *testing.T) { + huge := strings.Repeat("x", 16000) + toolsRaw, _ := common.Marshal([]map[string]any{{ + "type": "custom", + "name": "freeform", + "format": map[string]any{ + "type": "grammar", + "syntax": "lark", + "definition": huge, + }, + }}) + req := &dto.OpenAIResponsesRequest{Model: "claude-opus-4-7", Input: []byte(`"hi"`), Tools: toolsRaw} + claude, _, err := ConvertResponsesRequestToClaude(req) + if err != nil { + t.Fatalf("convert: %v", err) + } + tool := claude.Tools.([]any)[0].(*dto.Tool) + if len(tool.Description) > 10000 { + t.Errorf("description should be truncated, got len=%d", len(tool.Description)) + } + if !strings.Contains(tool.Description, "[grammar truncated]") { + t.Errorf("description should contain truncation marker, got %q", tool.Description[:200]) + } +} diff --git a/relay/channel/claude/responses_to_claude.go b/relay/channel/claude/responses_to_claude.go new file mode 100644 index 000000000000..9ed09985c827 --- /dev/null +++ b/relay/channel/claude/responses_to_claude.go @@ -0,0 +1,792 @@ +package claude + +import ( + "errors" + "fmt" + "regexp" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" +) + +// 不走 Chat Completions 中间格式,避免有损翻译丢失 thinking signature 等关键字段。 +// 第二个返回值为 OpenAI Responses 里 type:"custom" 工具的名字集合; +// 上游 Anthropic 无 custom tool 概念,所以请求侧降级成 function tool, +// 响应侧需要这个集合把 tool_use 还原成 custom_tool_call 实现透明往返。 +func ConvertResponsesRequestToClaude(req *dto.OpenAIResponsesRequest) (*dto.ClaudeRequest, map[string]bool, error) { + if req == nil { + return nil, nil, errors.New("request is nil") + } + if len(req.PreviousResponseID) > 0 { + return nil, nil, errors.New("previous_response_id is not supported when converting to Anthropic Messages API; pass the full conversation in input") + } + if len(req.Conversation) > 0 && !isJSONNull(req.Conversation) { + return nil, nil, errors.New("conversation is not supported when converting to Anthropic Messages API") + } + if format, present, err := extractTextFormatType(req.Text); err != nil { + return nil, nil, err + } else if present && format != "text" { + return nil, nil, fmt.Errorf("text.format=%q is not supported when converting to Anthropic Messages API", format) + } + + claude := &dto.ClaudeRequest{ + Model: req.Model, + Temperature: req.Temperature, + TopP: req.TopP, + Stream: req.Stream, + ServiceTier: req.ServiceTier, + } + if req.MaxOutputTokens != nil { + claude.MaxTokens = req.MaxOutputTokens + } + + system, err := buildSystemFromInstructions(req.Instructions) + if err != nil { + return nil, nil, err + } + claude.System = system + + messages, err := convertResponsesInputToClaudeMessages(req.Input) + if err != nil { + return nil, nil, err + } + claude.Messages = messages + + tools, customNames, survivingNames, err := convertResponsesToolsToClaudeTools(req.Tools) + if err != nil { + return nil, nil, err + } + if len(tools) > 0 { + claude.Tools = tools + } + + if tc, err := convertResponsesToolChoiceToClaude(req.ToolChoice, survivingNames); err != nil { + return nil, nil, err + } else if tc != nil { + claude.ToolChoice = tc + } + + if req.Reasoning != nil { + claude.Thinking = mapResponsesReasoningToClaudeThinking(req.Reasoning) + } + + if meta, err := convertResponsesMetadataToClaude(req.Metadata); err != nil { + return nil, nil, err + } else if meta != nil { + claude.Metadata = meta + } + + return claude, customNames, nil +} + +func isJSONNull(raw []byte) bool { + s := strings.TrimSpace(string(raw)) + return s == "" || s == "null" +} + +func extractTextFormatType(raw []byte) (formatType string, present bool, err error) { + if isJSONNull(raw) { + return "", false, nil + } + var text struct { + Format *struct { + Type string `json:"type"` + } `json:"format,omitempty"` + } + if err := common.Unmarshal(raw, &text); err != nil { + return "", false, err + } + if text.Format == nil { + return "", false, nil + } + return text.Format.Type, true, nil +} + +func buildSystemFromInstructions(raw []byte) (any, error) { + if isJSONNull(raw) { + return nil, nil + } + var asString string + if err := common.Unmarshal(raw, &asString); err == nil { + if asString == "" { + return nil, nil + } + return asString, nil + } + var asArray []any + if err := common.Unmarshal(raw, &asArray); err == nil { + blocks := make([]dto.ClaudeMediaMessage, 0, len(asArray)) + for _, item := range asArray { + m, ok := item.(map[string]any) + if !ok { + continue + } + if text, ok := stringFromInputTextPart(m); ok && text != "" { + blocks = append(blocks, dto.ClaudeMediaMessage{ + Type: "text", + Text: common.GetPointer(text), + }) + } + } + if len(blocks) == 0 { + return nil, nil + } + return blocks, nil + } + return nil, errors.New("instructions must be a string or an array of input_text parts") +} + +func stringFromInputTextPart(m map[string]any) (string, bool) { + t, _ := m["type"].(string) + if t != "" && t != "input_text" && t != "text" { + return "", false + } + if text, ok := m["text"].(string); ok { + return text, true + } + return "", false +} + +// Responses input 数组里可能混合:message / function_call / function_call_output / custom_tool_call / custom_tool_call_output / reasoning。 +// 多个相邻同 role 的 item 需要合并到同一个 Claude message 的 content blocks 里, +// 这是 Anthropic 协议的硬要求:thinking → tool_use → text 等都属于同一 assistant turn。 +func convertResponsesInputToClaudeMessages(rawInput []byte) ([]dto.ClaudeMessage, error) { + if isJSONNull(rawInput) { + return nil, errors.New("input is required") + } + var asString string + if err := common.Unmarshal(rawInput, &asString); err == nil { + return []dto.ClaudeMessage{{ + Role: "user", + Content: asString, + }}, nil + } + + var items []map[string]any + if err := common.Unmarshal(rawInput, &items); err != nil { + return nil, fmt.Errorf("input must be a string or an array of items: %w", err) + } + + messages := make([]dto.ClaudeMessage, 0, len(items)) + for _, item := range items { + role, blocks, err := convertResponsesInputItem(item) + if err != nil { + return nil, err + } + if len(blocks) == 0 { + continue + } + if n := len(messages); n > 0 && messages[n-1].Role == role { + if existing, ok := messages[n-1].Content.([]dto.ClaudeMediaMessage); ok { + if err := assertReasoningOrder(existing, blocks); err != nil { + return nil, err + } + messages[n-1].Content = append(existing, blocks...) + continue + } + } + messages = append(messages, dto.ClaudeMessage{ + Role: role, + Content: blocks, + }) + } + if len(messages) == 0 { + return nil, errors.New("input did not produce any messages") + } + return messages, nil +} + +// Anthropic 协议要求 thinking/redacted_thinking 必须排在同一 assistant message 的非-thinking 块之前。 +// 客户端按 OpenAI Responses 顺序拼回 reasoning item 时,若上一个 assistant message 已经有 text/tool_use, +// 再把 reasoning 追加进去就会违反此约束并被 Claude 拒绝;直接 400 避免静默拼成非法请求。 +func assertReasoningOrder(existing, incoming []dto.ClaudeMediaMessage) error { + hasIncomingReasoning := false + for _, b := range incoming { + if b.Type == "thinking" || b.Type == "redacted_thinking" { + hasIncomingReasoning = true + break + } + } + if !hasIncomingReasoning { + return nil + } + for _, b := range existing { + if b.Type != "thinking" && b.Type != "redacted_thinking" { + return errors.New("reasoning item must precede non-reasoning content within the same assistant turn; reorder your input so reasoning comes before message/function_call items") + } + } + return nil +} + +func convertResponsesInputItem(item map[string]any) (role string, blocks []dto.ClaudeMediaMessage, err error) { + itemType, _ := item["type"].(string) + switch itemType { + case "", "message": + return convertResponsesInputMessage(item) + case "function_call": + blk, err := convertResponsesInputFunctionCall(item) + if err != nil { + return "", nil, err + } + return "assistant", []dto.ClaudeMediaMessage{blk}, nil + case "function_call_output": + blk, err := convertResponsesInputFunctionCallOutput(item) + if err != nil { + return "", nil, err + } + return "user", []dto.ClaudeMediaMessage{blk}, nil + case "custom_tool_call": + // 客户端把上一轮我们返回的 custom_tool_call 回传给我们。因为请求侧把 custom tool 降级为 + // {input: string} schema 的 function tool,所以这里要把 raw string 重新包成 {"input": ...} + // 才与 Anthropic 上游已知的 tool schema 对得上。 + blk, err := convertResponsesInputCustomToolCall(item) + if err != nil { + return "", nil, err + } + return "assistant", []dto.ClaudeMediaMessage{blk}, nil + case "custom_tool_call_output": + blk, err := convertResponsesInputFunctionCallOutput(item) + if err != nil { + return "", nil, err + } + return "user", []dto.ClaudeMediaMessage{blk}, nil + case "reasoning": + blk, err := convertResponsesInputReasoning(item) + if err != nil { + return "", nil, err + } + if blk == nil { + return "", nil, nil + } + return "assistant", []dto.ClaudeMediaMessage{*blk}, nil + case "item_reference": + return "", nil, errors.New("item_reference is not supported when converting to Anthropic Messages API") + case "web_search_call", "file_search_call", "code_interpreter_call", + "image_generation_call", "computer_call", "computer_call_output", + "local_shell_call", "mcp_call", "mcp_list_tools", + "mcp_approval_request", "mcp_approval_response": + return "", nil, fmt.Errorf("input item type %q is not supported when converting to Anthropic Messages API", itemType) + } + return "", nil, fmt.Errorf("unknown input item type %q", itemType) +} + +func convertResponsesInputMessage(item map[string]any) (string, []dto.ClaudeMediaMessage, error) { + role, _ := item["role"].(string) + if role == "" { + role = "user" + } + switch role { + case "user", "assistant": + case "system", "developer": + role = "user" + default: + return "", nil, fmt.Errorf("unknown message role %q", role) + } + + content := item["content"] + if content == nil { + return role, nil, nil + } + if s, ok := content.(string); ok { + if s == "" { + return role, nil, nil + } + return role, []dto.ClaudeMediaMessage{{ + Type: "text", + Text: common.GetPointer(s), + }}, nil + } + + parts, ok := content.([]any) + if !ok { + return "", nil, fmt.Errorf("message content must be string or array, got %T", content) + } + blocks := make([]dto.ClaudeMediaMessage, 0, len(parts)) + for _, p := range parts { + pm, ok := p.(map[string]any) + if !ok { + continue + } + blk, err := convertResponsesContentPart(role, pm) + if err != nil { + return "", nil, err + } + if blk != nil { + blocks = append(blocks, *blk) + } + } + return role, blocks, nil +} + +func convertResponsesContentPart(role string, part map[string]any) (*dto.ClaudeMediaMessage, error) { + partType, _ := part["type"].(string) + switch partType { + case "input_text", "text": + text, _ := part["text"].(string) + if text == "" { + return nil, nil + } + return &dto.ClaudeMediaMessage{Type: "text", Text: common.GetPointer(text)}, nil + case "output_text": + text, _ := part["text"].(string) + if text == "" { + return nil, nil + } + return &dto.ClaudeMediaMessage{Type: "text", Text: common.GetPointer(text)}, nil + case "refusal": + text, _ := part["refusal"].(string) + if text == "" { + return nil, nil + } + return &dto.ClaudeMediaMessage{Type: "text", Text: common.GetPointer(text)}, nil + case "input_image": + return convertResponsesInputImage(part) + case "input_file": + return convertResponsesInputFile(part) + case "input_audio": + return nil, errors.New("input_audio is not supported by Anthropic Messages API") + } + return nil, fmt.Errorf("unknown content part type %q", partType) +} + +func convertResponsesInputImage(part map[string]any) (*dto.ClaudeMediaMessage, error) { + src := &dto.ClaudeMessageSource{} + if url, _ := part["image_url"].(string); url != "" { + if mediaType, data, ok := parseDataURL(url); ok { + src.Type = "base64" + src.MediaType = mediaType + src.Data = data + } else { + src.Type = "url" + src.Url = url + } + return &dto.ClaudeMediaMessage{Type: "image", Source: src}, nil + } + if fileID, _ := part["file_id"].(string); fileID != "" { + return nil, errors.New("input_image by file_id is not supported when converting to Anthropic Messages API") + } + return nil, errors.New("input_image requires image_url") +} + +func convertResponsesInputFile(part map[string]any) (*dto.ClaudeMediaMessage, error) { + if url, _ := part["file_url"].(string); url != "" { + if mediaType, data, ok := parseDataURL(url); ok { + return &dto.ClaudeMediaMessage{ + Type: "document", + Source: &dto.ClaudeMessageSource{ + Type: "base64", + MediaType: mediaType, + Data: data, + }, + }, nil + } + return &dto.ClaudeMediaMessage{ + Type: "document", + Source: &dto.ClaudeMessageSource{ + Type: "url", + Url: url, + }, + }, nil + } + if data, _ := part["file_data"].(string); data != "" { + mediaType := "application/pdf" + if mt, ok := part["mime_type"].(string); ok && mt != "" { + mediaType = mt + } + return &dto.ClaudeMediaMessage{ + Type: "document", + Source: &dto.ClaudeMessageSource{ + Type: "base64", + MediaType: mediaType, + Data: data, + }, + }, nil + } + return nil, errors.New("input_file requires file_url or file_data") +} + +func parseDataURL(url string) (mediaType, data string, ok bool) { + if !strings.HasPrefix(url, "data:") { + return "", "", false + } + rest := strings.TrimPrefix(url, "data:") + idx := strings.Index(rest, ";base64,") + if idx < 0 { + return "", "", false + } + return rest[:idx], rest[idx+len(";base64,"):], true +} + +func convertResponsesInputFunctionCall(item map[string]any) (dto.ClaudeMediaMessage, error) { + callID, _ := item["call_id"].(string) + name, _ := item["name"].(string) + if callID == "" || name == "" { + return dto.ClaudeMediaMessage{}, errors.New("function_call requires call_id and name") + } + args := item["arguments"] + var input any + switch v := args.(type) { + case string: + if v != "" { + if err := common.UnmarshalJsonStr(v, &input); err != nil { + input = v + } + } else { + input = map[string]any{} + } + case nil: + input = map[string]any{} + default: + input = v + } + return dto.ClaudeMediaMessage{ + Type: "tool_use", + Id: callID, + Name: name, + Input: input, + }, nil +} + +func convertResponsesInputCustomToolCall(item map[string]any) (dto.ClaudeMediaMessage, error) { + callID, _ := item["call_id"].(string) + name, _ := item["name"].(string) + if callID == "" || name == "" { + return dto.ClaudeMediaMessage{}, errors.New("custom_tool_call requires call_id and name") + } + input, _ := item["input"].(string) + return dto.ClaudeMediaMessage{ + Type: "tool_use", + Id: callID, + Name: name, + Input: map[string]any{ + "input": input, + }, + }, nil +} + +func convertResponsesInputFunctionCallOutput(item map[string]any) (dto.ClaudeMediaMessage, error) { + callID, _ := item["call_id"].(string) + if callID == "" { + return dto.ClaudeMediaMessage{}, errors.New("function_call_output requires call_id") + } + out := item["output"] + var content any + switch v := out.(type) { + case string: + content = v + case []any: + content = v + case map[string]any: + raw, err := common.Marshal(v) + if err != nil { + return dto.ClaudeMediaMessage{}, err + } + content = string(raw) + case nil: + content = "" + default: + raw, err := common.Marshal(v) + if err != nil { + return dto.ClaudeMediaMessage{}, err + } + content = string(raw) + } + return dto.ClaudeMediaMessage{ + Type: "tool_result", + ToolUseId: callID, + Content: content, + }, nil +} + +// 签名严格依赖 encrypted_content 解出来的值;只有 summary 文字不足以让 Claude 验签通过。 +func convertResponsesInputReasoning(item map[string]any) (*dto.ClaudeMediaMessage, error) { + encrypted, _ := item["encrypted_content"].(string) + kind, signature, data, err := DecodeReasoningEncryptedContent(encrypted) + if err != nil { + return nil, err + } + + if kind == ReasoningKindRedacted && data != "" { + return &dto.ClaudeMediaMessage{ + Type: "redacted_thinking", + Data: data, + }, nil + } + + var thinking string + if summary, ok := item["summary"].([]any); ok { + parts := make([]string, 0, len(summary)) + for _, s := range summary { + sm, ok := s.(map[string]any) + if !ok { + continue + } + if text, _ := sm["text"].(string); text != "" { + parts = append(parts, text) + } + } + thinking = strings.Join(parts, "") + } + if thinking == "" { + if content, ok := item["content"].([]any); ok { + parts := make([]string, 0, len(content)) + for _, c := range content { + cm, ok := c.(map[string]any) + if !ok { + continue + } + if text, _ := cm["text"].(string); text != "" { + parts = append(parts, text) + } + } + thinking = strings.Join(parts, "") + } + } + + if signature == "" && thinking == "" { + return nil, nil + } + + blk := &dto.ClaudeMediaMessage{ + Type: "thinking", + Thinking: common.GetPointer(thinking), + Signature: signature, + } + return blk, nil +} + +func convertResponsesToolsToClaudeTools(raw []byte) ([]any, map[string]bool, map[string]bool, error) { + if isJSONNull(raw) { + return nil, nil, nil, nil + } + var tools []map[string]any + if err := common.Unmarshal(raw, &tools); err != nil { + return nil, nil, nil, fmt.Errorf("tools must be an array: %w", err) + } + result := make([]any, 0, len(tools)) + var customNames map[string]bool + survivingNames := map[string]bool{} + for _, t := range tools { + ty, _ := t["type"].(string) + switch ty { + case "function": + tool, err := convertResponsesFunctionToolToClaude(t) + if err != nil { + return nil, nil, nil, err + } + if survivingNames[tool.Name] { + return nil, nil, nil, fmt.Errorf("duplicate tool name %q", tool.Name) + } + survivingNames[tool.Name] = true + result = append(result, tool) + case "custom": + tool, err := convertResponsesCustomToolToClaude(t) + if err != nil { + return nil, nil, nil, err + } + if survivingNames[tool.Name] { + return nil, nil, nil, fmt.Errorf("duplicate tool name %q (function/custom name conflict cannot be disambiguated when round-tripping through Anthropic)", tool.Name) + } + survivingNames[tool.Name] = true + result = append(result, tool) + if customNames == nil { + customNames = map[string]bool{} + } + customNames[tool.Name] = true + case "web_search_preview", "web_search", + "file_search", + "code_interpreter", + "computer_use_preview", "computer", + "image_generation", + "mcp": + // 上游 Anthropic 不支持这些 OpenAI 内置服务端工具(或者支持但需要单独开通/付费/语义不一致), + // 静默剥离避免转发到上游导致 schema 错误或意外计费;模型不会看到这些工具,行为等价于客户端没传。 + continue + default: + return nil, nil, nil, fmt.Errorf("unsupported tool type %q", ty) + } + } + return result, customNames, survivingNames, nil +} + +// Anthropic 无 free-text/grammar 输入工具的原生对应;把 OpenAI custom tool 降级为接受单个 input string 的 function tool。 +// grammar 约束(lark/regex)作为描述注入,依赖模型自觉遵守,协议层不强制。 +func convertResponsesCustomToolToClaude(t map[string]any) (*dto.Tool, error) { + name, _ := t["name"].(string) + if name == "" { + return nil, errors.New("custom tool requires name") + } + desc, _ := t["description"].(string) + // custom tool 在 OpenAI 是 freeform 文本输入,但我们降级成 {input: string} 的 function tool 后 + // 输入会被 JSON 包一层,原描述里「不要用 JSON 包装」之类的提示会与实际协议矛盾,需要剥掉。 + desc = stripFreeformHint(desc) + if format, ok := t["format"].(map[string]any); ok { + if ftype, _ := format["type"].(string); ftype == "grammar" { + syntax, _ := format["syntax"].(string) + definition, _ := format["definition"].(string) + if definition != "" { + const maxGrammarBytes = 8192 + truncated := false + if len(definition) > maxGrammarBytes { + definition = definition[:maxGrammarBytes] + truncated = true + } + if desc != "" { + desc += "\n\n" + } + desc += "Input must conform to the following " + syntax + " grammar:\n" + definition + if truncated { + desc += "\n[grammar truncated]" + } + } + } + } + return &dto.Tool{ + Name: name, + Description: desc, + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "input": map[string]any{ + "type": "string", + "description": "The raw input string for this custom tool.", + }, + }, + "required": []any{"input"}, + }, + }, nil +} + +func convertResponsesFunctionToolToClaude(t map[string]any) (*dto.Tool, error) { + name, _ := t["name"].(string) + if name == "" { + return nil, errors.New("function tool requires name") + } + desc, _ := t["description"].(string) + params, _ := t["parameters"].(map[string]any) + if params == nil { + params = map[string]any{"type": "object", "properties": map[string]any{}} + } + tool := &dto.Tool{ + Name: name, + Description: desc, + InputSchema: make(map[string]any, len(params)), + } + for k, v := range params { + tool.InputSchema[k] = v + } + if _, ok := tool.InputSchema["type"]; !ok { + tool.InputSchema["type"] = "object" + } + return tool, nil +} + +// Responses tool_choice 与 Chat Completions 不同:function 形态是 {type,name} 而非 {type,function:{name}}。 +// 单独实现,不复用 chat 版本的 mapToolChoice。 +// 接收 surviving tool name 集合:内置工具被静默剥离后,tool_choice 指向已剥离工具或 required+空 tools +// 都必须 unset,否则 Anthropic 会 400。 +func convertResponsesToolChoiceToClaude(raw []byte, survivingTools map[string]bool) (*dto.ClaudeToolChoice, error) { + if isJSONNull(raw) { + return nil, nil + } + hasSurviving := len(survivingTools) > 0 + var asString string + if err := common.Unmarshal(raw, &asString); err == nil { + switch asString { + case "auto": + if !hasSurviving { + return nil, nil + } + return &dto.ClaudeToolChoice{Type: "auto"}, nil + case "required": + if !hasSurviving { + return nil, nil + } + return &dto.ClaudeToolChoice{Type: "any"}, nil + case "none": + return &dto.ClaudeToolChoice{Type: "none"}, nil + case "": + return nil, nil + default: + return nil, fmt.Errorf("unknown tool_choice %q", asString) + } + } + var asObject map[string]any + if err := common.Unmarshal(raw, &asObject); err != nil { + return nil, fmt.Errorf("tool_choice must be string or object: %w", err) + } + ty, _ := asObject["type"].(string) + switch ty { + case "function", "custom": + name, _ := asObject["name"].(string) + if name == "" { + return nil, errors.New("tool_choice." + ty + " requires name") + } + if !survivingTools[name] { + return nil, nil + } + return &dto.ClaudeToolChoice{Type: "tool", Name: name}, nil + case "allowed_tools": + return nil, errors.New("tool_choice.allowed_tools is not supported when converting to Anthropic Messages API; downgrading to auto would silently broaden the allowed tool set") + case "auto", "": + if !hasSurviving { + return nil, nil + } + return &dto.ClaudeToolChoice{Type: "auto"}, nil + case "none": + return &dto.ClaudeToolChoice{Type: "none"}, nil + } + return nil, fmt.Errorf("unsupported tool_choice type %q", ty) +} + +// Anthropic adaptive thinking 没有 effort 等级,只有 type+display; +// 强度信息在 adaptive 下由模型自决,effort 的低/中/高被吞掉。 +func mapResponsesReasoningToClaudeThinking(r *dto.Reasoning) *dto.Thinking { + if r == nil { + return nil + } + if r.Effort == "minimal" { + return &dto.Thinking{Type: "disabled"} + } + t := &dto.Thinking{Type: "adaptive"} + switch r.Summary { + case "none": + t.Display = "omitted" + case "auto", "concise", "detailed", "": + t.Display = "summarized" + default: + t.Display = "summarized" + } + return t +} + +func convertResponsesMetadataToClaude(raw []byte) ([]byte, error) { + if isJSONNull(raw) { + return nil, nil + } + var meta map[string]any + if err := common.Unmarshal(raw, &meta); err != nil { + return nil, err + } + if userID, ok := meta["user_id"].(string); ok && userID != "" { + out, err := common.Marshal(map[string]string{"user_id": userID}) + if err != nil { + return nil, err + } + return out, nil + } + return nil, nil +} + +// 匹配 OpenAI Codex apply_patch 等 custom tool 描述里「This is a FREEFORM tool, so do not wrap the patch in JSON.」之类的整句。 +// 我们已把 custom tool 降级为 {input: string},再保留这句话会让模型拒绝按 schema 输出。 +var freeformHintRE = regexp.MustCompile(`(?i)[^.\n]*\bfreeform\b[^.\n]*(?:\.|\n|$)`) + +func stripFreeformHint(desc string) string { + if desc == "" { + return desc + } + cleaned := freeformHintRE.ReplaceAllString(desc, "") + return strings.TrimSpace(cleaned) +}