Add Anthropic Messages API support for OpenAI platform groups, enabling clients using Claude-style /v1/messages format to access OpenAI accounts through automatic protocol conversion. - Add apicompat package with type definitions and bidirectional converters (Anthropic ↔ Chat, Chat ↔ Responses, Anthropic ↔ Responses) - Implement /v1/messages endpoint for OpenAI gateway with streaming support - Add model mapping UI for OpenAI OAuth accounts (whitelist + mapping modes) - Support prompt caching fields and codex OAuth transforms - Fix tool call ID conversion for Responses API (fc_ prefix) - Ensure function_call_output has non-empty output field Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
437 lines
12 KiB
Go
437 lines
12 KiB
Go
package apicompat
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Non-streaming: ResponsesResponse → AnthropicResponse
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ResponsesToAnthropic converts a Responses API response directly into an
|
|
// Anthropic Messages response. Reasoning output items are mapped to thinking
|
|
// blocks; function_call items become tool_use blocks.
|
|
func ResponsesToAnthropic(resp *ResponsesResponse, model string) *AnthropicResponse {
|
|
out := &AnthropicResponse{
|
|
ID: resp.ID,
|
|
Type: "message",
|
|
Role: "assistant",
|
|
Model: model,
|
|
}
|
|
|
|
var blocks []AnthropicContentBlock
|
|
|
|
for _, item := range resp.Output {
|
|
switch item.Type {
|
|
case "reasoning":
|
|
summaryText := ""
|
|
for _, s := range item.Summary {
|
|
if s.Type == "summary_text" && s.Text != "" {
|
|
summaryText += s.Text
|
|
}
|
|
}
|
|
if summaryText != "" {
|
|
blocks = append(blocks, AnthropicContentBlock{
|
|
Type: "thinking",
|
|
Thinking: summaryText,
|
|
})
|
|
}
|
|
case "message":
|
|
for _, part := range item.Content {
|
|
if part.Type == "output_text" && part.Text != "" {
|
|
blocks = append(blocks, AnthropicContentBlock{
|
|
Type: "text",
|
|
Text: part.Text,
|
|
})
|
|
}
|
|
}
|
|
case "function_call":
|
|
blocks = append(blocks, AnthropicContentBlock{
|
|
Type: "tool_use",
|
|
ID: fromResponsesCallID(item.CallID),
|
|
Name: item.Name,
|
|
Input: json.RawMessage(item.Arguments),
|
|
})
|
|
}
|
|
}
|
|
|
|
if len(blocks) == 0 {
|
|
blocks = append(blocks, AnthropicContentBlock{Type: "text", Text: ""})
|
|
}
|
|
out.Content = blocks
|
|
|
|
out.StopReason = responsesStatusToAnthropicStopReason(resp.Status, resp.IncompleteDetails, blocks)
|
|
|
|
if resp.Usage != nil {
|
|
out.Usage = AnthropicUsage{
|
|
InputTokens: resp.Usage.InputTokens,
|
|
OutputTokens: resp.Usage.OutputTokens,
|
|
}
|
|
if resp.Usage.InputTokensDetails != nil {
|
|
out.Usage.CacheReadInputTokens = resp.Usage.InputTokensDetails.CachedTokens
|
|
}
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func responsesStatusToAnthropicStopReason(status string, details *ResponsesIncompleteDetails, blocks []AnthropicContentBlock) string {
|
|
switch status {
|
|
case "incomplete":
|
|
if details != nil && details.Reason == "max_output_tokens" {
|
|
return "max_tokens"
|
|
}
|
|
return "end_turn"
|
|
case "completed":
|
|
if len(blocks) > 0 && blocks[len(blocks)-1].Type == "tool_use" {
|
|
return "tool_use"
|
|
}
|
|
return "end_turn"
|
|
default:
|
|
return "end_turn"
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Streaming: ResponsesStreamEvent → []AnthropicStreamEvent (stateful converter)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ResponsesEventToAnthropicState tracks state for converting a sequence of
|
|
// Responses SSE events directly into Anthropic SSE events.
|
|
type ResponsesEventToAnthropicState struct {
|
|
MessageStartSent bool
|
|
MessageStopSent bool
|
|
|
|
ContentBlockIndex int
|
|
ContentBlockOpen bool
|
|
CurrentBlockType string // "text" | "thinking" | "tool_use"
|
|
|
|
// OutputIndexToBlockIdx maps Responses output_index → Anthropic content block index.
|
|
OutputIndexToBlockIdx map[int]int
|
|
|
|
InputTokens int
|
|
OutputTokens int
|
|
CacheReadInputTokens int
|
|
|
|
ResponseID string
|
|
Model string
|
|
Created int64
|
|
}
|
|
|
|
// NewResponsesEventToAnthropicState returns an initialised stream state.
|
|
func NewResponsesEventToAnthropicState() *ResponsesEventToAnthropicState {
|
|
return &ResponsesEventToAnthropicState{
|
|
OutputIndexToBlockIdx: make(map[int]int),
|
|
Created: time.Now().Unix(),
|
|
}
|
|
}
|
|
|
|
// ResponsesEventToAnthropicEvents converts a single Responses SSE event into
|
|
// zero or more Anthropic SSE events, updating state as it goes.
|
|
func ResponsesEventToAnthropicEvents(
|
|
evt *ResponsesStreamEvent,
|
|
state *ResponsesEventToAnthropicState,
|
|
) []AnthropicStreamEvent {
|
|
switch evt.Type {
|
|
case "response.created":
|
|
return resToAnthHandleCreated(evt, state)
|
|
case "response.output_item.added":
|
|
return resToAnthHandleOutputItemAdded(evt, state)
|
|
case "response.output_text.delta":
|
|
return resToAnthHandleTextDelta(evt, state)
|
|
case "response.output_text.done":
|
|
return resToAnthHandleBlockDone(state)
|
|
case "response.function_call_arguments.delta":
|
|
return resToAnthHandleFuncArgsDelta(evt, state)
|
|
case "response.function_call_arguments.done":
|
|
return resToAnthHandleBlockDone(state)
|
|
case "response.output_item.done":
|
|
return resToAnthHandleOutputItemDone(evt, state)
|
|
case "response.reasoning_summary_text.delta":
|
|
return resToAnthHandleReasoningDelta(evt, state)
|
|
case "response.reasoning_summary_text.done":
|
|
return resToAnthHandleBlockDone(state)
|
|
case "response.completed", "response.incomplete":
|
|
return resToAnthHandleCompleted(evt, state)
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// FinalizeResponsesAnthropicStream emits synthetic termination events if the
|
|
// stream ended without a proper completion event.
|
|
func FinalizeResponsesAnthropicStream(state *ResponsesEventToAnthropicState) []AnthropicStreamEvent {
|
|
if !state.MessageStartSent || state.MessageStopSent {
|
|
return nil
|
|
}
|
|
|
|
var events []AnthropicStreamEvent
|
|
events = append(events, closeCurrentBlock(state)...)
|
|
|
|
events = append(events,
|
|
AnthropicStreamEvent{
|
|
Type: "message_delta",
|
|
Delta: &AnthropicDelta{
|
|
StopReason: "end_turn",
|
|
},
|
|
Usage: &AnthropicUsage{
|
|
InputTokens: state.InputTokens,
|
|
OutputTokens: state.OutputTokens,
|
|
CacheReadInputTokens: state.CacheReadInputTokens,
|
|
},
|
|
},
|
|
AnthropicStreamEvent{Type: "message_stop"},
|
|
)
|
|
state.MessageStopSent = true
|
|
return events
|
|
}
|
|
|
|
// ResponsesAnthropicEventToSSE formats an AnthropicStreamEvent as an SSE line pair.
|
|
func ResponsesAnthropicEventToSSE(evt AnthropicStreamEvent) (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 resToAnthHandleCreated(evt *ResponsesStreamEvent, state *ResponsesEventToAnthropicState) []AnthropicStreamEvent {
|
|
if evt.Response != nil {
|
|
state.ResponseID = evt.Response.ID
|
|
// Only use upstream model if no override was set (e.g. originalModel)
|
|
if state.Model == "" {
|
|
state.Model = evt.Response.Model
|
|
}
|
|
}
|
|
|
|
if state.MessageStartSent {
|
|
return nil
|
|
}
|
|
state.MessageStartSent = true
|
|
|
|
return []AnthropicStreamEvent{{
|
|
Type: "message_start",
|
|
Message: &AnthropicResponse{
|
|
ID: state.ResponseID,
|
|
Type: "message",
|
|
Role: "assistant",
|
|
Content: []AnthropicContentBlock{},
|
|
Model: state.Model,
|
|
Usage: AnthropicUsage{
|
|
InputTokens: 0,
|
|
OutputTokens: 0,
|
|
},
|
|
},
|
|
}}
|
|
}
|
|
|
|
func resToAnthHandleOutputItemAdded(evt *ResponsesStreamEvent, state *ResponsesEventToAnthropicState) []AnthropicStreamEvent {
|
|
if evt.Item == nil {
|
|
return nil
|
|
}
|
|
|
|
switch evt.Item.Type {
|
|
case "function_call":
|
|
var events []AnthropicStreamEvent
|
|
events = append(events, closeCurrentBlock(state)...)
|
|
|
|
idx := state.ContentBlockIndex
|
|
state.OutputIndexToBlockIdx[evt.OutputIndex] = idx
|
|
state.ContentBlockOpen = true
|
|
state.CurrentBlockType = "tool_use"
|
|
|
|
events = append(events, AnthropicStreamEvent{
|
|
Type: "content_block_start",
|
|
Index: &idx,
|
|
ContentBlock: &AnthropicContentBlock{
|
|
Type: "tool_use",
|
|
ID: fromResponsesCallID(evt.Item.CallID),
|
|
Name: evt.Item.Name,
|
|
Input: json.RawMessage("{}"),
|
|
},
|
|
})
|
|
return events
|
|
|
|
case "reasoning":
|
|
var events []AnthropicStreamEvent
|
|
events = append(events, closeCurrentBlock(state)...)
|
|
|
|
idx := state.ContentBlockIndex
|
|
state.OutputIndexToBlockIdx[evt.OutputIndex] = idx
|
|
state.ContentBlockOpen = true
|
|
state.CurrentBlockType = "thinking"
|
|
|
|
events = append(events, AnthropicStreamEvent{
|
|
Type: "content_block_start",
|
|
Index: &idx,
|
|
ContentBlock: &AnthropicContentBlock{
|
|
Type: "thinking",
|
|
Thinking: "",
|
|
},
|
|
})
|
|
return events
|
|
|
|
case "message":
|
|
return nil
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func resToAnthHandleTextDelta(evt *ResponsesStreamEvent, state *ResponsesEventToAnthropicState) []AnthropicStreamEvent {
|
|
if evt.Delta == "" {
|
|
return nil
|
|
}
|
|
|
|
var events []AnthropicStreamEvent
|
|
|
|
if !state.ContentBlockOpen || state.CurrentBlockType != "text" {
|
|
events = append(events, closeCurrentBlock(state)...)
|
|
|
|
idx := state.ContentBlockIndex
|
|
state.ContentBlockOpen = true
|
|
state.CurrentBlockType = "text"
|
|
|
|
events = append(events, AnthropicStreamEvent{
|
|
Type: "content_block_start",
|
|
Index: &idx,
|
|
ContentBlock: &AnthropicContentBlock{
|
|
Type: "text",
|
|
Text: "",
|
|
},
|
|
})
|
|
}
|
|
|
|
idx := state.ContentBlockIndex
|
|
events = append(events, AnthropicStreamEvent{
|
|
Type: "content_block_delta",
|
|
Index: &idx,
|
|
Delta: &AnthropicDelta{
|
|
Type: "text_delta",
|
|
Text: evt.Delta,
|
|
},
|
|
})
|
|
return events
|
|
}
|
|
|
|
func resToAnthHandleFuncArgsDelta(evt *ResponsesStreamEvent, state *ResponsesEventToAnthropicState) []AnthropicStreamEvent {
|
|
if evt.Delta == "" {
|
|
return nil
|
|
}
|
|
|
|
blockIdx, ok := state.OutputIndexToBlockIdx[evt.OutputIndex]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
return []AnthropicStreamEvent{{
|
|
Type: "content_block_delta",
|
|
Index: &blockIdx,
|
|
Delta: &AnthropicDelta{
|
|
Type: "input_json_delta",
|
|
PartialJSON: evt.Delta,
|
|
},
|
|
}}
|
|
}
|
|
|
|
func resToAnthHandleReasoningDelta(evt *ResponsesStreamEvent, state *ResponsesEventToAnthropicState) []AnthropicStreamEvent {
|
|
if evt.Delta == "" {
|
|
return nil
|
|
}
|
|
|
|
blockIdx, ok := state.OutputIndexToBlockIdx[evt.OutputIndex]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
return []AnthropicStreamEvent{{
|
|
Type: "content_block_delta",
|
|
Index: &blockIdx,
|
|
Delta: &AnthropicDelta{
|
|
Type: "thinking_delta",
|
|
Thinking: evt.Delta,
|
|
},
|
|
}}
|
|
}
|
|
|
|
func resToAnthHandleBlockDone(state *ResponsesEventToAnthropicState) []AnthropicStreamEvent {
|
|
if !state.ContentBlockOpen {
|
|
return nil
|
|
}
|
|
return closeCurrentBlock(state)
|
|
}
|
|
|
|
func resToAnthHandleOutputItemDone(evt *ResponsesStreamEvent, state *ResponsesEventToAnthropicState) []AnthropicStreamEvent {
|
|
if evt.Item == nil {
|
|
return nil
|
|
}
|
|
if state.ContentBlockOpen {
|
|
return closeCurrentBlock(state)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func resToAnthHandleCompleted(evt *ResponsesStreamEvent, state *ResponsesEventToAnthropicState) []AnthropicStreamEvent {
|
|
if state.MessageStopSent {
|
|
return nil
|
|
}
|
|
|
|
var events []AnthropicStreamEvent
|
|
events = append(events, closeCurrentBlock(state)...)
|
|
|
|
stopReason := "end_turn"
|
|
if evt.Response != nil {
|
|
if evt.Response.Usage != nil {
|
|
state.InputTokens = evt.Response.Usage.InputTokens
|
|
state.OutputTokens = evt.Response.Usage.OutputTokens
|
|
if evt.Response.Usage.InputTokensDetails != nil {
|
|
state.CacheReadInputTokens = evt.Response.Usage.InputTokensDetails.CachedTokens
|
|
}
|
|
}
|
|
switch evt.Response.Status {
|
|
case "incomplete":
|
|
if evt.Response.IncompleteDetails != nil && evt.Response.IncompleteDetails.Reason == "max_output_tokens" {
|
|
stopReason = "max_tokens"
|
|
}
|
|
case "completed":
|
|
if state.ContentBlockIndex > 0 && state.CurrentBlockType == "tool_use" {
|
|
stopReason = "tool_use"
|
|
}
|
|
}
|
|
}
|
|
|
|
events = append(events,
|
|
AnthropicStreamEvent{
|
|
Type: "message_delta",
|
|
Delta: &AnthropicDelta{
|
|
StopReason: stopReason,
|
|
},
|
|
Usage: &AnthropicUsage{
|
|
InputTokens: state.InputTokens,
|
|
OutputTokens: state.OutputTokens,
|
|
CacheReadInputTokens: state.CacheReadInputTokens,
|
|
},
|
|
},
|
|
AnthropicStreamEvent{Type: "message_stop"},
|
|
)
|
|
state.MessageStopSent = true
|
|
return events
|
|
}
|
|
|
|
func closeCurrentBlock(state *ResponsesEventToAnthropicState) []AnthropicStreamEvent {
|
|
if !state.ContentBlockOpen {
|
|
return nil
|
|
}
|
|
idx := state.ContentBlockIndex
|
|
state.ContentBlockOpen = false
|
|
state.ContentBlockIndex++
|
|
return []AnthropicStreamEvent{{
|
|
Type: "content_block_stop",
|
|
Index: &idx,
|
|
}}
|
|
}
|