Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions relay/channel/openai/helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,43 @@ func HandleFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, lastStream
for _, resp := range claudeResponses {
_ = helper.ClaudeData(c, *resp)
}

// Fallback: ensure closing events are emitted even if upstream never sent
// finish_reason / usage. Some OpenAI-compatible upstreams (e.g. LiteLLM)
// truncate the stream without proper terminators, and Claude Code hangs
// indefinitely without message_stop.
if !info.ClaudeConvertInfo.Done {
// Close any open content blocks first so the event sequence stays
// valid (content_block_start … content_block_stop pairs).
for _, stopBlock := range service.GenerateClaudeStopBlocksForOpenInfo(info) {
_ = helper.ClaudeData(c, *stopBlock)
}
info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeNone

stopReason := service.StopReasonOpenAI2Claude(info.FinishReason)
if stopReason == "" {
stopReason = "end_turn"
}
if usage != nil {
_ = helper.ClaudeData(c, dto.ClaudeResponse{
Type: "message_delta",
Usage: service.BuildClaudeUsageFromOpenAIUsage(usage),
Delta: &dto.ClaudeMediaMessage{
StopReason: common.GetPointer[string](stopReason),
},
})
} else {
_ = helper.ClaudeData(c, dto.ClaudeResponse{
Type: "message_delta",
Delta: &dto.ClaudeMediaMessage{
StopReason: common.GetPointer[string](stopReason),
},
})
}
_ = helper.ClaudeData(c, dto.ClaudeResponse{
Type: "message_stop",
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
info.ClaudeConvertInfo.Done = true

case types.RelayFormatGemini:
Expand Down
85 changes: 81 additions & 4 deletions service/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,29 @@ func generateStopBlock(index int) *dto.ClaudeResponse {
}
}

// GenerateClaudeStopBlocksForOpenInfo returns the content_block_stop events
// required to close any open Claude content blocks tracked in
// info.ClaudeConvertInfo. Used by fallback paths that need to emit a valid
// terminal event sequence without re-implementing the block-tracking logic.
//
// Returns an empty slice if no block is currently open.
func GenerateClaudeStopBlocksForOpenInfo(info *relaycommon.RelayInfo) []*dto.ClaudeResponse {
if info == nil {
return nil
}
var responses []*dto.ClaudeResponse
switch info.ClaudeConvertInfo.LastMessagesType {
case relaycommon.LastMessageTypeText, relaycommon.LastMessageTypeThinking:
responses = append(responses, generateStopBlock(info.ClaudeConvertInfo.Index))
case relaycommon.LastMessageTypeTools:
base := info.ClaudeConvertInfo.ToolCallBaseIndex
for offset := 0; offset <= info.ClaudeConvertInfo.ToolCallMaxIndexOffset; offset++ {
responses = append(responses, generateStopBlock(base+offset))
}
}
return responses
}

func buildClaudeUsageFromOpenAIUsage(oaiUsage *dto.Usage) *dto.ClaudeUsage {
if oaiUsage == nil {
return nil
Expand All @@ -247,6 +270,13 @@ func buildClaudeUsageFromOpenAIUsage(oaiUsage *dto.Usage) *dto.ClaudeUsage {
return usage
}

// BuildClaudeUsageFromOpenAIUsage is the exported variant of
// buildClaudeUsageFromOpenAIUsage for cross-package callers (e.g. fallback
// closing events in relay/channel/openai/helper.go).
func BuildClaudeUsageFromOpenAIUsage(oaiUsage *dto.Usage) *dto.ClaudeUsage {
return buildClaudeUsageFromOpenAIUsage(oaiUsage)
}

func NormalizeCacheCreationSplit(totalTokens int, tokens5m int, tokens1h int) (int, int) {
remainder := lo.Max([]int{totalTokens - tokens5m - tokens1h, 0})
return tokens5m + remainder, tokens1h
Expand Down Expand Up @@ -418,12 +448,27 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
if oaiUsage == nil {
oaiUsage = info.ClaudeConvertInfo.Usage
}
// Always emit message_delta + message_stop, even when usage is
// missing. Some OpenAI-compatible upstreams (e.g. LiteLLM) send
// finish_reason without usage; skipping the terminal events
// would leave Claude clients hanging.
stopReason := stopReasonOpenAI2Claude(info.FinishReason)
if stopReason == "" {
stopReason = "end_turn"
}
if oaiUsage != nil {
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_delta",
Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
Delta: &dto.ClaudeMediaMessage{
StopReason: common.GetPointer[string](stopReasonOpenAI2Claude(info.FinishReason)),
StopReason: common.GetPointer[string](stopReason),
},
})
} else {
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_delta",
Delta: &dto.ClaudeMediaMessage{
StopReason: common.GetPointer[string](stopReason),
},
})
}
Expand Down Expand Up @@ -468,10 +513,36 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
oaiUsage := openAIResponse.Usage
if oaiUsage == nil {
oaiUsage = info.ClaudeConvertInfo.Usage
// Some upstreams emit finish_reason first, then send a final usage-only chunk.
// Defer closing until usage is available so the final message_delta carries it.
return claudeResponses
}
// Emit closing events immediately. Some OpenAI-compatible upstreams
// (e.g. LiteLLM) send finish_reason without usage and never follow up
// with a usage-only chunk; deferring would leave Claude Code hanging.
stopOpenBlocks()
stopReason := stopReasonOpenAI2Claude(info.FinishReason)
if stopReason == "" {
stopReason = "end_turn"
}
if oaiUsage != nil {
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_delta",
Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
Delta: &dto.ClaudeMediaMessage{
StopReason: common.GetPointer[string](stopReason),
},
})
} else {
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_delta",
Delta: &dto.ClaudeMediaMessage{
StopReason: common.GetPointer[string](stopReason),
},
})
}
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_stop",
})
info.ClaudeConvertInfo.Done = true
return claudeResponses
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

var claudeResponse dto.ClaudeResponse
Expand Down Expand Up @@ -647,6 +718,12 @@ func stopReasonOpenAI2Claude(reason string) string {
return reasonmap.OpenAIFinishReasonToClaudeStopReason(reason)
}

// StopReasonOpenAI2Claude is the exported variant for cross-package callers
// (e.g. fallback closing events in relay/channel/openai/helper.go).
func StopReasonOpenAI2Claude(reason string) string {
return stopReasonOpenAI2Claude(reason)
}

func toJSONString(v interface{}) string {
b, err := json.Marshal(v)
if err != nil {
Expand Down