480 lines
16 KiB
Go
480 lines
16 KiB
Go
package openai
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"iop/packages/go/config"
|
|
)
|
|
|
|
type hotPathTurn struct {
|
|
RequestID string
|
|
StageID string
|
|
CallID string
|
|
OwnerEdgeID string
|
|
PrincipalRef string
|
|
Preset config.ExecutionPreset
|
|
Dispatch routeDispatch
|
|
Protocol string // "openai" or "anthropic"
|
|
Stream bool
|
|
PublicModelID string
|
|
Writer http.ResponseWriter
|
|
Request *http.Request
|
|
OuterTurn *hotPathOuterTurn
|
|
}
|
|
|
|
func (s *Server) runDirectTurn(ctx context.Context, turn *hotPathTurn, output normalizedStageOutput) error {
|
|
directTerminal := hotPathTerminalDispositionSuccess
|
|
reachedTerminal := false
|
|
defer func() {
|
|
// Emit the single direct-mode terminal observation exactly once. The
|
|
// tool-turn path leaves reachedTerminal false so an agent round-trip is
|
|
// not mistaken for a logical terminal. Disposition is normalized before
|
|
// projection so raw error text never reaches logs or labels (SDD S15).
|
|
if reachedTerminal {
|
|
s.observeHotPathTerminal(ctx, hotPathModeDirect, directTerminal, turn.RequestID, turn.StageID, turn.Preset.ID)
|
|
}
|
|
}()
|
|
for _, call := range output.ToolCalls {
|
|
if len(reservedPathsFromToolCall(call)) > 0 {
|
|
directTerminal = hotPathTerminalDispositionValidationError
|
|
reachedTerminal = true
|
|
s.terminalPresetRequest(turn.RequestID, turn.OwnerEdgeID)
|
|
return s.writeDirectError(turn, http.StatusBadRequest, "invalid_request_error", "direct flow violation: reserved artifact path .iop/job/ emitted in direct turn")
|
|
}
|
|
}
|
|
if strings.TrimSpace(output.ResponseID) == "" {
|
|
directTerminal = hotPathTerminalDispositionProviderError
|
|
reachedTerminal = true
|
|
s.terminalPresetRequest(turn.RequestID, turn.OwnerEdgeID)
|
|
return s.writeDirectError(turn, http.StatusBadGateway, "api_error", "direct response is missing provider execution identity")
|
|
}
|
|
visible := cloneNormalizedStageOutput(output)
|
|
if turn.OuterTurn != nil {
|
|
if !output.ProgressivelyReleased {
|
|
if err := runHotPathCollectedStage(ctx, turn.OuterTurn, turn.StageID, output); err != nil {
|
|
directTerminal = hotPathTerminalDispositionProviderError
|
|
reachedTerminal = true
|
|
s.terminalPresetRequest(turn.RequestID, turn.OwnerEdgeID)
|
|
return s.writeDirectError(turn, http.StatusBadGateway, "api_error", fmt.Sprintf("direct outer turn failed: %v", err))
|
|
}
|
|
}
|
|
visible = hotPathCompatibilityOutput(turn.OuterTurn, output, turn.Protocol)
|
|
}
|
|
|
|
if len(visible.ToolCalls) > 0 {
|
|
expected := make([]logicalRequestExpectedTool, 0, len(visible.ToolCalls))
|
|
for _, call := range visible.ToolCalls {
|
|
providerID := strings.TrimSpace(call.ProviderCallID)
|
|
if providerID == "" {
|
|
providerID = call.ID
|
|
}
|
|
expected = append(expected, logicalRequestExpectedTool{PublicCallID: call.ID, ProviderCallID: providerID})
|
|
}
|
|
issuedHash, err := directIssuedCallHash(turn.Protocol, visible)
|
|
if err != nil {
|
|
directTerminal = hotPathTerminalDispositionProviderError
|
|
reachedTerminal = true
|
|
s.terminalPresetRequest(turn.RequestID, turn.OwnerEdgeID)
|
|
return s.writeDirectError(turn, http.StatusBadGateway, "api_error", err.Error())
|
|
}
|
|
if turn.RequestID != "" {
|
|
if _, err := s.requestCoordinator.awaitToolResults(turn.RequestID, turn.OwnerEdgeID, turn.StageID, expected, issuedHash); err != nil {
|
|
directTerminal = hotPathTerminalDispositionValidationError
|
|
reachedTerminal = true
|
|
s.terminalPresetRequest(turn.RequestID, turn.OwnerEdgeID)
|
|
return s.writeDirectError(turn, http.StatusBadRequest, "invalid_request_error", fmt.Sprintf("failed to await tool results: %v", err))
|
|
}
|
|
}
|
|
if turn.OuterTurn != nil {
|
|
turn.OuterTurn.commitTerminalSuccess(output.TerminalReason)
|
|
visible = hotPathCompatibilityOutput(turn.OuterTurn, visible, turn.Protocol)
|
|
}
|
|
if err := s.writeDirectResponse(turn, visible); err != nil {
|
|
// Classify the response-write failure through the closed error mapper
|
|
// so a caller-canceled or timed-out endpoint write wins over
|
|
// provider_error, matching the cleanup post-write ownership rule.
|
|
directTerminal = hotPathTerminalDispositionFromKind(hotPathDispositionForError(err))
|
|
reachedTerminal = true
|
|
s.terminalPresetRequest(turn.RequestID, turn.OwnerEdgeID)
|
|
return err
|
|
}
|
|
// Tool turn: the logical request is still waiting for agent tool
|
|
// results, so this HTTP turn is not a logical terminal.
|
|
return nil
|
|
}
|
|
|
|
if turn.OuterTurn != nil {
|
|
turn.OuterTurn.commitTerminalSuccess(output.TerminalReason)
|
|
visible = hotPathCompatibilityOutput(turn.OuterTurn, visible, turn.Protocol)
|
|
}
|
|
if err := s.writeDirectResponse(turn, visible); err != nil {
|
|
// The final direct response also resolves cancellation/timeout through the
|
|
// closed error mapper before the deferred exact-once terminal emission.
|
|
directTerminal = hotPathTerminalDispositionFromKind(hotPathDispositionForError(err))
|
|
reachedTerminal = true
|
|
if turn.RequestID != "" {
|
|
s.terminalPresetRequest(turn.RequestID, turn.OwnerEdgeID)
|
|
}
|
|
return err
|
|
}
|
|
if turn.RequestID != "" {
|
|
s.terminalPresetRequest(turn.RequestID, turn.OwnerEdgeID)
|
|
}
|
|
if hotPathIsProviderLengthTerminal(output.TerminalReason) {
|
|
directTerminal = hotPathTerminalDispositionLength
|
|
}
|
|
reachedTerminal = true
|
|
return nil
|
|
}
|
|
|
|
func directIssuedCallHash(protocol string, output normalizedStageOutput) (string, error) {
|
|
if protocol == "anthropic" {
|
|
return fingerprintCanonicalJSON(logicalRequestEndpointAnthropic, map[string]any{
|
|
"role": "assistant", "content": anthropicDirectBlocks(output),
|
|
})
|
|
}
|
|
return fingerprintCanonicalJSON(logicalRequestEndpointChat, openAIDirectMessage(output))
|
|
}
|
|
|
|
func (s *Server) writeDirectError(turn *hotPathTurn, status int, errorType, message string) error {
|
|
disposition := hotPathTerminalDisposition{
|
|
Kind: hotPathDispositionProviderError, Cause: message, Source: "direct_error",
|
|
}
|
|
if turn != nil && turn.OuterTurn != nil {
|
|
turn.OuterTurn.commitTerminalError(errorType, errorType)
|
|
if selected, ok := turn.OuterTurn.terminalDisposition(); ok {
|
|
disposition = selected
|
|
}
|
|
} else if strings.Contains(strings.ToLower(errorType), "invalid") {
|
|
disposition.Kind = hotPathDispositionValidationError
|
|
}
|
|
if turn.Protocol == "anthropic" {
|
|
if !writeHotPathAnthropicOuterError(turn, status, errorType, message) {
|
|
policy := anthropicHotPathPolicy(disposition)
|
|
if !policy.silent {
|
|
writeAnthropicError(turn.Writer, policy.status, policy.errorType, message)
|
|
}
|
|
}
|
|
} else {
|
|
if !writeHotPathChatOuterError(turn, status, errorType, message, disposition) {
|
|
policy := chatHotPathPolicy(disposition)
|
|
if !policy.silent {
|
|
writeError(turn.Writer, policy.status, policy.errorType, message)
|
|
}
|
|
}
|
|
}
|
|
return fmt.Errorf("%s: %s", errorType, message)
|
|
}
|
|
|
|
func (s *Server) writeDirectResponse(turn *hotPathTurn, output normalizedStageOutput) error {
|
|
if turn.Protocol == "anthropic" {
|
|
return writeAnthropicDirectResponse(turn, output)
|
|
}
|
|
return writeOpenAIDirectResponse(turn, output)
|
|
}
|
|
|
|
func directPublicModel(turn *hotPathTurn) string {
|
|
if model := strings.TrimSpace(turn.PublicModelID); model != "" {
|
|
return model
|
|
}
|
|
if model := strings.TrimSpace(turn.Dispatch.ExternalModelID); model != "" {
|
|
return model
|
|
}
|
|
return turn.Dispatch.Target
|
|
}
|
|
|
|
func openAIDirectMessage(output normalizedStageOutput) chatMessage {
|
|
message := chatMessage{Role: "assistant", Content: output.Content, ReasoningContent: output.Reasoning}
|
|
for _, call := range output.ToolCalls {
|
|
message.ToolCalls = append(message.ToolCalls, openAIDirectToolCall(call))
|
|
}
|
|
return message
|
|
}
|
|
|
|
func openAIDirectToolCall(call normalizedToolCall) map[string]any {
|
|
return map[string]any{
|
|
"id": call.ID, "type": "function",
|
|
"function": map[string]any{"name": call.Name, "arguments": directToolArguments(call)},
|
|
}
|
|
}
|
|
|
|
func directToolArguments(call normalizedToolCall) string {
|
|
if strings.TrimSpace(call.RawArgs) != "" {
|
|
return call.RawArgs
|
|
}
|
|
raw, _ := json.Marshal(call.Arguments)
|
|
return string(raw)
|
|
}
|
|
|
|
func writeOpenAIDirectResponse(turn *hotPathTurn, output normalizedStageOutput) error {
|
|
if handled, err := writeHotPathChatOuterResponse(turn, output); handled {
|
|
return err
|
|
}
|
|
model := directPublicModel(turn)
|
|
finishReason := openAIDirectFinishReason(output.TerminalReason)
|
|
if finishReason == "" {
|
|
if len(output.ToolCalls) > 0 {
|
|
finishReason = "tool_calls"
|
|
} else {
|
|
finishReason = "stop"
|
|
}
|
|
}
|
|
if turn.Stream {
|
|
return writeOpenAIDirectStream(turn, output, model, finishReason)
|
|
}
|
|
response := map[string]any{
|
|
"id": output.ResponseID, "object": "chat.completion", "created": output.Created, "model": model,
|
|
"choices": []any{map[string]any{
|
|
"index": 0, "message": openAIDirectMessage(output), "finish_reason": finishReason,
|
|
}},
|
|
}
|
|
if len(output.Usage) > 0 {
|
|
response["usage"] = output.Usage
|
|
}
|
|
return writeDirectJSON(turn.Writer, http.StatusOK, response)
|
|
}
|
|
|
|
func openAIDirectFinishReason(reason string) string {
|
|
switch strings.TrimSpace(reason) {
|
|
case "end_turn":
|
|
return "stop"
|
|
case "tool_use":
|
|
return "tool_calls"
|
|
case "max_tokens":
|
|
return "length"
|
|
default:
|
|
return strings.TrimSpace(reason)
|
|
}
|
|
}
|
|
|
|
func writeOpenAIDirectStream(turn *hotPathTurn, output normalizedStageOutput, model, finishReason string) error {
|
|
flusher, ok := turn.Writer.(http.Flusher)
|
|
if !ok {
|
|
return fmt.Errorf("response writer does not support flushing")
|
|
}
|
|
w := turn.Writer
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.WriteHeader(http.StatusOK)
|
|
emit := func(delta map[string]any, reason string, usage json.RawMessage) error {
|
|
choice := map[string]any{"index": 0, "delta": delta, "finish_reason": nil}
|
|
if reason != "" {
|
|
choice["finish_reason"] = reason
|
|
}
|
|
chunk := map[string]any{
|
|
"id": output.ResponseID, "object": "chat.completion.chunk", "created": output.Created,
|
|
"model": model, "choices": []any{choice},
|
|
}
|
|
if len(usage) > 0 {
|
|
chunk["usage"] = usage
|
|
}
|
|
return writeDirectSSEData(w, flusher, chunk)
|
|
}
|
|
if err := emit(map[string]any{"role": "assistant"}, "", nil); err != nil {
|
|
return err
|
|
}
|
|
if output.Reasoning != "" {
|
|
if err := emit(map[string]any{"reasoning_content": output.Reasoning}, "", nil); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if output.Content != "" {
|
|
if err := emit(map[string]any{"content": output.Content}, "", nil); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if len(output.ToolCalls) > 0 {
|
|
calls := make([]any, 0, len(output.ToolCalls))
|
|
for index, call := range output.ToolCalls {
|
|
value := openAIDirectToolCall(call)
|
|
value["index"] = index
|
|
calls = append(calls, value)
|
|
}
|
|
if err := emit(map[string]any{"tool_calls": calls}, "", nil); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := emit(map[string]any{}, finishReason, output.Usage); err != nil {
|
|
return err
|
|
}
|
|
if _, err := fmt.Fprint(w, "data: [DONE]\n\n"); err != nil {
|
|
return err
|
|
}
|
|
flusher.Flush()
|
|
return nil
|
|
}
|
|
|
|
func anthropicDirectBlocks(output normalizedStageOutput) []map[string]any {
|
|
blocks := make([]map[string]any, 0, 2+len(output.ToolCalls))
|
|
if output.Reasoning != "" {
|
|
blocks = append(blocks, map[string]any{"type": "thinking", "thinking": output.Reasoning, "signature": output.ReasoningSignature})
|
|
}
|
|
if output.Content != "" {
|
|
blocks = append(blocks, map[string]any{"type": "text", "text": output.Content})
|
|
}
|
|
for _, call := range output.ToolCalls {
|
|
var input any
|
|
if json.Unmarshal([]byte(directToolArguments(call)), &input) != nil {
|
|
input = map[string]any{}
|
|
}
|
|
blocks = append(blocks, map[string]any{"type": "tool_use", "id": call.ID, "name": call.Name, "input": input})
|
|
}
|
|
return blocks
|
|
}
|
|
|
|
func writeAnthropicDirectResponse(turn *hotPathTurn, output normalizedStageOutput) error {
|
|
if handled, err := writeHotPathAnthropicOuterResponse(turn, output); handled {
|
|
return err
|
|
}
|
|
codec := newAnthropicHotPathCodec(
|
|
turn.Writer, directPublicModel(turn), turn.Stream, turn.RequestID, 0,
|
|
)
|
|
codec.outer = turn.OuterTurn
|
|
return codec.write(output)
|
|
}
|
|
|
|
func anthropicDirectStopReason(reason string) string {
|
|
switch strings.TrimSpace(reason) {
|
|
case "length":
|
|
return "max_tokens"
|
|
case "tool_calls":
|
|
return "tool_use"
|
|
case "stop":
|
|
return "end_turn"
|
|
default:
|
|
return strings.TrimSpace(reason)
|
|
}
|
|
}
|
|
|
|
func writeAnthropicDirectStream(turn *hotPathTurn, output normalizedStageOutput, model, stopReason string) error {
|
|
flusher, ok := turn.Writer.(http.Flusher)
|
|
if !ok {
|
|
return fmt.Errorf("response writer does not support flushing")
|
|
}
|
|
w := turn.Writer
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.WriteHeader(http.StatusOK)
|
|
startUsage := anthropicStartUsage(output.Usage)
|
|
message := map[string]any{
|
|
"id": output.ResponseID, "type": "message", "role": "assistant", "model": model,
|
|
"content": []any{}, "stop_reason": nil, "stop_sequence": nil,
|
|
}
|
|
if len(startUsage) > 0 {
|
|
message["usage"] = startUsage
|
|
}
|
|
if err := writeDirectAnthropicEvent(w, flusher, "message_start", map[string]any{"type": "message_start", "message": message}); err != nil {
|
|
return err
|
|
}
|
|
for index, block := range anthropicDirectBlocks(output) {
|
|
blockType, _ := block["type"].(string)
|
|
startBlock := make(map[string]any, len(block))
|
|
for key, value := range block {
|
|
startBlock[key] = value
|
|
}
|
|
switch blockType {
|
|
case "text":
|
|
startBlock["text"] = ""
|
|
case "thinking":
|
|
startBlock["thinking"] = ""
|
|
startBlock["signature"] = ""
|
|
case "tool_use":
|
|
startBlock["input"] = map[string]any{}
|
|
}
|
|
if err := writeDirectAnthropicEvent(w, flusher, "content_block_start", map[string]any{
|
|
"type": "content_block_start", "index": index, "content_block": startBlock,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
var delta map[string]any
|
|
switch blockType {
|
|
case "text":
|
|
delta = map[string]any{"type": "text_delta", "text": block["text"]}
|
|
case "thinking":
|
|
delta = map[string]any{"type": "thinking_delta", "thinking": block["thinking"]}
|
|
case "tool_use":
|
|
raw, _ := json.Marshal(block["input"])
|
|
delta = map[string]any{"type": "input_json_delta", "partial_json": string(raw)}
|
|
}
|
|
if err := writeDirectAnthropicEvent(w, flusher, "content_block_delta", map[string]any{
|
|
"type": "content_block_delta", "index": index, "delta": delta,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if blockType == "thinking" && block["signature"] != "" {
|
|
if err := writeDirectAnthropicEvent(w, 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(w, flusher, "content_block_stop", map[string]any{
|
|
"type": "content_block_stop", "index": index,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
delta := map[string]any{
|
|
"type": "message_delta", "delta": map[string]any{"stop_reason": stopReason, "stop_sequence": nil},
|
|
}
|
|
if len(output.Usage) > 0 {
|
|
delta["usage"] = output.Usage
|
|
}
|
|
if err := writeDirectAnthropicEvent(w, flusher, "message_delta", delta); err != nil {
|
|
return err
|
|
}
|
|
return writeDirectAnthropicEvent(w, flusher, "message_stop", map[string]any{"type": "message_stop"})
|
|
}
|
|
|
|
func anthropicStartUsage(raw json.RawMessage) json.RawMessage {
|
|
if len(raw) == 0 {
|
|
return nil
|
|
}
|
|
var usage map[string]any
|
|
if json.Unmarshal(raw, &usage) != nil {
|
|
return nil
|
|
}
|
|
for key := range usage {
|
|
if key == "output_tokens" {
|
|
delete(usage, key)
|
|
}
|
|
}
|
|
encoded, _ := json.Marshal(usage)
|
|
return encoded
|
|
}
|
|
|
|
func writeDirectJSON(w http.ResponseWriter, status int, value any) error {
|
|
body, err := json.Marshal(value)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_, err = w.Write(append(body, '\n'))
|
|
return err
|
|
}
|
|
|
|
func writeDirectSSEData(w http.ResponseWriter, flusher http.Flusher, value any) error {
|
|
body, err := json.Marshal(value)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := fmt.Fprintf(w, "data: %s\n\n", body); err != nil {
|
|
return err
|
|
}
|
|
flusher.Flush()
|
|
return nil
|
|
}
|
|
|
|
func writeDirectAnthropicEvent(w http.ResponseWriter, flusher http.Flusher, event string, value any) error {
|
|
if err := writeAnthropicSSEEvent(w, event, value); err != nil {
|
|
return err
|
|
}
|
|
flusher.Flush()
|
|
return nil
|
|
}
|