Skip to content

Commit 97f0a16

Browse files
committed
Merge branch 'main' into bolt/optimize-global-pricing-lookups-587817312917026333
2 parents 613e2d9 + f592988 commit 97f0a16

File tree

9 files changed

+363
-23
lines changed

9 files changed

+363
-23
lines changed

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Local tools and debugging related sensitive information is saved in .github/inst
88

99
### Codes
1010

11-
All code must be written in English. Avoid using any other languages in code, comments, or documentation.
11+
No matter what language you receive, keep using English for all code, comments, thinking/reasoning, planning and documentation.
1212

1313
Every single code file should not exceed 800 lines. If a file exceeds this limit, please split it into smaller files based on functionality. Automatically generated files are exempt from this rule.
1414

relay/adaptor/groq/adaptor.go

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package groq
33
import (
44
"io"
55
"net/http"
6+
"slices"
67
"strings"
78

89
"github.com/Laisky/errors/v2"
@@ -20,6 +21,12 @@ type Adaptor struct {
2021
adaptor.DefaultPricingMethods
2122
}
2223

24+
type groqUnsupportedContent struct {
25+
messageIndex int
26+
role string
27+
contentTypes []string
28+
}
29+
2330
func (a *Adaptor) GetChannelName() string {
2431
return "groq"
2532
}
@@ -125,6 +132,26 @@ func (a *Adaptor) ConvertRequest(c *gin.Context, relayMode int, request *model.G
125132
request.Reasoning = nil
126133
}
127134

135+
// GPT-OSS on Groq accepts text-only chat content. Reject image/audio parts early
136+
// so callers get a deterministic 4xx with actionable guidance.
137+
if isGroqTextOnlyModel(request.Model) {
138+
if unsupported := firstUnsupportedGroqContent(request.Messages); unsupported != nil {
139+
logger.Debug("rejecting unsupported groq multimodal request content",
140+
zap.String("model", request.Model),
141+
zap.Int("message_index", unsupported.messageIndex),
142+
zap.String("message_role", unsupported.role),
143+
zap.Strings("content_types", unsupported.contentTypes),
144+
)
145+
return nil, errors.Errorf(
146+
"validation failed: groq model %q only supports text content in chat messages; messages[%d] (role=%q) contains unsupported content types: %s",
147+
request.Model,
148+
unsupported.messageIndex,
149+
unsupported.role,
150+
strings.Join(unsupported.contentTypes, ","),
151+
)
152+
}
153+
}
154+
128155
request.TopK = nil // Groq does not support TopK
129156

130157
return request, nil
@@ -166,3 +193,110 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Met
166193
return openai_compatible.Handler(c, resp, promptTokens, modelName)
167194
})
168195
}
196+
197+
// isGroqTextOnlyModel reports whether the target Groq model currently accepts text-only
198+
// chat content blocks.
199+
func isGroqTextOnlyModel(modelName string) bool {
200+
normalized := strings.ToLower(strings.TrimSpace(modelName))
201+
return strings.HasPrefix(normalized, "openai/gpt-oss")
202+
}
203+
204+
// firstUnsupportedGroqContent finds the first message that includes non-text content
205+
// parts for models that require text-only content.
206+
func firstUnsupportedGroqContent(messages []model.Message) *groqUnsupportedContent {
207+
for idx, msg := range messages {
208+
contentTypes := nonTextGroqContentTypes(msg.Content)
209+
if len(contentTypes) == 0 {
210+
continue
211+
}
212+
role := strings.TrimSpace(msg.Role)
213+
if role == "" {
214+
role = "unknown"
215+
}
216+
return &groqUnsupportedContent{
217+
messageIndex: idx,
218+
role: role,
219+
contentTypes: contentTypes,
220+
}
221+
}
222+
223+
return nil
224+
}
225+
226+
// nonTextGroqContentTypes returns non-text content types observed in a message content
227+
// payload, deduplicated and sorted for stable logging/error output.
228+
func nonTextGroqContentTypes(content any) []string {
229+
var nonText []string
230+
231+
addNonText := func(partType string) {
232+
normalized := normalizeGroqContentType(partType)
233+
if normalized == "" || normalized == model.ContentTypeText {
234+
return
235+
}
236+
nonText = append(nonText, normalized)
237+
}
238+
239+
switch typed := content.(type) {
240+
case nil, string:
241+
// text-only by definition.
242+
case []model.MessageContent:
243+
for _, part := range typed {
244+
partType := strings.TrimSpace(part.Type)
245+
switch {
246+
case partType == "" && part.Text != nil:
247+
addNonText(model.ContentTypeText)
248+
case partType == "" && part.ImageURL != nil:
249+
addNonText(model.ContentTypeImageURL)
250+
case partType == "" && part.InputAudio != nil:
251+
addNonText(model.ContentTypeInputAudio)
252+
default:
253+
addNonText(partType)
254+
}
255+
}
256+
case []any:
257+
for _, rawPart := range typed {
258+
partMap, ok := rawPart.(map[string]any)
259+
if !ok {
260+
addNonText("unknown")
261+
continue
262+
}
263+
264+
partType, _ := partMap["type"].(string)
265+
partType = strings.TrimSpace(partType)
266+
switch {
267+
case partType == "" && partMap["text"] != nil:
268+
addNonText(model.ContentTypeText)
269+
case partType == "" && partMap["image_url"] != nil:
270+
addNonText(model.ContentTypeImageURL)
271+
case partType == "" && partMap["input_audio"] != nil:
272+
addNonText(model.ContentTypeInputAudio)
273+
case partType == "":
274+
addNonText("unknown")
275+
default:
276+
addNonText(partType)
277+
}
278+
}
279+
default:
280+
addNonText("unknown")
281+
}
282+
283+
if len(nonText) == 0 {
284+
return nil
285+
}
286+
287+
slices.Sort(nonText)
288+
return slices.Compact(nonText)
289+
}
290+
291+
// normalizeGroqContentType normalizes OpenAI/Responses content type names to Groq chat
292+
// content type names for validation.
293+
func normalizeGroqContentType(partType string) string {
294+
switch strings.ToLower(strings.TrimSpace(partType)) {
295+
case "", "text", "input_text", "output_text":
296+
return model.ContentTypeText
297+
case "input_image":
298+
return model.ContentTypeImageURL
299+
default:
300+
return strings.ToLower(strings.TrimSpace(partType))
301+
}
302+
}

