diff --git a/backend/internal/service/upstream_response_model.go b/backend/internal/service/upstream_response_model.go index 81869c07d9..f4e3bd4fe0 100644 --- a/backend/internal/service/upstream_response_model.go +++ b/backend/internal/service/upstream_response_model.go @@ -167,10 +167,33 @@ func upstreamModelMismatch(sentModel, responseModel string) *bool { return nil } sentModel = strings.TrimSpace(sentModel) - mismatch := sentModel == "" || !strings.EqualFold(sentModel, responseModel) + mismatch := sentModel == "" || !upstreamModelsMatchForAudit(sentModel, responseModel) return &mismatch } +func upstreamModelsMatchForAudit(sentModel, responseModel string) bool { + if strings.EqualFold(sentModel, responseModel) { + return true + } + + // xAI reports the runtime build ID for these supported public aliases. + // Canonicalize only for mismatch auditing; keep the raw response model for + // observability and for the separate response-model billing safeguards. + sentGrokModel := canonicalGrokBuildRuntimeModel(sentModel) + return sentGrokModel != "" && sentGrokModel == canonicalGrokBuildRuntimeModel(responseModel) +} + +func canonicalGrokBuildRuntimeModel(model string) string { + switch strings.ToLower(strings.TrimSpace(model)) { + case "grok-4.5", "grok-4.5-latest", "grok-4.5-build": + return "grok-4.5-build" + case "grok-4.6", "grok-4.6-latest", "grok-4.6-build": + return "grok-4.6-build" + default: + return "" + } +} + func upstreamSentModel(requestedModel, upstreamModel string) string { sentModel := strings.TrimSpace(upstreamModel) if sentModel == "" { diff --git a/backend/internal/service/upstream_response_model_test.go b/backend/internal/service/upstream_response_model_test.go index a831e74e65..51374ec1f3 100644 --- a/backend/internal/service/upstream_response_model_test.go +++ b/backend/internal/service/upstream_response_model_test.go @@ -59,6 +59,77 @@ func TestUpstreamModelMismatchThreeStateAndCaseInsensitiveComparison(t *testing. require.True(t, *mismatched) } +func TestUpstreamModelMismatchTreatsGrokBuildRuntimeIDsAsAliases(t *testing.T) { + tests := []struct { + name string + sentModel string + responseModel string + }{ + { + name: "issue 5634 grok 4.6", + sentModel: "grok-4.6", + responseModel: "grok-4.6-build", + }, + { + name: "grok 4.6 latest", + sentModel: "grok-4.6-latest", + responseModel: "grok-4.6-build", + }, + { + name: "issue 5647 grok 4.5 latest", + sentModel: "grok-4.5-latest", + responseModel: "grok-4.5-build", + }, + { + name: "grok 4.5 canonical", + sentModel: "grok-4.5", + responseModel: "GROK-4.5-BUILD", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mismatch := upstreamModelMismatch(tt.sentModel, tt.responseModel) + + require.NotNil(t, mismatch) + require.False(t, *mismatch) + }) + } +} + +func TestUpstreamModelMismatchDoesNotCollapseDifferentModels(t *testing.T) { + tests := []struct { + name string + sentModel string + responseModel string + }{ + { + name: "different grok versions", + sentModel: "grok-4.5", + responseModel: "grok-4.6-build", + }, + { + name: "unrelated build suffix", + sentModel: "gpt-5.5", + responseModel: "gpt-5.5-build", + }, + { + name: "different grok runtime", + sentModel: "grok-build-0.1", + responseModel: "grok-4.5-build", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mismatch := upstreamModelMismatch(tt.sentModel, tt.responseModel) + + require.NotNil(t, mismatch) + require.True(t, *mismatch) + }) + } +} + func TestObserveOpenAISSEBodyIgnoresMalformedPayload(t *testing.T) { observer := &upstreamResponseModelObserver{} observeOpenAISSEBody(observer, "data: not-json\n\ndata: {\"type\":\"response.completed\",\"response\":{\"model\":\"gpt-5.4\"}}\n\n")