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
2 changes: 1 addition & 1 deletion internal/server/gql/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ func NewSchema(
defaultSelector: defaultSelector,
candidateSelectorDiagnostics: candidateSelectorDiagnostics,
channelLimiterManager: channelLimiterManager,
TestChannelOrchestrator: orchestrator.NewTestChannelOrchestrator(channelService, requestService, systemService, usageLogService, promptProtectionRuleService, httpClient),
TestChannelOrchestrator: orchestrator.NewTestChannelOrchestrator(channelService, requestService, systemService, usageLogService, promptProtectionRuleService, httpClient, modelService),
gcWorker: gcWorker,
videoWorker: videoWorker,
},
Expand Down
199 changes: 139 additions & 60 deletions internal/server/orchestrator/tester.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"github.com/tidwall/gjson"
"golang.org/x/sync/errgroup"

"github.com/looplj/axonhub/internal/ent/model"
"github.com/looplj/axonhub/internal/log"
"github.com/looplj/axonhub/internal/objects"
"github.com/looplj/axonhub/internal/pkg/xjson"
Expand All @@ -35,6 +36,7 @@
usageLogService *biz.UsageLogService
promptProtectionRuleService *biz.PromptProtectionRuleService
httpClient *httpclient.HttpClient
modelService *biz.ModelService
modelCircuitBreaker *biz.ModelCircuitBreaker
modelMapper *ModelMapper
loadBalancer *LoadBalancer
Expand All @@ -49,6 +51,7 @@
usageLogService *biz.UsageLogService,
promptProtectionRuleService *biz.PromptProtectionRuleService,
httpClient *httpclient.HttpClient,
modelService *biz.ModelService,
) *TestChannelOrchestrator {
return &TestChannelOrchestrator{
channelService: channelService,
Expand All @@ -57,6 +60,7 @@
usageLogService: usageLogService,
promptProtectionRuleService: promptProtectionRuleService,
httpClient: httpClient,
modelService: modelService,
modelCircuitBreaker: biz.NewModelCircuitBreaker(),
modelMapper: NewModelMapper(),
loadBalancer: NewLoadBalancer(systemService, channelService, NewWeightStrategy()),
Expand All @@ -78,6 +82,83 @@
Error *string
}

// isTTSModel checks whether a model outputs audio by looking up its model card.
func (processor *TestChannelOrchestrator) isTTSModel(ctx context.Context, modelID string) bool {
if processor.modelService == nil || modelID == "" {
return false
}

m, err := processor.modelService.GetModelByModelID(ctx, modelID, model.StatusEnabled)
if err != nil {
return false
}

if m.ModelCard == nil {
return false
}

for _, output := range m.ModelCard.Modalities.Output {

Check failure on line 100 in internal/server/orchestrator/tester.go

View workflow job for this annotation

GitHub Actions / lint

slicescontains: Loop can be simplified using slices.Contains (modernize)
if output == "audio" {
return true
}
}

return false
}

// buildTestRequest constructs the appropriate test request for a model.
// For TTS models, it builds a request with audio modalities and parameters.
// For regular models, it builds a standard chat completion request.
func (processor *TestChannelOrchestrator) buildTestRequest(ctx context.Context, testModel string, useStream bool) *llm.Request {
if processor.isTTSModel(ctx, testModel) {
return &llm.Request{
Model: testModel,
Messages: []llm.Message{
{
Role: "assistant",
Content: llm.MessageContent{
Content: lo.ToPtr("Hello, this is a TTS test. Please respond with audio."),
},
},
},
Modalities: []string{"text", "audio"},
Audio: &llm.ChatCompletionAudioParam{},
MaxCompletionTokens: lo.ToPtr(int64(256)),
Stream: lo.ToPtr(useStream),
}
}

// Standard chat completion request for non-TTS models.
return &llm.Request{
Model: testModel,
Messages: []llm.Message{
{
Role: "system",
Content: llm.MessageContent{
Content: lo.ToPtr("You are a helpful assistant."),
},
},
{
Role: "user",
Content: llm.MessageContent{
MultipleContent: []llm.MessageContentPart{
{
Type: "text",
Text: lo.ToPtr("Hello world, I'm AxonHub."),
},
{
Type: "text",
Text: lo.ToPtr("Please tell me who you are?"),
},
},
},
},
},
MaxCompletionTokens: lo.ToPtr(int64(256)),
Stream: lo.ToPtr(useStream),
}
}

// TestChannel tests a specific channel with a simple request.
func (processor *TestChannelOrchestrator) TestChannel(
ctx context.Context,
Expand Down Expand Up @@ -122,35 +203,8 @@
// Check if the channel requires streaming
useStream := channel != nil && channel.Policies.Stream == objects.CapabilityPolicyRequire

// Create a simple test request
llmRequest := &llm.Request{
Model: testModel,
Messages: []llm.Message{
{
Role: "system",
Content: llm.MessageContent{
Content: lo.ToPtr("You are a helpful assistant."),
},
},
{
Role: "user",
Content: llm.MessageContent{
MultipleContent: []llm.MessageContentPart{
{
Type: "text",
Text: lo.ToPtr("Hello world, I'm AxonHub."),
},
{
Type: "text",
Text: lo.ToPtr("Please tell me who you are?"),
},
},
},
},
},
MaxCompletionTokens: lo.ToPtr(int64(256)),
Stream: lo.ToPtr(useStream),
}
// Build the appropriate test request based on model type
llmRequest := processor.buildTestRequest(ctx, testModel, useStream)

body, err := json.Marshal(llmRequest)
if err != nil {
Expand Down Expand Up @@ -205,10 +259,14 @@
}, nil
}

// Extract message text or audio transcript from the response.
msg := response.Choices[0].Message
messageText := extractMessageText(msg)

return &TestChannelResult{
Latency: latency,
Success: true,
Message: response.Choices[0].Message.Content.Content,
Message: messageText,
Error: nil,
}, nil
}
Expand All @@ -225,6 +283,7 @@