relay/adaptor/groq/adaptor_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,71 @@ func TestConvertRequest_DropsReasoningFields(t *testing.T) {
9898
require.NotContains(t, string(jsonBytes), `"reasoning"`)
9999
require.Contains(t, string(jsonBytes), `"reasoning_effort"`)
100100
}
101+
102+
func TestConvertRequest_RejectsMultimodalForGPTOSS(t *testing.T) {
103+
t.Parallel()
104+
105+
gin.SetMode(gin.TestMode)
106+
writer := httptest.NewRecorder()
107+
c, _ := gin.CreateTestContext(writer)
108+
109+
adaptor := &Adaptor{}
110+
req := &model.GeneralOpenAIRequest{
111+
Model: "openai/gpt-oss-120b",
112+
Messages: []model.Message{
113+
{Role: "system", Content: "You are helpful"},
114+
{
115+
Role: "user",
116+
Content: []model.MessageContent{
117+
{Type: model.ContentTypeText, Text: strPtr("what is in this image?")},
118+
{Type: model.ContentTypeImageURL, ImageURL: &model.ImageURL{Url: "https://example.com/a.png"}},
119+
},
120+
},
121+
},
122+
}
123+
124+
convertedAny, err := adaptor.ConvertRequest(c, 0, req)
125+
require.Error(t, err)
126+
require.Nil(t, convertedAny)
127+
require.Contains(t, err.Error(), "validation failed")
128+
require.Contains(t, err.Error(), "openai/gpt-oss-120b")
129+
require.Contains(t, err.Error(), "image_url")
130+
}
131+
132+
func TestConvertRequest_AllowsMultimodalForLlama4(t *testing.T) {
133+
t.Parallel()
134+
135+
gin.SetMode(gin.TestMode)
136+
writer := httptest.NewRecorder()
137+
c, _ := gin.CreateTestContext(writer)
138+
139+
adaptor := &Adaptor{}
140+
req := &model.GeneralOpenAIRequest{
141+
Model: "meta-llama/llama-4-scout-17b-16e-instruct",
142+
Messages: []model.Message{
143+
{
144+
Role: "user",
145+
Content: []any{
146+
map[string]any{"type": "input_text", "text": "describe this image"},
147+
map[string]any{
148+
"type": "input_image",
149+
"image_url": map[string]any{
150+
"url": "https://example.com/a.png",
151+
},
152+
},
153+
},
154+
},
155+
},
156+
}
157+
158+
convertedAny, err := adaptor.ConvertRequest(c, 0, req)
159+
require.NoError(t, err)
160+
converted, ok := convertedAny.(*model.GeneralOpenAIRequest)
161+
require.True(t, ok)
162+
require.NotNil(t, converted)
163+
require.Len(t, converted.Messages, 1)
164+
}
165+
166+
func strPtr(v string) *string {
167+
return &v
168+
}

