@@ -3,6 +3,7 @@ package groq
33import (
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+
2330func (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+ }
0 commit comments