iop/apps/edge/internal/openai/hot_path_direct.go
toki 495996fee4 feat(openai): 핫패스 에이전트 실행 경로를 확장한다
Anthropic·Chat 게이트와 관찰·종료 제어를 통합하고 관련 계약·검증 산출물을 반영한다.
2026-08-06 00:09:24 +09:00

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
}