// Accumulate stream chunks
var accumulatedContent string
var hasAudioResponse bool

for stream.Next() {
select {
Expand Down Expand Up @@ -256,8 +315,19 @@
}

// Accumulate content from the first choice
if len(chunk.Choices) > 0 && chunk.Choices[0].Delta != nil && chunk.Choices[0].Delta.Content.Content != nil {
accumulatedContent += *chunk.Choices[0].Delta.Content.Content
if len(chunk.Choices) > 0 && chunk.Choices[0].Delta != nil {
delta := chunk.Choices[0].Delta
if delta.Content.Content != nil {
accumulatedContent += *delta.Content.Content
}

// Also accumulate audio transcripts from TTS streaming responses.
if delta.Audio != nil {
hasAudioResponse = true
if delta.Audio.Transcript != "" {
accumulatedContent += delta.Audio.Transcript
}
}
}
}

Expand All @@ -283,6 +353,15 @@
}

if accumulatedContent == "" {
if hasAudioResponse {
return &TestChannelResult{
Latency: latency,
Success: true,
Message: lo.ToPtr("(audio stream response)"),
Error: nil,
}, nil
}

return &TestChannelResult{
Latency: latency,
Success: false,
Expand Down Expand Up @@ -441,34 +520,7 @@
modelCircuitBreaker: processor.modelCircuitBreaker,
}

llmRequest := &llm.Request{
Model: testModel,
Messages: []llm.Message{
{
Role: "system",
Content: llm.MessageContent{
Content: new("You are a helpful assistant."),
},
},
{
Role: "user",
Content: llm.MessageContent{
MultipleContent: []llm.MessageContentPart{
{
Type: "text",
Text: new("Hello world, I'm AxonHub."),
},
{
Type: "text",
Text: new("Please tell me who you are?"),
},
},
},
},
},
MaxCompletionTokens: new(int64(256)),
Stream: new(useStream),
}
llmRequest := processor.buildTestRequest(ctx, testModel, useStream)

body, err := json.Marshal(llmRequest)
if err != nil {
Expand Down Expand Up @@ -554,3 +606,30 @@

return key[:4] + "****" + key[len(key)-4:]
}

// extractMessageText extracts displayable text from a message, handling both text
// and audio responses. For TTS models, the response may contain audio data with a
// transcript instead of plain text content.
func extractMessageText(msg *llm.Message) *string {
if msg == nil {
return lo.ToPtr("")
}

// Prefer text content if available.
if msg.Content.Content != nil && *msg.Content.Content != "" {
return msg.Content.Content
}

// Fall back to audio transcript for TTS responses.
if msg.Audio != nil {
if msg.Audio.Transcript != "" {
return lo.ToPtr(msg.Audio.Transcript)
}

if msg.Audio.Data != "" {
return lo.ToPtr("(audio response)")
}
}

return lo.ToPtr("")
}
12 changes: 10 additions & 2 deletions llm/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,7 @@ type Request struct {
// Parameters for audio output. Required when audio output is requested with
// `modalities: ["audio"]`.
// [Learn more](https://platform.openai.com/docs/guides/audio).
// TODO
// Audio ChatCompletionAudioParam `json:"audio,omitzero"`
Audio *ChatCompletionAudioParam `json:"audio,omitzero"`

// Modify the likelihood of specified tokens appearing in the completion.
//
Expand Down Expand Up @@ -295,6 +294,15 @@ type StreamOptions struct {
IncludeUsage bool `json:"include_usage,omitempty"`
}

// ChatCompletionAudioParam specifies audio output parameters for chat completions.
// Required when audio output is requested with modalities: ["audio"].
type ChatCompletionAudioParam struct {
// Format specifies the output audio format (e.g. "mp3", "wav", "opus").
Format string `json:"format,omitempty"`
// Voice specifies the voice to use for audio generation (e.g. "alloy", "echo", "nova").
Voice string `json:"voice,omitempty"`
}

type Stop struct {
// Stop and MultipleStop are mutually exclusive representations of the same field.
// If both are populated, Stop takes precedence during marshaling.
Expand Down
9 changes: 8 additions & 1 deletion llm/transformer/anthropic/outbound_convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,15 @@ func buildBaseRequest(chatReq *llm.Request, config *Config) *MessageRequest {
MaxTokens: resolveMaxTokens(chatReq),
}

var userID string
if chatReq.Metadata != nil && chatReq.Metadata["user_id"] != "" {
req.Metadata = &AnthropicMetadata{UserID: chatReq.Metadata["user_id"]}
userID = chatReq.Metadata["user_id"]
} else if chatReq.User != nil && *chatReq.User != "" {
userID = *chatReq.User
}

if userID != "" {
req.Metadata = &AnthropicMetadata{UserID: shared.SanitizeUserID(userID)}
}

// DeepSeek Anthropic format supports output_config.effort. When reasoning_effort
Expand Down
15 changes: 13 additions & 2 deletions llm/transformer/doubao/outbound.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/looplj/axonhub/llm/internal/pkg/xurl"
"github.com/looplj/axonhub/llm/transformer"
"github.com/looplj/axonhub/llm/transformer/openai"
"github.com/looplj/axonhub/llm/transformer/shared"
)

// Config holds all configuration for the Doubao outbound transformer.
Expand Down Expand Up @@ -142,8 +143,18 @@ func (t *OutboundTransformer) TransformRequest(
RequestID: "",
}

if llmReq.Metadata != nil {
doubaoReq.UserID = llmReq.Metadata["user_id"]
var userID string
if llmReq.Metadata != nil && llmReq.Metadata["user_id"] != "" {
userID = llmReq.Metadata["user_id"]
} else if llmReq.User != nil && *llmReq.User != "" {
userID = *llmReq.User
}

if userID != "" {
doubaoReq.UserID = shared.SanitizeUserID(userID)
}

if llmReq.Metadata != nil && llmReq.Metadata["request_id"] != "" {
doubaoReq.RequestID = llmReq.Metadata["request_id"]
}

Expand Down
11 changes: 11 additions & 0 deletions llm/transformer/openai/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ type Request struct {
// Modalities specifies output types (text, audio, image).
Modalities []string `json:"modalities,omitempty"`

// Audio specifies audio output parameters. Required when modalities includes "audio".
Audio *ChatCompletionAudioParam `json:"audio,omitempty"`

// ReasoningEffort controls effort on reasoning models.
ReasoningEffort string `json:"reasoning_effort,omitempty"`

Expand Down Expand Up @@ -102,6 +105,14 @@ type StreamOptions struct {
IncludeUsage bool `json:"include_usage,omitempty"`
}

// ChatCompletionAudioParam specifies audio output parameters for chat completions.
type ChatCompletionAudioParam struct {
// Format specifies the output audio format (e.g. "mp3", "wav", "opus").
Format string `json:"format,omitempty"`
// Voice specifies the voice to use for audio generation (e.g. "alloy", "echo", "nova").
Voice string `json:"voice,omitempty"`
}

// Stop represents stop sequences.
type Stop struct {
// Stop and MultipleStop are mutually exclusive representations of the same field.
Expand Down
Loading
Loading