1188 lines
34 KiB
Go
1188 lines
34 KiB
Go
package openai
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
edgeservice "iop/apps/edge/internal/service"
|
|
"iop/packages/go/streamgate"
|
|
iop "iop/proto/gen/iop"
|
|
)
|
|
|
|
type openAIChatStreamChunk struct {
|
|
ID string `json:"id"`
|
|
Model string `json:"model"`
|
|
Choices []struct {
|
|
Delta struct {
|
|
Content string `json:"content"`
|
|
ReasoningContent string `json:"reasoning_content"`
|
|
Reasoning string `json:"reasoning"`
|
|
ToolCalls []struct {
|
|
Index int `json:"index"`
|
|
ID string `json:"id"`
|
|
ExtraContent openAIChatToolExtraContent `json:"extra_content,omitempty"`
|
|
Function struct {
|
|
Name string `json:"name"`
|
|
Arguments string `json:"arguments"`
|
|
} `json:"function"`
|
|
} `json:"tool_calls"`
|
|
} `json:"delta"`
|
|
FinishReason *string `json:"finish_reason"`
|
|
} `json:"choices"`
|
|
Usage *openAIChatBridgeUsage `json:"usage,omitempty"`
|
|
Error *struct {
|
|
Type string `json:"type"`
|
|
Message string `json:"message"`
|
|
} `json:"error,omitempty"`
|
|
}
|
|
|
|
type anthropicBridgeToolState struct {
|
|
id string
|
|
name string
|
|
extraContent openAIChatToolExtraContent
|
|
arguments strings.Builder
|
|
}
|
|
|
|
type anthropicBridgeStream struct {
|
|
w http.ResponseWriter
|
|
model string
|
|
id string
|
|
started bool
|
|
stopped bool
|
|
nextBlock int
|
|
openBlock bool
|
|
openKind string
|
|
finish *string
|
|
usage anthropicUsage
|
|
tools map[int]*anthropicBridgeToolState
|
|
pendingSSE []byte
|
|
}
|
|
|
|
func newAnthropicBridgeStream(w http.ResponseWriter, model string) *anthropicBridgeStream {
|
|
return &anthropicBridgeStream{w: w, model: model, tools: make(map[int]*anthropicBridgeToolState)}
|
|
}
|
|
|
|
func (s *anthropicBridgeStream) Feed(chunk []byte) error {
|
|
if s.stopped {
|
|
return nil
|
|
}
|
|
s.pendingSSE = append(s.pendingSSE, chunk...)
|
|
s.pendingSSE = bytes.ReplaceAll(s.pendingSSE, []byte("\r\n"), []byte("\n"))
|
|
for {
|
|
index := bytes.Index(s.pendingSSE, []byte("\n\n"))
|
|
if index < 0 {
|
|
return nil
|
|
}
|
|
event := append([]byte(nil), s.pendingSSE[:index]...)
|
|
s.pendingSSE = s.pendingSSE[index+2:]
|
|
if err := s.consumeSSEEvent(event); err != nil {
|
|
return err
|
|
}
|
|
if s.stopped {
|
|
s.pendingSSE = nil
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *anthropicBridgeStream) consumeSSEEvent(event []byte) error {
|
|
var dataLines [][]byte
|
|
for _, line := range bytes.Split(event, []byte("\n")) {
|
|
line = bytes.TrimSpace(line)
|
|
if len(line) == 0 || line[0] == ':' {
|
|
continue
|
|
}
|
|
if bytes.HasPrefix(line, []byte("data:")) {
|
|
dataLines = append(dataLines, bytes.TrimSpace(bytes.TrimPrefix(line, []byte("data:"))))
|
|
}
|
|
}
|
|
if len(dataLines) == 0 {
|
|
return nil
|
|
}
|
|
payload := bytes.Join(dataLines, []byte("\n"))
|
|
if bytes.Equal(payload, []byte("[DONE]")) {
|
|
return s.Finish()
|
|
}
|
|
var chunk openAIChatStreamChunk
|
|
if err := json.Unmarshal(payload, &chunk); err != nil {
|
|
return fmt.Errorf("decode Chat SSE event: %w", err)
|
|
}
|
|
if chunk.Error != nil {
|
|
message := strings.TrimSpace(chunk.Error.Message)
|
|
if message == "" {
|
|
message = "upstream provider error"
|
|
}
|
|
return s.Error(chunk.Error.Type, message)
|
|
}
|
|
if chunk.ID != "" && s.id == "" {
|
|
s.id = chunk.ID
|
|
}
|
|
if chunk.Usage != nil {
|
|
s.usage.InputTokens = chunk.Usage.PromptTokens
|
|
s.usage.OutputTokens = chunk.Usage.CompletionTokens
|
|
s.usage.CacheReadInputTokens = chunk.Usage.PromptDetails.CachedTokens
|
|
}
|
|
if err := s.start(); err != nil {
|
|
return err
|
|
}
|
|
for _, choice := range chunk.Choices {
|
|
reasoning := choice.Delta.ReasoningContent
|
|
if reasoning == "" {
|
|
reasoning = choice.Delta.Reasoning
|
|
}
|
|
if reasoning != "" {
|
|
if err := s.delta("thinking", reasoning); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if choice.Delta.Content != "" {
|
|
if err := s.delta("text", choice.Delta.Content); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, delta := range choice.Delta.ToolCalls {
|
|
state := s.tools[delta.Index]
|
|
if state == nil {
|
|
state = &anthropicBridgeToolState{}
|
|
s.tools[delta.Index] = state
|
|
}
|
|
if delta.ID != "" {
|
|
state.id = delta.ID
|
|
}
|
|
if delta.Function.Name != "" {
|
|
state.name = delta.Function.Name
|
|
}
|
|
if delta.ExtraContent.Google != nil && delta.ExtraContent.Google.ThoughtSignature != "" {
|
|
state.extraContent = delta.ExtraContent
|
|
}
|
|
state.arguments.WriteString(delta.Function.Arguments)
|
|
}
|
|
if choice.FinishReason != nil {
|
|
s.finish = choice.FinishReason
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *anthropicBridgeStream) start() error {
|
|
if s.started {
|
|
return nil
|
|
}
|
|
s.started = true
|
|
id := s.id
|
|
if id == "" {
|
|
id = "msg_iop"
|
|
}
|
|
return writeAnthropicSSEEvent(s.w, "message_start", map[string]any{
|
|
"type": "message_start",
|
|
"message": anthropicMessageResponse{
|
|
ID: id, Type: "message", Role: "assistant", Model: s.model,
|
|
Content: []map[string]any{}, StopReason: nil, StopSequence: nil,
|
|
Usage: anthropicUsage{InputTokens: s.usage.InputTokens},
|
|
},
|
|
})
|
|
}
|
|
|
|
func (s *anthropicBridgeStream) delta(kind, value string) error {
|
|
if !s.openBlock || s.openKind != kind {
|
|
if err := s.closeBlock(); err != nil {
|
|
return err
|
|
}
|
|
block := map[string]any{"type": kind}
|
|
if kind == "thinking" {
|
|
block["thinking"] = ""
|
|
block["signature"] = ""
|
|
} else {
|
|
block["text"] = ""
|
|
}
|
|
if err := writeAnthropicSSEEvent(s.w, "content_block_start", map[string]any{
|
|
"type": "content_block_start", "index": s.nextBlock, "content_block": block,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
s.openBlock, s.openKind = true, kind
|
|
}
|
|
deltaType, field := "text_delta", "text"
|
|
if kind == "thinking" {
|
|
deltaType, field = "thinking_delta", "thinking"
|
|
}
|
|
return writeAnthropicSSEEvent(s.w, "content_block_delta", map[string]any{
|
|
"type": "content_block_delta", "index": s.nextBlock,
|
|
"delta": map[string]any{"type": deltaType, field: value},
|
|
})
|
|
}
|
|
|
|
func (s *anthropicBridgeStream) closeBlock() error {
|
|
if !s.openBlock {
|
|
return nil
|
|
}
|
|
if err := writeAnthropicSSEEvent(s.w, "content_block_stop", map[string]any{
|
|
"type": "content_block_stop", "index": s.nextBlock,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
s.nextBlock++
|
|
s.openBlock = false
|
|
s.openKind = ""
|
|
return nil
|
|
}
|
|
|
|
func (s *anthropicBridgeStream) emitTools() error {
|
|
if len(s.tools) == 0 {
|
|
return nil
|
|
}
|
|
indices := make([]int, 0, len(s.tools))
|
|
for index := range s.tools {
|
|
indices = append(indices, index)
|
|
}
|
|
sort.Ints(indices)
|
|
for _, index := range indices {
|
|
tool := s.tools[index]
|
|
arguments := tool.arguments.String()
|
|
if tool.id == "" || tool.name == "" || !json.Valid([]byte(arguments)) {
|
|
return fmt.Errorf("Chat stream tool call has invalid id, name, or arguments")
|
|
}
|
|
if err := writeAnthropicSSEEvent(s.w, "content_block_start", map[string]any{
|
|
"type": "content_block_start", "index": s.nextBlock,
|
|
"content_block": map[string]any{
|
|
"type": "tool_use", "id": encodeAnthropicBridgeToolID(tool.id, tool.extraContent),
|
|
"name": tool.name, "input": map[string]any{},
|
|
},
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if err := writeAnthropicSSEEvent(s.w, "content_block_delta", map[string]any{
|
|
"type": "content_block_delta", "index": s.nextBlock,
|
|
"delta": map[string]any{"type": "input_json_delta", "partial_json": arguments},
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if err := writeAnthropicSSEEvent(s.w, "content_block_stop", map[string]any{
|
|
"type": "content_block_stop", "index": s.nextBlock,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
s.nextBlock++
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *anthropicBridgeStream) Finish() error {
|
|
if s.stopped {
|
|
return nil
|
|
}
|
|
if err := s.start(); err != nil {
|
|
return err
|
|
}
|
|
if err := s.closeBlock(); err != nil {
|
|
return err
|
|
}
|
|
if err := s.emitTools(); err != nil {
|
|
return err
|
|
}
|
|
stopReason, err := anthropicStopReason(s.finish)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if stopReason == nil {
|
|
fallback := "end_turn"
|
|
stopReason = &fallback
|
|
}
|
|
if err := writeAnthropicSSEEvent(s.w, "message_delta", map[string]any{
|
|
"type": "message_delta", "delta": map[string]any{"stop_reason": *stopReason, "stop_sequence": nil},
|
|
"usage": s.usage,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if err := writeAnthropicSSEEvent(s.w, "message_stop", map[string]any{"type": "message_stop"}); err != nil {
|
|
return err
|
|
}
|
|
s.stopped = true
|
|
return nil
|
|
}
|
|
|
|
func (s *anthropicBridgeStream) Error(errorType, message string) error {
|
|
if s.stopped {
|
|
return nil
|
|
}
|
|
if strings.TrimSpace(errorType) == "" {
|
|
errorType = "api_error"
|
|
}
|
|
s.stopped = true
|
|
return writeAnthropicSSEEvent(s.w, "error", anthropicErrorResponse{
|
|
Type: "error", Error: errorBody{Type: errorType, Message: message},
|
|
})
|
|
}
|
|
|
|
func writeAnthropicSSEEvent(w http.ResponseWriter, event string, payload any) error {
|
|
encoded, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, encoded); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// anthropicHotPathCodec is the caller-facing Messages codec for one preset
|
|
// HTTP turn. It consumes only the normalized outer-turn accumulator and
|
|
// release log; selected-provider wire decoding remains in the shared stage
|
|
// decoders. The codec owns exactly one caller envelope and terminal.
|
|
type anthropicHotPathCodec struct {
|
|
mu sync.Mutex
|
|
|
|
w http.ResponseWriter
|
|
model string
|
|
stream bool
|
|
requestID string
|
|
maxTokens int
|
|
outer *hotPathOuterTurn
|
|
|
|
flusher http.Flusher
|
|
started bool
|
|
terminal bool
|
|
releaseAttached bool
|
|
progressiveTools bool
|
|
nextBlock int
|
|
openBlock bool
|
|
openKind string
|
|
openToolID string
|
|
emittedTools map[string]struct{}
|
|
}
|
|
|
|
type hotPathAnthropicCodecContextKey struct{}
|
|
|
|
type anthropicHotPathBlock struct {
|
|
kind string
|
|
id string
|
|
name string
|
|
signature string
|
|
fragments []string
|
|
toolIndex int
|
|
}
|
|
|
|
func newAnthropicHotPathCodec(
|
|
w http.ResponseWriter,
|
|
model string,
|
|
stream bool,
|
|
requestID string,
|
|
maxTokens int,
|
|
) *anthropicHotPathCodec {
|
|
return &anthropicHotPathCodec{
|
|
w: w, model: model, stream: stream, requestID: requestID, maxTokens: maxTokens,
|
|
}
|
|
}
|
|
|
|
func withHotPathAnthropicCodec(r *http.Request, codec *anthropicHotPathCodec) *http.Request {
|
|
if r == nil || codec == nil {
|
|
return r
|
|
}
|
|
return r.WithContext(context.WithValue(r.Context(), hotPathAnthropicCodecContextKey{}, codec))
|
|
}
|
|
|
|
func hotPathAnthropicCodecFromRequest(r *http.Request) *anthropicHotPathCodec {
|
|
if r == nil {
|
|
return nil
|
|
}
|
|
codec, _ := r.Context().Value(hotPathAnthropicCodecContextKey{}).(*anthropicHotPathCodec)
|
|
return codec
|
|
}
|
|
|
|
func (c *anthropicHotPathCodec) callerOuterTurn(responseID string, outputCapTokens int) *hotPathOuterTurn {
|
|
if c == nil {
|
|
return newHotPathCallerCappedOuterTurn(responseID, outputCapTokens)
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.outer == nil {
|
|
capTokens := c.maxTokens
|
|
if capTokens <= 0 {
|
|
capTokens = outputCapTokens
|
|
}
|
|
c.outer = newHotPathCallerCappedOuterTurn(responseID, capTokens)
|
|
}
|
|
return c.outer
|
|
}
|
|
|
|
func (c *anthropicHotPathCodec) currentOuterTurn() *hotPathOuterTurn {
|
|
if c == nil {
|
|
return nil
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.outer
|
|
}
|
|
|
|
// prepareProgressiveWriter connects the normalized outer-turn release seam to
|
|
// the caller-facing Messages codec. Initial-selector tool fragments stay held
|
|
// until structural classification; later, already-classified Light stages may
|
|
// release tool fragments as well as text and reasoning.
|
|
func (c *anthropicHotPathCodec) prepareProgressiveWriter(w http.ResponseWriter, outer *hotPathOuterTurn, releaseTools bool) error {
|
|
if c == nil || !c.stream || outer == nil {
|
|
return nil
|
|
}
|
|
flusher, ok := w.(http.Flusher)
|
|
if !ok {
|
|
return fmt.Errorf("response writer does not support flushing")
|
|
}
|
|
c.mu.Lock()
|
|
c.w = w
|
|
c.flusher = flusher
|
|
c.outer = outer
|
|
c.progressiveTools = releaseTools
|
|
if c.emittedTools == nil {
|
|
c.emittedTools = make(map[string]struct{})
|
|
}
|
|
attached := c.releaseAttached
|
|
if !attached {
|
|
c.releaseAttached = true
|
|
}
|
|
c.mu.Unlock()
|
|
if attached {
|
|
return nil
|
|
}
|
|
if err := outer.setReleaseCallback(func(delta hotPathReleasedDelta) error {
|
|
return c.writeProgressiveDelta(outer, delta)
|
|
}); err != nil {
|
|
c.mu.Lock()
|
|
c.releaseAttached = false
|
|
c.mu.Unlock()
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *anthropicHotPathCodec) writeProgressiveDelta(outer *hotPathOuterTurn, delta hotPathReleasedDelta) error {
|
|
responseID, ok := outer.publicResponseIdentity()
|
|
if !ok {
|
|
return fmt.Errorf("Anthropic Hot Path response is missing provider identity")
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.terminal {
|
|
return errHotPathTurnTerminal
|
|
}
|
|
if delta.Kind == streamgate.EventKindToolCallFragment && !c.progressiveTools {
|
|
return nil
|
|
}
|
|
usage := c.previewUsageLocked(outer)
|
|
if _, err := c.startStreamLocked(responseID, usage); err != nil {
|
|
return err
|
|
}
|
|
switch delta.Kind {
|
|
case streamgate.EventKindReasoningDelta:
|
|
if err := c.ensureProgressiveBlockLocked(outer, "thinking", "", ""); err != nil {
|
|
return err
|
|
}
|
|
return c.writeProgressiveBlockDeltaLocked(map[string]any{"type": "thinking_delta", "thinking": delta.Text})
|
|
case streamgate.EventKindTextDelta:
|
|
if err := c.ensureProgressiveBlockLocked(outer, "text", "", ""); err != nil {
|
|
return err
|
|
}
|
|
return c.writeProgressiveBlockDeltaLocked(map[string]any{"type": "text_delta", "text": delta.Text})
|
|
case streamgate.EventKindToolCallFragment:
|
|
if strings.TrimSpace(delta.PublicID) == "" || strings.TrimSpace(delta.Name) == "" {
|
|
return fmt.Errorf("Anthropic Hot Path tool block is missing id or name")
|
|
}
|
|
if err := c.ensureProgressiveBlockLocked(outer, "tool_use", delta.PublicID, delta.Name); err != nil {
|
|
return err
|
|
}
|
|
c.emittedTools[delta.PublicID] = struct{}{}
|
|
return c.writeProgressiveBlockDeltaLocked(map[string]any{"type": "input_json_delta", "partial_json": delta.Args})
|
|
default:
|
|
return fmt.Errorf("unsupported progressive Anthropic delta kind %q", delta.Kind)
|
|
}
|
|
}
|
|
|
|
func (c *anthropicHotPathCodec) previewUsageLocked(outer *hotPathOuterTurn) json.RawMessage {
|
|
usage, ok := outer.currentPreviewUsage()
|
|
if !ok {
|
|
return nil
|
|
}
|
|
raw, _ := json.Marshal(anthropicUsage{
|
|
InputTokens: usage.InputTokens, OutputTokens: usage.OutputTokens,
|
|
CacheReadInputTokens: usage.CachedInputTokens,
|
|
})
|
|
return raw
|
|
}
|
|
|
|
func (c *anthropicHotPathCodec) ensureProgressiveBlockLocked(outer *hotPathOuterTurn, kind, toolID, toolName string) error {
|
|
if c.openBlock && c.openKind == kind && (kind != "tool_use" || c.openToolID == toolID) {
|
|
return nil
|
|
}
|
|
if err := c.closeProgressiveBlockLocked(outer, ""); err != nil {
|
|
return err
|
|
}
|
|
block := map[string]any{"type": kind}
|
|
switch kind {
|
|
case "thinking":
|
|
block["thinking"], block["signature"] = "", ""
|
|
case "text":
|
|
block["text"] = ""
|
|
case "tool_use":
|
|
block["id"], block["name"], block["input"] = toolID, toolName, map[string]any{}
|
|
default:
|
|
return fmt.Errorf("unsupported Anthropic content block kind %q", kind)
|
|
}
|
|
if err := writeDirectAnthropicEvent(c.w, c.flusher, "content_block_start", map[string]any{
|
|
"type": "content_block_start", "index": c.nextBlock, "content_block": block,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
c.openBlock = true
|
|
c.openKind = kind
|
|
c.openToolID = toolID
|
|
return nil
|
|
}
|
|
|
|
func (c *anthropicHotPathCodec) writeProgressiveBlockDeltaLocked(delta map[string]any) error {
|
|
return writeDirectAnthropicEvent(c.w, c.flusher, "content_block_delta", map[string]any{
|
|
"type": "content_block_delta", "index": c.nextBlock, "delta": delta,
|
|
})
|
|
}
|
|
|
|
func (c *anthropicHotPathCodec) closeProgressiveBlockLocked(outer *hotPathOuterTurn, finalSignature string) error {
|
|
if !c.openBlock {
|
|
return nil
|
|
}
|
|
if c.openKind == "thinking" {
|
|
signature := finalSignature
|
|
if signature == "" && outer != nil {
|
|
signature = outer.currentReasoningSignature()
|
|
}
|
|
if signature != "" {
|
|
if err := c.writeProgressiveBlockDeltaLocked(map[string]any{"type": "signature_delta", "signature": signature}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
if err := writeDirectAnthropicEvent(c.w, c.flusher, "content_block_stop", map[string]any{
|
|
"type": "content_block_stop", "index": c.nextBlock,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
c.nextBlock++
|
|
c.openBlock = false
|
|
c.openKind = ""
|
|
c.openToolID = ""
|
|
return nil
|
|
}
|
|
|
|
func (c *anthropicHotPathCodec) runInitialPresetTurn(
|
|
s *Server,
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
dispatch routeDispatch,
|
|
runMeta map[string]string,
|
|
result *edgeservice.ProviderPoolDispatchResult,
|
|
) (normalizedStageOutput, bool, error) {
|
|
var (
|
|
stage normalizedStageOutput
|
|
gate hotPathSelectorGate
|
|
err error
|
|
)
|
|
if c.stream {
|
|
outer := c.callerOuterTurn("", hotPathOutputTokenCap(runMeta))
|
|
if err := c.prepareProgressiveWriter(w, outer, false); err != nil {
|
|
return stage, false, err
|
|
}
|
|
stage, gate, err = s.runLivePresetSelectorResult(
|
|
r.Context(), dispatch, "anthropic", runMeta["iop_stage_id"], result, outer,
|
|
)
|
|
} else {
|
|
stage, gate, err = s.collectPresetSelectorResult(r.Context(), dispatch, "anthropic", result)
|
|
}
|
|
if err != nil {
|
|
if contextErr := r.Context().Err(); contextErr != nil {
|
|
// Exact active-run cancellation is complete; caller cancellation is
|
|
// intentionally wire-silent.
|
|
return stage, true, contextErr
|
|
}
|
|
return stage, false, err
|
|
}
|
|
err = s.dispatchPresetTurn(w, r, dispatch, "anthropic", c.stream, runMeta, stage, gate)
|
|
return stage, true, err
|
|
}
|
|
|
|
func writeHotPathAnthropicOuterResponse(turn *hotPathTurn, output normalizedStageOutput) (bool, error) {
|
|
if turn == nil {
|
|
return false, nil
|
|
}
|
|
codec := hotPathAnthropicCodecFromRequest(turn.Request)
|
|
if codec == nil {
|
|
return false, nil
|
|
}
|
|
codec.w = turn.Writer
|
|
if codec.model == "" {
|
|
codec.model = directPublicModel(turn)
|
|
}
|
|
return true, codec.write(output)
|
|
}
|
|
|
|
func writeHotPathAnthropicOuterError(turn *hotPathTurn, status int, errorType, message string) bool {
|
|
if turn == nil {
|
|
return false
|
|
}
|
|
codec := hotPathAnthropicCodecFromRequest(turn.Request)
|
|
if codec == nil {
|
|
return false
|
|
}
|
|
codec.w = turn.Writer
|
|
disposition := hotPathTerminalDisposition{
|
|
Kind: hotPathDispositionProviderError, Cause: message, Source: "anthropic_outer_error",
|
|
}
|
|
selected := false
|
|
if turn.OuterTurn != nil {
|
|
if terminalDisposition, ok := turn.OuterTurn.terminalDisposition(); ok {
|
|
disposition = terminalDisposition
|
|
selected = true
|
|
}
|
|
}
|
|
if !selected && strings.Contains(strings.ToLower(errorType), "invalid") {
|
|
disposition.Kind = hotPathDispositionValidationError
|
|
}
|
|
_ = codec.writeDisposition(disposition, status, errorType, message)
|
|
return true
|
|
}
|
|
|
|
func (c *anthropicHotPathCodec) bindResponseID(responseID string) error {
|
|
responseID = strings.TrimSpace(responseID)
|
|
if responseID == "" {
|
|
return fmt.Errorf("Anthropic Hot Path response is missing provider identity")
|
|
}
|
|
if c == nil {
|
|
return nil
|
|
}
|
|
c.mu.Lock()
|
|
outer := c.outer
|
|
c.mu.Unlock()
|
|
if outer != nil {
|
|
return outer.bindPublicResponseID(responseID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *anthropicHotPathCodec) write(output normalizedStageOutput) error {
|
|
if c == nil || c.w == nil {
|
|
return fmt.Errorf("Anthropic Hot Path codec is unavailable")
|
|
}
|
|
if err := c.bindResponseID(output.ResponseID); err != nil {
|
|
return err
|
|
}
|
|
responseID := strings.TrimSpace(output.ResponseID)
|
|
outer := c.currentOuterTurn()
|
|
if outer != nil {
|
|
var ok bool
|
|
responseID, ok = outer.publicResponseIdentity()
|
|
if !ok {
|
|
return fmt.Errorf("Anthropic Hot Path response is missing provider identity")
|
|
}
|
|
}
|
|
blocks, err := c.blocks(output)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
stopReason := anthropicDirectStopReason(output.TerminalReason)
|
|
if outer != nil {
|
|
if disposition, ok := outer.terminalDisposition(); ok {
|
|
policy := anthropicHotPathPolicy(disposition)
|
|
switch {
|
|
case policy.silent && outer.isTerminalCommitted():
|
|
return c.writeDisposition(disposition, 0, "", "")
|
|
case policy.errorTerminal && outer.isTerminalCommitted():
|
|
return c.writeDisposition(disposition, policy.status, policy.errorType, disposition.Cause)
|
|
case policy.stopReason != "":
|
|
stopReason = policy.stopReason
|
|
}
|
|
}
|
|
}
|
|
if stopReason == "" {
|
|
if len(output.ToolCalls) > 0 {
|
|
stopReason = "tool_use"
|
|
} else {
|
|
stopReason = "end_turn"
|
|
}
|
|
}
|
|
usage := anthropicHotPathUsage(output)
|
|
if c.stream {
|
|
return c.writeStream(responseID, blocks, stopReason, usage)
|
|
}
|
|
return c.writeJSON(responseID, blocks, stopReason, usage)
|
|
}
|
|
|
|
func (c *anthropicHotPathCodec) blocks(output normalizedStageOutput) ([]anthropicHotPathBlock, error) {
|
|
var released []hotPathReleasedDelta
|
|
if c.outer != nil && !output.CallerStageOnly {
|
|
released = c.outer.releasedDeltas()
|
|
}
|
|
if len(released) == 0 {
|
|
if output.Reasoning != "" {
|
|
released = append(released, hotPathReleasedDelta{Kind: streamgate.EventKindReasoningDelta, Text: output.Reasoning})
|
|
}
|
|
if output.Content != "" {
|
|
released = append(released, hotPathReleasedDelta{Kind: streamgate.EventKindTextDelta, Text: output.Content})
|
|
}
|
|
for _, call := range output.ToolCalls {
|
|
released = append(released, hotPathReleasedDelta{
|
|
Kind: streamgate.EventKindToolCallFragment, PublicID: call.ID,
|
|
Name: call.Name, Args: directToolArguments(call),
|
|
})
|
|
}
|
|
}
|
|
|
|
blocks := make([]anthropicHotPathBlock, 0, len(released))
|
|
toolBlocks := make(map[string]int)
|
|
toolOrdinal := 0
|
|
for _, delta := range released {
|
|
switch delta.Kind {
|
|
case streamgate.EventKindReasoningDelta, streamgate.EventKindTextDelta:
|
|
kind := "text"
|
|
if delta.Kind == streamgate.EventKindReasoningDelta {
|
|
kind = "thinking"
|
|
}
|
|
if len(blocks) == 0 || blocks[len(blocks)-1].kind != kind {
|
|
blocks = append(blocks, anthropicHotPathBlock{kind: kind, toolIndex: -1})
|
|
}
|
|
blocks[len(blocks)-1].fragments = append(blocks[len(blocks)-1].fragments, delta.Text)
|
|
case streamgate.EventKindToolCallFragment:
|
|
key := delta.PublicID
|
|
if key == "" {
|
|
key = fmt.Sprintf("tool-%d", toolOrdinal)
|
|
}
|
|
blockIndex, ok := toolBlocks[key]
|
|
if !ok {
|
|
block := anthropicHotPathBlock{kind: "tool_use", id: delta.PublicID, name: delta.Name, toolIndex: toolOrdinal}
|
|
if toolOrdinal < len(output.ToolCalls) {
|
|
call := output.ToolCalls[toolOrdinal]
|
|
block.id = call.ID
|
|
block.name = call.Name
|
|
}
|
|
blocks = append(blocks, block)
|
|
blockIndex = len(blocks) - 1
|
|
toolBlocks[key] = blockIndex
|
|
toolOrdinal++
|
|
}
|
|
blocks[blockIndex].fragments = append(blocks[blockIndex].fragments, delta.Args)
|
|
}
|
|
}
|
|
for toolOrdinal < len(output.ToolCalls) {
|
|
call := output.ToolCalls[toolOrdinal]
|
|
blocks = append(blocks, anthropicHotPathBlock{
|
|
kind: "tool_use", id: call.ID, name: call.Name,
|
|
fragments: []string{directToolArguments(call)}, toolIndex: toolOrdinal,
|
|
})
|
|
toolOrdinal++
|
|
}
|
|
for index := range blocks {
|
|
block := &blocks[index]
|
|
if block.kind == "tool_use" {
|
|
if strings.TrimSpace(block.id) == "" || strings.TrimSpace(block.name) == "" {
|
|
return nil, fmt.Errorf("Anthropic Hot Path tool block is missing id or name")
|
|
}
|
|
arguments := strings.Join(block.fragments, "")
|
|
if block.toolIndex >= 0 && block.toolIndex < len(output.ToolCalls) {
|
|
expected := directToolArguments(output.ToolCalls[block.toolIndex])
|
|
if arguments == "" {
|
|
arguments = expected
|
|
block.fragments = []string{expected}
|
|
} else if expected != "" && arguments != expected {
|
|
return nil, fmt.Errorf("Anthropic Hot Path tool fragments do not match the issued call")
|
|
}
|
|
}
|
|
if arguments == "" {
|
|
arguments = "{}"
|
|
block.fragments = []string{arguments}
|
|
}
|
|
if !json.Valid([]byte(arguments)) {
|
|
return nil, fmt.Errorf("Anthropic Hot Path tool input is not valid JSON")
|
|
}
|
|
}
|
|
}
|
|
for index := len(blocks) - 1; index >= 0; index-- {
|
|
if blocks[index].kind == "thinking" {
|
|
blocks[index].signature = output.ReasoningSignature
|
|
break
|
|
}
|
|
}
|
|
return blocks, nil
|
|
}
|
|
|
|
func anthropicHotPathUsage(output normalizedStageOutput) json.RawMessage {
|
|
if len(output.Usage) > 0 {
|
|
var fields map[string]json.RawMessage
|
|
if json.Unmarshal(output.Usage, &fields) == nil {
|
|
if _, ok := fields["input_tokens"]; ok {
|
|
return cloneRawJSON(output.Usage)
|
|
}
|
|
}
|
|
}
|
|
if output.OpenAIUsage != nil {
|
|
raw, _ := json.Marshal(output.OpenAIUsage)
|
|
return openAIUsageToAnthropic(raw)
|
|
}
|
|
return openAIUsageToAnthropic(output.Usage)
|
|
}
|
|
|
|
func (c *anthropicHotPathCodec) writeJSON(responseID string, blocks []anthropicHotPathBlock, stopReason string, usage json.RawMessage) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.terminal {
|
|
return errHotPathTurnTerminal
|
|
}
|
|
content := make([]map[string]any, 0, len(blocks))
|
|
for _, block := range blocks {
|
|
switch block.kind {
|
|
case "thinking":
|
|
content = append(content, map[string]any{
|
|
"type": "thinking", "thinking": strings.Join(block.fragments, ""), "signature": block.signature,
|
|
})
|
|
case "text":
|
|
content = append(content, map[string]any{"type": "text", "text": strings.Join(block.fragments, "")})
|
|
case "tool_use":
|
|
var input any
|
|
if err := json.Unmarshal([]byte(strings.Join(block.fragments, "")), &input); err != nil {
|
|
return err
|
|
}
|
|
content = append(content, map[string]any{
|
|
"type": "tool_use", "id": block.id, "name": block.name, "input": input,
|
|
})
|
|
}
|
|
}
|
|
response := map[string]any{
|
|
"id": responseID, "type": "message", "role": "assistant", "model": c.model,
|
|
"content": content, "stop_reason": stopReason, "stop_sequence": nil,
|
|
}
|
|
if len(usage) > 0 {
|
|
response["usage"] = usage
|
|
}
|
|
c.terminal = true
|
|
return writeDirectJSON(c.w, http.StatusOK, response)
|
|
}
|
|
|
|
func (c *anthropicHotPathCodec) startStreamLocked(responseID string, usage json.RawMessage) (http.Flusher, error) {
|
|
flusher := c.flusher
|
|
if flusher == nil {
|
|
var ok bool
|
|
flusher, ok = c.w.(http.Flusher)
|
|
if !ok {
|
|
return nil, fmt.Errorf("response writer does not support flushing")
|
|
}
|
|
c.flusher = flusher
|
|
}
|
|
if c.started {
|
|
return flusher, nil
|
|
}
|
|
c.w.Header().Set("Content-Type", "text/event-stream")
|
|
c.w.Header().Set("Cache-Control", "no-cache")
|
|
c.w.WriteHeader(http.StatusOK)
|
|
message := map[string]any{
|
|
"id": responseID, "type": "message", "role": "assistant", "model": c.model,
|
|
"content": []any{}, "stop_reason": nil, "stop_sequence": nil,
|
|
}
|
|
if startUsage := anthropicStartUsage(usage); len(startUsage) > 0 {
|
|
message["usage"] = startUsage
|
|
}
|
|
if err := writeDirectAnthropicEvent(c.w, flusher, "message_start", map[string]any{
|
|
"type": "message_start", "message": message,
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
c.started = true
|
|
return flusher, nil
|
|
}
|
|
|
|
func (c *anthropicHotPathCodec) writeStream(responseID string, blocks []anthropicHotPathBlock, stopReason string, usage json.RawMessage) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.terminal {
|
|
return errHotPathTurnTerminal
|
|
}
|
|
flusher, err := c.startStreamLocked(responseID, usage)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if c.started && c.openBlock {
|
|
if err := c.closeProgressiveBlockLocked(c.outer, outputReasoningSignature(blocks)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, block := range blocks {
|
|
if c.releaseAttached {
|
|
if block.kind != "tool_use" {
|
|
continue
|
|
}
|
|
if _, emitted := c.emittedTools[block.id]; emitted {
|
|
continue
|
|
}
|
|
}
|
|
if err := c.writeCompleteBlockLocked(block); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
delta := map[string]any{
|
|
"type": "message_delta", "delta": map[string]any{"stop_reason": stopReason, "stop_sequence": nil},
|
|
}
|
|
if len(usage) > 0 {
|
|
delta["usage"] = usage
|
|
}
|
|
if err := writeDirectAnthropicEvent(c.w, flusher, "message_delta", delta); err != nil {
|
|
return err
|
|
}
|
|
if err := writeDirectAnthropicEvent(c.w, flusher, "message_stop", map[string]any{"type": "message_stop"}); err != nil {
|
|
return err
|
|
}
|
|
c.terminal = true
|
|
return nil
|
|
}
|
|
|
|
func outputReasoningSignature(blocks []anthropicHotPathBlock) string {
|
|
for index := len(blocks) - 1; index >= 0; index-- {
|
|
if blocks[index].kind == "thinking" {
|
|
return blocks[index].signature
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (c *anthropicHotPathCodec) writeCompleteBlockLocked(block anthropicHotPathBlock) error {
|
|
index := c.nextBlock
|
|
start := map[string]any{"type": block.kind}
|
|
switch block.kind {
|
|
case "thinking":
|
|
start["thinking"], start["signature"] = "", ""
|
|
case "text":
|
|
start["text"] = ""
|
|
case "tool_use":
|
|
start["id"], start["name"], start["input"] = block.id, block.name, map[string]any{}
|
|
}
|
|
if err := writeDirectAnthropicEvent(c.w, c.flusher, "content_block_start", map[string]any{
|
|
"type": "content_block_start", "index": index, "content_block": start,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
for _, fragment := range block.fragments {
|
|
delta := map[string]any{"type": "text_delta", "text": fragment}
|
|
switch block.kind {
|
|
case "thinking":
|
|
delta = map[string]any{"type": "thinking_delta", "thinking": fragment}
|
|
case "tool_use":
|
|
delta = map[string]any{"type": "input_json_delta", "partial_json": fragment}
|
|
}
|
|
if err := writeDirectAnthropicEvent(c.w, c.flusher, "content_block_delta", map[string]any{
|
|
"type": "content_block_delta", "index": index, "delta": delta,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if block.kind == "thinking" && block.signature != "" {
|
|
if err := writeDirectAnthropicEvent(c.w, c.flusher, "content_block_delta", map[string]any{
|
|
"type": "content_block_delta", "index": index,
|
|
"delta": map[string]any{"type": "signature_delta", "signature": block.signature},
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := writeDirectAnthropicEvent(c.w, c.flusher, "content_block_stop", map[string]any{
|
|
"type": "content_block_stop", "index": index,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
c.nextBlock++
|
|
return nil
|
|
}
|
|
|
|
func (c *anthropicHotPathCodec) writeError(status int, errorType, message string) error {
|
|
disposition := hotPathTerminalDisposition{
|
|
Kind: hotPathDispositionProviderError, Cause: message, Source: "anthropic_codec_error",
|
|
}
|
|
if strings.Contains(strings.ToLower(errorType), "invalid") {
|
|
disposition.Kind = hotPathDispositionValidationError
|
|
}
|
|
if outer := c.currentOuterTurn(); outer != nil {
|
|
if selected, ok := outer.terminalDisposition(); ok {
|
|
disposition = selected
|
|
}
|
|
}
|
|
return c.writeDisposition(disposition, status, errorType, message)
|
|
}
|
|
|
|
func (c *anthropicHotPathCodec) writeDisposition(
|
|
disposition hotPathTerminalDisposition,
|
|
status int,
|
|
errorType, message string,
|
|
) error {
|
|
if c == nil || c.w == nil {
|
|
return fmt.Errorf("Anthropic Hot Path codec is unavailable")
|
|
}
|
|
policy := anthropicHotPathPolicy(disposition)
|
|
if policy.status != 0 {
|
|
status = policy.status
|
|
}
|
|
if policy.errorType != "" {
|
|
errorType = policy.errorType
|
|
}
|
|
if strings.TrimSpace(message) == "" {
|
|
message = hotPathFirstNonEmpty(disposition.Cause, "hot path stage failed")
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.terminal {
|
|
return errHotPathTurnTerminal
|
|
}
|
|
if policy.silent {
|
|
c.terminal = true
|
|
return nil
|
|
}
|
|
if !policy.errorTerminal {
|
|
return fmt.Errorf("Anthropic disposition %q is not an error terminal", disposition.Kind)
|
|
}
|
|
if c.stream && c.started {
|
|
flusher := c.flusher
|
|
if flusher == nil {
|
|
var ok bool
|
|
flusher, ok = c.w.(http.Flusher)
|
|
if !ok {
|
|
return fmt.Errorf("response writer does not support flushing")
|
|
}
|
|
}
|
|
c.terminal = true
|
|
return writeDirectAnthropicEvent(c.w, flusher, "error", anthropicErrorResponse{
|
|
Type: "error", Error: errorBody{Type: errorType, Message: message},
|
|
})
|
|
}
|
|
c.terminal = true
|
|
writeAnthropicError(c.w, status, errorType, message)
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) writeAnthropicChatBridgeResponse(w http.ResponseWriter, r *http.Request, handle edgeservice.ProviderTunnelResult, envelope anthropicRequestEnvelope) {
|
|
frames := handle.Stream().Frames
|
|
if frames == nil {
|
|
writeAnthropicError(w, http.StatusBadGateway, "api_error", "provider tunnel is unavailable")
|
|
return
|
|
}
|
|
timer := time.NewTimer(handle.WaitTimeout())
|
|
defer timer.Stop()
|
|
status := http.StatusOK
|
|
headers := make(map[string]string)
|
|
started := false
|
|
committed := false
|
|
var body []byte
|
|
var stream *anthropicBridgeStream
|
|
if envelope.Stream {
|
|
stream = newAnthropicBridgeStream(w, envelope.Model)
|
|
}
|
|
flush := func() {
|
|
if flusher, ok := w.(http.Flusher); ok {
|
|
flusher.Flush()
|
|
}
|
|
}
|
|
commitStream := func() {
|
|
if committed {
|
|
return
|
|
}
|
|
copyAnthropicResponseHeaders(w.Header(), headers)
|
|
w.Header().Del("Content-Length")
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.WriteHeader(status)
|
|
committed = true
|
|
flush()
|
|
}
|
|
|
|
for {
|
|
select {
|
|
case <-r.Context().Done():
|
|
s.cancelRunOnHTTPGiveUp(handle.Dispatch(), r.Context().Err())
|
|
return
|
|
case <-timer.C:
|
|
s.cancelRunOnHTTPGiveUp(handle.Dispatch(), errRunTimedOut)
|
|
if !committed {
|
|
writeAnthropicError(w, http.StatusBadGateway, "api_error", "provider response timed out")
|
|
} else {
|
|
_ = stream.Error("api_error", "provider response timed out")
|
|
flush()
|
|
}
|
|
return
|
|
case frame, ok := <-frames:
|
|
if !ok {
|
|
if !committed {
|
|
writeAnthropicError(w, http.StatusBadGateway, "api_error", "provider tunnel closed before a response")
|
|
} else {
|
|
_ = stream.Error("api_error", "provider tunnel closed before a response")
|
|
flush()
|
|
}
|
|
return
|
|
}
|
|
switch frame.GetKind() {
|
|
case iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_RESPONSE_START:
|
|
if started {
|
|
continue
|
|
}
|
|
started = true
|
|
status = int(frame.GetStatusCode())
|
|
if status == 0 {
|
|
status = http.StatusOK
|
|
}
|
|
for key, value := range frame.GetHeaders() {
|
|
headers[key] = value
|
|
}
|
|
if envelope.Stream && status < http.StatusBadRequest {
|
|
commitStream()
|
|
}
|
|
case iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_BODY:
|
|
if envelope.Stream && status < http.StatusBadRequest {
|
|
commitStream()
|
|
if err := stream.Feed(frame.GetBody()); err != nil {
|
|
_ = stream.Error("api_error", "upstream stream could not be translated")
|
|
flush()
|
|
return
|
|
}
|
|
flush()
|
|
} else {
|
|
body = append(body, frame.GetBody()...)
|
|
}
|
|
case iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_ERROR:
|
|
if committed {
|
|
_ = stream.Error("api_error", "provider tunnel failed")
|
|
flush()
|
|
} else {
|
|
writeAnthropicError(w, http.StatusBadGateway, "api_error", "provider tunnel failed")
|
|
}
|
|
return
|
|
case iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END:
|
|
if envelope.Stream && status < http.StatusBadRequest {
|
|
commitStream()
|
|
if err := stream.Finish(); err != nil {
|
|
_ = stream.Error("api_error", "upstream stream could not be translated")
|
|
}
|
|
flush()
|
|
return
|
|
}
|
|
copyAnthropicResponseHeaders(w.Header(), headers)
|
|
w.Header().Del("Content-Length")
|
|
if status >= http.StatusBadRequest {
|
|
writeJSON(w, status, convertChatErrorToAnthropic(body))
|
|
return
|
|
}
|
|
converted, err := convertChatResponseToAnthropic(body, envelope.Model)
|
|
if err != nil {
|
|
writeAnthropicError(w, http.StatusBadGateway, "api_error", "upstream response could not be translated")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, converted)
|
|
return
|
|
case iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_USAGE:
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
}
|