| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662 |
- package apicompat
- import (
- "crypto/rand"
- "encoding/hex"
- "encoding/json"
- "fmt"
- "time"
- )
- // ---------------------------------------------------------------------------
- // Non-streaming: AnthropicResponse → ResponsesResponse
- // ---------------------------------------------------------------------------
- // AnthropicToResponsesResponse converts an Anthropic Messages response into a
- // Responses API response. This is the reverse of ResponsesToAnthropic and
- // enables Anthropic upstream responses to be returned in OpenAI Responses format.
- func AnthropicToResponsesResponse(resp *AnthropicResponse) *ResponsesResponse {
- id := resp.ID
- if id == "" {
- id = generateResponsesID()
- }
- // Anthropic responses carry no creation timestamp, so stamp now — the same
- // synthesize-what-the-client-requires rule the generated id above follows.
- out := &ResponsesResponse{
- ID: id,
- Object: "response",
- CreatedAt: time.Now().Unix(),
- Model: resp.Model,
- }
- var outputs []ResponsesOutput
- var msgParts []ResponsesContentPart
- for _, block := range resp.Content {
- switch block.Type {
- case "thinking":
- if block.Thinking != "" {
- outputs = append(outputs, ResponsesOutput{
- Type: "reasoning",
- ID: generateItemID(),
- Summary: []ResponsesSummary{{
- Type: "summary_text",
- Text: block.Thinking,
- }},
- })
- }
- case "text":
- if block.Text != "" {
- msgParts = append(msgParts, ResponsesContentPart{
- Type: "output_text",
- Text: block.Text,
- })
- }
- case "tool_use":
- args := "{}"
- if len(block.Input) > 0 {
- args = string(block.Input)
- }
- outputs = append(outputs, ResponsesOutput{
- Type: "function_call",
- ID: generateItemID(),
- CallID: toResponsesCallID(block.ID),
- Name: block.Name,
- Arguments: args,
- Status: "completed",
- })
- }
- }
- // Assemble message output item from text parts
- if len(msgParts) > 0 {
- outputs = append(outputs, ResponsesOutput{
- Type: "message",
- ID: generateItemID(),
- Role: "assistant",
- Content: msgParts,
- Status: "completed",
- })
- }
- if len(outputs) == 0 {
- outputs = append(outputs, ResponsesOutput{
- Type: "message",
- ID: generateItemID(),
- Role: "assistant",
- Content: []ResponsesContentPart{{Type: "output_text", Text: ""}},
- Status: "completed",
- })
- }
- out.Output = outputs
- // Map stop_reason → status
- out.Status = anthropicStopReasonToResponsesStatus(AnthropicStopReasonString(resp.StopReason), resp.Content)
- if out.Status == "incomplete" {
- out.IncompleteDetails = &ResponsesIncompleteDetails{Reason: "max_output_tokens"}
- }
- // Usage
- // Anthropic's input_tokens excludes cache_read/cache_creation, while OpenAI
- // Responses' input_tokens is the total including cached tokens. Add them back
- // when converting so downstream consumers see OpenAI semantics.
- totalInputTokens := resp.Usage.InputTokens +
- resp.Usage.CacheReadInputTokens +
- resp.Usage.CacheCreationInputTokens
- out.Usage = &ResponsesUsage{
- InputTokens: totalInputTokens,
- OutputTokens: resp.Usage.OutputTokens,
- TotalTokens: totalInputTokens + resp.Usage.OutputTokens,
- CacheCreationInputTokens: resp.Usage.CacheCreationInputTokens,
- }
- if resp.Usage.CacheReadInputTokens > 0 {
- out.Usage.InputTokensDetails = &ResponsesInputTokensDetails{
- CachedTokens: resp.Usage.CacheReadInputTokens,
- }
- }
- return out
- }
- // anthropicStopReasonToResponsesStatus maps Anthropic stop_reason to Responses status.
- func anthropicStopReasonToResponsesStatus(stopReason string, blocks []AnthropicContentBlock) string {
- switch stopReason {
- case "max_tokens":
- return "incomplete"
- case "end_turn", "tool_use", "stop_sequence":
- return "completed"
- default:
- return "completed"
- }
- }
- // ---------------------------------------------------------------------------
- // Streaming: AnthropicStreamEvent → []ResponsesStreamEvent (stateful converter)
- // ---------------------------------------------------------------------------
- // AnthropicEventToResponsesState tracks state for converting a sequence of
- // Anthropic SSE events into Responses SSE events.
- type AnthropicEventToResponsesState struct {
- ResponseID string
- Model string
- Created int64
- SequenceNumber int
- // CreatedSent tracks whether response.created has been emitted.
- CreatedSent bool
- // CompletedSent tracks whether the terminal event has been emitted.
- CompletedSent bool
- // Current output tracking
- OutputIndex int
- CurrentItemID string
- CurrentItemType string // "message" | "function_call" | "reasoning"
- // For message output: accumulate text parts
- ContentIndex int
- // TextAccum accumulates the current text part so that output_text.done and
- // content_part.done can carry the full text (deltas carry increments only).
- TextAccum string
- // For function_call: track per-output info
- CurrentCallID string
- CurrentName string
- // Content of the currently open item, folded into Outputs when it closes.
- CurrentContent []ResponsesContentPart // message
- CurrentArgs string // function_call
- CurrentSummary string // reasoning
- // Outputs accumulates every closed output item so that response.completed
- // can carry the full output list. The OpenAI SDK's get_final_response()
- // parses the terminal event's response directly; without this, clients see
- // an empty output_text.
- Outputs []ResponsesOutput
- // Usage from message_start / message_delta. InputTokens here follows
- // Anthropic semantics (excludes cached tokens); they are added back when
- // emitting the OpenAI Responses usage.
- InputTokens int
- OutputTokens int
- CacheReadInputTokens int
- CacheCreationInputTokens int
- StopReason string
- }
- // NewAnthropicEventToResponsesState returns an initialised stream state.
- func NewAnthropicEventToResponsesState() *AnthropicEventToResponsesState {
- return &AnthropicEventToResponsesState{
- Created: time.Now().Unix(),
- }
- }
- // AnthropicEventToResponsesEvents converts a single Anthropic SSE event into
- // zero or more Responses SSE events, updating state as it goes.
- func AnthropicEventToResponsesEvents(
- evt *AnthropicStreamEvent,
- state *AnthropicEventToResponsesState,
- ) []ResponsesStreamEvent {
- switch evt.Type {
- case "message_start":
- return anthToResHandleMessageStart(evt, state)
- case "content_block_start":
- return anthToResHandleContentBlockStart(evt, state)
- case "content_block_delta":
- return anthToResHandleContentBlockDelta(evt, state)
- case "content_block_stop":
- return anthToResHandleContentBlockStop(evt, state)
- case "message_delta":
- return anthToResHandleMessageDelta(evt, state)
- case "message_stop":
- return anthToResHandleMessageStop(state)
- default:
- return nil
- }
- }
- // FinalizeAnthropicResponsesStream emits synthetic termination events if the
- // stream ended without a proper message_stop.
- func FinalizeAnthropicResponsesStream(state *AnthropicEventToResponsesState) []ResponsesStreamEvent {
- if !state.CreatedSent || state.CompletedSent {
- return nil
- }
- var events []ResponsesStreamEvent
- // Close any open item
- events = append(events, closeCurrentResponsesItem(state)...)
- status, incompleteDetails := anthropicResponsesStreamTerminalState(state.StopReason)
- events = append(events, makeResponsesCompletedEvent(state, status, incompleteDetails))
- state.CompletedSent = true
- return events
- }
- // ResponsesEventToSSE formats a ResponsesStreamEvent as an SSE data line.
- func ResponsesEventToSSE(evt ResponsesStreamEvent) (string, error) {
- data, err := json.Marshal(evt)
- if err != nil {
- return "", err
- }
- return fmt.Sprintf("event: %s\ndata: %s\n\n", evt.Type, data), nil
- }
- // --- internal handlers ---
- func anthToResHandleMessageStart(evt *AnthropicStreamEvent, state *AnthropicEventToResponsesState) []ResponsesStreamEvent {
- if evt.Message != nil {
- state.ResponseID = evt.Message.ID
- if state.Model == "" {
- state.Model = evt.Message.Model
- }
- if evt.Message.Usage.InputTokens > 0 {
- state.InputTokens = evt.Message.Usage.InputTokens
- }
- if evt.Message.Usage.CacheReadInputTokens > 0 {
- state.CacheReadInputTokens = evt.Message.Usage.CacheReadInputTokens
- }
- if evt.Message.Usage.CacheCreationInputTokens > 0 {
- state.CacheCreationInputTokens = evt.Message.Usage.CacheCreationInputTokens
- }
- }
- if state.CreatedSent {
- return nil
- }
- state.CreatedSent = true
- // Emit response.created
- return []ResponsesStreamEvent{makeResponsesCreatedEvent(state)}
- }
- func anthToResHandleContentBlockStart(evt *AnthropicStreamEvent, state *AnthropicEventToResponsesState) []ResponsesStreamEvent {
- if evt.ContentBlock == nil {
- return nil
- }
- var events []ResponsesStreamEvent
- switch evt.ContentBlock.Type {
- case "thinking":
- // 开新 item 前必须先关掉在开的那个,与下面的 tool_use 分支一致。
- // 一个 message item 在它的 text 块 content_block_stop 时是刻意保持打开的
- // (同一 item 里可能还有后续 text 块),所以 thinking 块到来时它仍然开着:
- // 不关就直接被 CurrentItemType/CurrentItemID 覆盖,累积在 CurrentContent
- // 里的助手文本既不会进 state.Outputs,也拿不到 output_item.done,
- // response.completed 于是只带 reasoning——客户端看到的是「成功但无输出」。
- // 交错思考(interleaved-thinking,本仓库在 anthropic-beta 透传里明确支持)
- // 会稳定产生 text → thinking 这个顺序。
- events = append(events, closeCurrentResponsesItem(state)...)
- state.CurrentItemID = generateItemID()
- state.CurrentItemType = "reasoning"
- state.ContentIndex = 0
- events = append(events, makeResponsesEvent(state, "response.output_item.added", &ResponsesStreamEvent{
- OutputIndex: state.OutputIndex,
- Item: &ResponsesOutput{
- Type: "reasoning",
- ID: state.CurrentItemID,
- },
- }))
- case "text":
- // If we don't have an open message item, open one
- if state.CurrentItemType != "message" {
- // 走到这里时 CurrentItemType 只可能是 ""(前一个 reasoning/function_call
- // 已在自己的 content_block_stop 里关闭);保留这次关闭是为了让三个分支
- // 的「开新 item 前先关旧的」保持同一条不变式,而不是留一个仅 text 例外。
- events = append(events, closeCurrentResponsesItem(state)...)
- state.CurrentItemID = generateItemID()
- state.CurrentItemType = "message"
- state.ContentIndex = 0
- events = append(events, makeResponsesEvent(state, "response.output_item.added", &ResponsesStreamEvent{
- OutputIndex: state.OutputIndex,
- Item: &ResponsesOutput{
- Type: "message",
- ID: state.CurrentItemID,
- Role: "assistant",
- Status: "in_progress",
- },
- }))
- }
- // response.content_part.added must precede the output_text.delta events
- // for that part. The message item is added with content: [], and the
- // OpenAI SDK's accumulating stream helper (client.responses.stream) only
- // appends a content part when it sees content_part.added. Without it the
- // following output_text.delta indexes output.content[content_index] and
- // raises IndexError. Raw event iteration
- // (responses.create(stream=True)) does not accumulate, which is why this
- // went unnoticed.
- events = append(events, makeResponsesEvent(state, "response.content_part.added", &ResponsesStreamEvent{
- OutputIndex: state.OutputIndex,
- ContentIndex: state.ContentIndex,
- ItemID: state.CurrentItemID,
- Part: &ResponsesContentPart{Type: "output_text", Text: ""},
- }))
- state.TextAccum = ""
- case "tool_use":
- // Close previous item if any
- events = append(events, closeCurrentResponsesItem(state)...)
- state.CurrentItemID = generateItemID()
- state.CurrentItemType = "function_call"
- state.CurrentCallID = toResponsesCallID(evt.ContentBlock.ID)
- state.CurrentName = evt.ContentBlock.Name
- events = append(events, makeResponsesEvent(state, "response.output_item.added", &ResponsesStreamEvent{
- OutputIndex: state.OutputIndex,
- Item: &ResponsesOutput{
- Type: "function_call",
- ID: state.CurrentItemID,
- CallID: state.CurrentCallID,
- Name: state.CurrentName,
- Status: "in_progress",
- },
- }))
- }
- return events
- }
- func anthToResHandleContentBlockDelta(evt *AnthropicStreamEvent, state *AnthropicEventToResponsesState) []ResponsesStreamEvent {
- if evt.Delta == nil {
- return nil
- }
- switch evt.Delta.Type {
- case "text_delta":
- if evt.Delta.Text == "" {
- return nil
- }
- state.TextAccum += evt.Delta.Text
- return []ResponsesStreamEvent{makeResponsesEvent(state, "response.output_text.delta", &ResponsesStreamEvent{
- OutputIndex: state.OutputIndex,
- ContentIndex: state.ContentIndex,
- Delta: evt.Delta.Text,
- ItemID: state.CurrentItemID,
- })}
- case "thinking_delta":
- if evt.Delta.Thinking == "" {
- return nil
- }
- state.CurrentSummary += evt.Delta.Thinking
- return []ResponsesStreamEvent{makeResponsesEvent(state, "response.reasoning_summary_text.delta", &ResponsesStreamEvent{
- OutputIndex: state.OutputIndex,
- SummaryIndex: 0,
- Delta: evt.Delta.Thinking,
- ItemID: state.CurrentItemID,
- })}
- case "input_json_delta":
- if evt.Delta.PartialJSON == "" {
- return nil
- }
- state.CurrentArgs += evt.Delta.PartialJSON
- return []ResponsesStreamEvent{makeResponsesEvent(state, "response.function_call_arguments.delta", &ResponsesStreamEvent{
- OutputIndex: state.OutputIndex,
- Delta: evt.Delta.PartialJSON,
- ItemID: state.CurrentItemID,
- CallID: state.CurrentCallID,
- Name: state.CurrentName,
- })}
- case "signature_delta":
- // Anthropic signature deltas have no Responses equivalent; skip
- return nil
- }
- return nil
- }
- func anthToResHandleContentBlockStop(evt *AnthropicStreamEvent, state *AnthropicEventToResponsesState) []ResponsesStreamEvent {
- switch state.CurrentItemType {
- case "reasoning":
- // Emit reasoning summary done + output item done
- events := []ResponsesStreamEvent{
- makeResponsesEvent(state, "response.reasoning_summary_text.done", &ResponsesStreamEvent{
- OutputIndex: state.OutputIndex,
- SummaryIndex: 0,
- ItemID: state.CurrentItemID,
- }),
- }
- events = append(events, closeCurrentResponsesItem(state)...)
- return events
- case "function_call":
- // Emit function_call_arguments.done + output item done
- events := []ResponsesStreamEvent{
- makeResponsesEvent(state, "response.function_call_arguments.done", &ResponsesStreamEvent{
- OutputIndex: state.OutputIndex,
- ItemID: state.CurrentItemID,
- CallID: state.CurrentCallID,
- Name: state.CurrentName,
- }),
- }
- events = append(events, closeCurrentResponsesItem(state)...)
- return events
- case "message":
- // Text block is done: emit output_text.done then content_part.done (the
- // order OpenAI uses), both carrying the part's full text. The message
- // item itself stays open since more blocks may follow.
- text := state.TextAccum
- state.TextAccum = ""
- contentIndex := state.ContentIndex
- state.CurrentContent = append(state.CurrentContent, ResponsesContentPart{Type: "output_text", Text: text})
- // 关掉一个 part 就推进 content_index:上面那句注释说的「item 保持打开,
- // 因为后面可能还有块」正是这里的触发条件。不推进的话,同一 item 里第二个
- // text 块会再发一次 content_part.added(content_index=0),与第一个 part 撞在
- // 同一下标上——SDK 的累积式 stream helper 按 content[content_index] 写入,
- // 后一个 part 直接覆盖前一个,可见文本丢失。
- // 只在这里推进:新 item 的 content_index 由 closeCurrentResponsesItem 归 0。
- state.ContentIndex++
- return []ResponsesStreamEvent{
- makeResponsesEvent(state, "response.output_text.done", &ResponsesStreamEvent{
- OutputIndex: state.OutputIndex,
- ContentIndex: contentIndex,
- ItemID: state.CurrentItemID,
- Text: text,
- }),
- makeResponsesEvent(state, "response.content_part.done", &ResponsesStreamEvent{
- OutputIndex: state.OutputIndex,
- ContentIndex: contentIndex,
- ItemID: state.CurrentItemID,
- Part: &ResponsesContentPart{Type: "output_text", Text: text},
- }),
- }
- }
- return nil
- }
- func anthToResHandleMessageDelta(evt *AnthropicStreamEvent, state *AnthropicEventToResponsesState) []ResponsesStreamEvent {
- if evt.Usage != nil {
- state.OutputTokens = evt.Usage.OutputTokens
- if evt.Usage.InputTokens > 0 {
- state.InputTokens = evt.Usage.InputTokens
- }
- if evt.Usage.CacheReadInputTokens > 0 {
- state.CacheReadInputTokens = evt.Usage.CacheReadInputTokens
- }
- if evt.Usage.CacheCreationInputTokens > 0 {
- state.CacheCreationInputTokens = evt.Usage.CacheCreationInputTokens
- }
- }
- if evt.Delta != nil && evt.Delta.StopReason != "" {
- state.StopReason = evt.Delta.StopReason
- }
- return nil
- }
- func anthToResHandleMessageStop(state *AnthropicEventToResponsesState) []ResponsesStreamEvent {
- if state.CompletedSent {
- return nil
- }
- var events []ResponsesStreamEvent
- events = append(events, closeCurrentResponsesItem(state)...)
- status, incompleteDetails := anthropicResponsesStreamTerminalState(state.StopReason)
- events = append(events, makeResponsesCompletedEvent(state, status, incompleteDetails))
- state.CompletedSent = true
- return events
- }
- // --- helper functions ---
- func anthropicResponsesStreamTerminalState(stopReason string) (string, *ResponsesIncompleteDetails) {
- if stopReason == "max_tokens" {
- return "incomplete", &ResponsesIncompleteDetails{Reason: "max_output_tokens"}
- }
- return "completed", nil
- }
- func closeCurrentResponsesItem(state *AnthropicEventToResponsesState) []ResponsesStreamEvent {
- if state.CurrentItemType == "" {
- return nil
- }
- // Assemble the full item: both output_item.done and response.completed must
- // carry its content. Emitting only {type,id,status} makes SDK-side
- // accumulation produce an empty output.
- item := ResponsesOutput{
- Type: state.CurrentItemType,
- ID: state.CurrentItemID,
- Status: "completed",
- }
- switch state.CurrentItemType {
- case "message":
- item.Role = "assistant"
- item.Content = state.CurrentContent
- case "function_call":
- item.CallID = state.CurrentCallID
- item.Name = state.CurrentName
- args := state.CurrentArgs
- if args == "" {
- args = "{}"
- }
- item.Arguments = args
- case "reasoning":
- if state.CurrentSummary != "" {
- item.Summary = []ResponsesSummary{{Type: "summary_text", Text: state.CurrentSummary}}
- }
- }
- state.Outputs = append(state.Outputs, item)
- // Reset
- state.CurrentItemType = ""
- state.CurrentItemID = ""
- state.CurrentCallID = ""
- state.CurrentName = ""
- state.CurrentContent = nil
- state.CurrentArgs = ""
- state.CurrentSummary = ""
- state.TextAccum = ""
- state.OutputIndex++
- state.ContentIndex = 0
- return []ResponsesStreamEvent{makeResponsesEvent(state, "response.output_item.done", &ResponsesStreamEvent{
- OutputIndex: state.OutputIndex - 1, // Use the index before increment
- Item: &item,
- })}
- }
- func makeResponsesCreatedEvent(state *AnthropicEventToResponsesState) ResponsesStreamEvent {
- seq := state.SequenceNumber
- state.SequenceNumber++
- return ResponsesStreamEvent{
- Type: "response.created",
- SequenceNumber: seq,
- Response: &ResponsesResponse{
- ID: state.ResponseID,
- Object: "response",
- CreatedAt: state.Created,
- Model: state.Model,
- Status: "in_progress",
- Output: []ResponsesOutput{},
- },
- }
- }
- func makeResponsesCompletedEvent(
- state *AnthropicEventToResponsesState,
- status string,
- incompleteDetails *ResponsesIncompleteDetails,
- ) ResponsesStreamEvent {
- seq := state.SequenceNumber
- state.SequenceNumber++
- // Anthropic's input_tokens excludes cache_read/cache_creation; add them
- // back to match OpenAI Responses semantics where input_tokens is the total.
- totalInputTokens := state.InputTokens + state.CacheReadInputTokens + state.CacheCreationInputTokens
- usage := &ResponsesUsage{
- InputTokens: totalInputTokens,
- OutputTokens: state.OutputTokens,
- TotalTokens: totalInputTokens + state.OutputTokens,
- CacheCreationInputTokens: state.CacheCreationInputTokens,
- }
- if state.CacheReadInputTokens > 0 {
- usage.InputTokensDetails = &ResponsesInputTokensDetails{
- CachedTokens: state.CacheReadInputTokens,
- }
- }
- eventType := "response.completed"
- if status == "incomplete" {
- eventType = "response.incomplete"
- }
- // Carry the output items accumulated over the stream. The SDK's
- // get_final_response() reads them straight from the terminal event, so an
- // empty list leaves clients with an empty result.
- outputs := state.Outputs
- if outputs == nil {
- outputs = []ResponsesOutput{}
- }
- return ResponsesStreamEvent{
- Type: eventType,
- SequenceNumber: seq,
- Response: &ResponsesResponse{
- ID: state.ResponseID,
- Object: "response",
- CreatedAt: state.Created,
- Model: state.Model,
- Status: status,
- Output: outputs,
- Usage: usage,
- IncompleteDetails: incompleteDetails,
- },
- }
- }
- func makeResponsesEvent(state *AnthropicEventToResponsesState, eventType string, template *ResponsesStreamEvent) ResponsesStreamEvent {
- seq := state.SequenceNumber
- state.SequenceNumber++
- evt := *template
- evt.Type = eventType
- evt.SequenceNumber = seq
- return evt
- }
- func generateResponsesID() string {
- b := make([]byte, 12)
- _, _ = rand.Read(b)
- return "resp_" + hex.EncodeToString(b)
- }
- func generateItemID() string {
- b := make([]byte, 12)
- _, _ = rand.Read(b)
- return "item_" + hex.EncodeToString(b)
- }
|