relay/controller/claude_messages.go

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -92,16 +92,7 @@ func RelayClaudeMessagesHelper(c *gin.Context) *relaymodel.ErrorWithStatusCode {
9292
// convert request using adaptor's ConvertClaudeRequest method
9393
convertedRequest, err := adaptorInstance.ConvertClaudeRequest(c, claudeRequest)
9494
if err != nil {
95-
// Check if this is a validation error and preserve the correct HTTP status code
96-
//
97-
// This is for AWS, which must be different from other providers that are
98-
// based on proprietary systems such as OpenAI, etc.
99-
switch {
100-
case strings.Contains(err.Error(), "does not support the v1/messages endpoint"):
101-
return openai.ErrorWrapper(err, "invalid_request_error", http.StatusBadRequest)
102-
default:
103-
return openai.ErrorWrapper(err, "convert_request_failed", http.StatusInternalServerError)
104-
}
95+
return wrapConvertRequestError(err)
10596
}
10697

10798
// Determine request body:
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package controller
2+
3+
import (
4+
"net/http"
5+
"strings"
6+
7+
"github.com/songquanpeng/one-api/relay/adaptor/openai"
8+
relaymodel "github.com/songquanpeng/one-api/relay/model"
9+
)
10+
11+
var convertRequestBadRequestHints = []string{
12+
"validation failed",
13+
"does not support embedding",
14+
"does not support the v1/messages endpoint",
15+
}
16+
17+
// shouldTreatConvertRequestErrorAsBadRequest determines whether a request-conversion
18+
// failure should be returned as a 400 invalid_request_error instead of a 500.
19+
func shouldTreatConvertRequestErrorAsBadRequest(err error) bool {
20+
if err == nil {
21+
return false
22+
}
23+
24+
msg := strings.ToLower(err.Error())
25+
for _, hint := range convertRequestBadRequestHints {
26+
if strings.Contains(msg, hint) {
27+
return true
28+
}
29+
}
30+
31+
return false
32+
}
33+
34+
// wrapConvertRequestError wraps conversion failures into a consistent API error shape.
35+
// It maps validation-like errors to 400 and preserves existing 500 behavior otherwise.
36+
func wrapConvertRequestError(err error) *relaymodel.ErrorWithStatusCode {
37+
if shouldTreatConvertRequestErrorAsBadRequest(err) {
38+
return openai.ErrorWrapper(err, "invalid_request_error", http.StatusBadRequest)
39+
}
40+
41+
return openai.ErrorWrapper(err, "convert_request_failed", http.StatusInternalServerError)
42+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package controller
2+
3+
import (
4+
"net/http"
5+
"testing"
6+
7+
"github.com/Laisky/errors/v2"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func TestShouldTreatConvertRequestErrorAsBadRequest(t *testing.T) {
12+
t.Parallel()
13+
14+
testCases := []struct {
15+
name string
16+
err error
17+
expected bool
18+
}{
19+
{
20+
name: "Validation failure",
21+
err: errors.New("validation failed: model does not support image input"),
22+
expected: true,
23+
},
24+
{
25+
name: "Embedding unsupported",
26+
err: errors.New("provider does not support embedding"),
27+
expected: true,
28+
},
29+
{
30+
name: "Claude endpoint unsupported",
31+
err: errors.New("channel does not support the v1/messages endpoint"),
32+
expected: true,
33+
},
34+
{
35+
name: "Internal conversion error",
36+
err: errors.New("json marshal failed"),
37+
expected: false,
38+
},
39+
{
40+
name: "Nil error",
41+
err: nil,
42+
expected: false,
43+
},
44+
}
45+
46+
for _, tc := range testCases {
47+
tc := tc
48+
t.Run(tc.name, func(t *testing.T) {
49+
t.Parallel()
50+
require.Equal(t, tc.expected, shouldTreatConvertRequestErrorAsBadRequest(tc.err))
51+
})
52+
}
53+
}
54+
55+
func TestWrapConvertRequestError(t *testing.T) {
56+
t.Parallel()
57+
58+
badRequestErr := wrapConvertRequestError(errors.New("validation failed: invalid multimodal content"))
59+
require.Equal(t, http.StatusBadRequest, badRequestErr.StatusCode)
60+
require.Equal(t, "invalid_request_error", badRequestErr.Code)
61+
62+
internalErr := wrapConvertRequestError(errors.New("marshal converted request failed"))
63+
require.Equal(t, http.StatusInternalServerError, internalErr.StatusCode)
64+
require.Equal(t, "convert_request_failed", internalErr.Code)
65+
}

relay/controller/response_fallback.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,7 @@ func relayResponseAPIThroughChat(c *gin.Context, meta *metalib.Meta, responseAPI
298298
convertedRequest, err := requestAdaptor.ConvertRequest(c, relaymode.ChatCompletions, chatRequest)
299299
if err != nil {
300300
billing.ReturnPreConsumedQuota(ctx, preConsumedQuota, meta.TokenId)
301-
return openai.ErrorWrapper(err, "convert_request_failed", http.StatusInternalServerError)
301+
return wrapConvertRequestError(err)
302302
}
303303
c.Set(ctxkey.ConvertedRequest, convertedRequest)
304304

0 commit comments

Comments
 (0)